├── .gitignore ├── CONTRIBUTING ├── LICENSE ├── README.md ├── TODO ├── USAGE.md ├── hkml ├── images └── hkml_interactive_list_demo.gif ├── manifests └── lore.js ├── release_note ├── scripts ├── __summary_mm_commits.py ├── mm_commits_changes.sh └── update_lore_manifest.sh ├── src ├── _hkml.py ├── _hkml_cli.py ├── _hkml_list_cache.py ├── hkml.py ├── hkml_cache.py ├── hkml_common.py ├── hkml_export.py ├── hkml_fetch.py ├── hkml_forward.py ├── hkml_init.py ├── hkml_interactive.py ├── hkml_list.py ├── hkml_manifest.py ├── hkml_monitor.py ├── hkml_open.py ├── hkml_patch.py ├── hkml_patch_format.py ├── hkml_reply.py ├── hkml_send.py ├── hkml_signature.py ├── hkml_sync.py ├── hkml_tag.py ├── hkml_thread.py ├── hkml_view.py ├── hkml_view_mails.py ├── hkml_view_text.py └── hkml_write.py └── tests ├── run.sh ├── test_hkml_common.py ├── test_hkml_patch_format.py ├── test_hkml_view.py ├── test_hkml_view_mails.py └── test_hkml_view_text.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .hkm 3 | .hkml_log 4 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | General Process 2 | =============== 3 | 4 | For contributions, please refer to the linux kernel development process[1] and 5 | send patches via GitHub PullRequests or mails to sj@kernel.org. The 6 | contributions should have proper 'Signed-off-by:' tags[2]. The tag will be 7 | considered the same as that of the linux kernel development process. 8 | 9 | [1] https://docs.kernel.org/process/index.html 10 | [2] https://docs.kernel.org/process/submitting-patches.html#sign-your-work-the-developer-s-certificate-of-origin 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | HacKerMaiL 2 | ========== 3 | 4 | HacKerMaiL (hkml) is a mails management tool for hackers who collaborate using 5 | mailing lists. It requires no complicated setup but just `git`. Using it, you 6 | can fetch mailing list archives, read mails in those, and post replies or new 7 | mails. 8 | 9 | For now, hackermail supports 10 | [public-inbox](https://public-inbox.org/design_notes.html) managed mailing list 11 | archives and manually exported mbox file contents. Linux Kernel Mailing Lists 12 | [(LKML)](https://kernel.org/lore.html) are good examples of the public-inbox 13 | managed mailing list archives. Specifically, it is being used for, and aimed 14 | to support development of [DAMON](https://damonitor.github.io) and general 15 | parts of Linux kernel. 16 | 17 | 18 | Demo 19 | ==== 20 | 21 | [![asciicast](https://asciinema.org/a/632442.svg)](https://asciinema.org/a/632442) 22 | ![interactive list](images/hkml_interactive_list_demo.gif) 23 | 24 | (Note that the above demos are recorded on an old version of `hkml`. Latest 25 | version may have different features/interface.) 26 | 27 | 28 | Getting Started 29 | =============== 30 | 31 | List recent mails in Linux kernel DAMON subsystem mailing list 32 | (https://lore.kernel.org/damon): 33 | 34 | $ ./hkml list damon --fetch 35 | 36 | For the first time, the command will ask you if you want to initialize the 37 | setup, with some questions. For this specific case (listing mails of a Linux 38 | kernel subsystem mailing list), you can simply select default options. Then, 39 | it will open an interactive list of the mails. From it, users can do actions 40 | for mails including below. 41 | 42 | - Opening a mail 43 | - Listing complete thread of a mail 44 | - Replying to a mail 45 | - Forwarding a mail 46 | - Continue writing a draft mail 47 | - Managing tags of a mail 48 | - Checking/applying patch mails 49 | - Exporting mails 50 | 51 | Press '?' for help, or read "Interactive Viewer" 52 | [section](USAGE.md#interactive-viewer) of [USAGE.md](USAGE.md). 53 | 54 | For more detail and complete list of features, 55 | 56 | $ ./hkml -h 57 | $ ./hkml list -h 58 | 59 | or refer to [USAGE.md](USAGE.md) file. For daily use of `hkml`, particularly, 60 | you may want to know how to [manage tags](USAGE.md#tagging) of mails (useful 61 | for managing 'sent' and 'drafts' mails) and how you can 62 | [backup/syncronize](USAGE.md#synchronizing) those. 63 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | - Cleanup code 2 | - Monitoring --pisearch updates 3 | - Monitoring tagged threads 4 | - Implement complete TUI 5 | - Support every feature with TUI 6 | - Deprecate TUI menu and related code 7 | - Give selection to tag removing menu 8 | - Update lore manifest automatically 9 | - Implement monitoring output pushing to git repo 10 | - Keep applying dimming for threads that opened from a list 11 | - e.g., if hkml list --dim_old 2024-11-16 and open complete thread of a mail 12 | on the list, I'd want the thread also have same dim setup. 13 | - Allow listing recently opened threads 14 | - Give users ability to change color for (+), (-), (>) lines in text viewer 15 | - Support custom list format, like 'damo report access --format_region' 16 | - Or, just remove the unnecessary mail index for non --stdout case? 17 | - Let interactive mails selection and menu for slected mails 18 | - Clone and fetch old epoch git for searching if needed 19 | - Make hidden context menu easier to find 20 | - Rate-limit lore traffic 21 | - Ask 'hkml patch format' tagging patches as sent-patches or drafts-patches. 22 | - Support commits <-> patches queue conversion 23 | - 'patch format --series' creates 'series' file together 24 | - 'series' file contains name of patches in order, and baseline commit 25 | - 'patch apply' should be able to receive the 'series' file and assemble tree back 26 | - Support tagging patch series easier 27 | - Let mails cache reading delay explainable 28 | - E.g., when cached list output is used and --dim_old is set, dim handler 29 | reads mail's sent date, and it makes delay if mail cache is big. Same for 30 | first mail read. This makes user experience bad. Think about improvement. 31 | - Let adding quick note on viewer 32 | - The notes should be automatically added on reply draft. 33 | - Let easier browsing on long mail 34 | - Let lines folding? 35 | - When applying patch, 'Signed-off-by:' of the user is added to the commit with 36 | a blank line. Investigate and fix. 37 | - Color the focused line on mails list? 38 | - Periodic mails list refresh 39 | - May better to double confirm if this is what we really need, though. 40 | 'hkml monitor' can be used for a similar purpose. 41 | - Search/highlight mails based on --from keywords. 42 | -------------------------------------------------------------------------------- /hkml: -------------------------------------------------------------------------------- 1 | src/hkml.py -------------------------------------------------------------------------------- /images/hkml_interactive_list_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjp38/hackermail/d2b8274f8e3694c665a806b9b9a014b3fd02e477/images/hkml_interactive_list_demo.gif -------------------------------------------------------------------------------- /release_note: -------------------------------------------------------------------------------- 1 | This file contains major changes in each version since v1.0.0 2 | 3 | v1.4.0 4 | - Support relative date format (e.g., -4 days) 5 | - Fix mails display effects menu on the interactive list 6 | - Support thread level filtering (--keywords_for thread) 7 | 8 | v1.3.9 9 | - hkml list: implement '--keywords_for root' to apply keywords filtering to 10 | threads root mails only. 11 | - hkml list: implement '--patches_for' to filter patch mails to review (didn't 12 | receive Reviewed-by: yet), pick (received Reviewed-by:), or for specific 13 | reviewer (touch files for the reviewer, according to MAINTAINERS). 14 | - hkml list: implement '--keywords' for replacing '*_keywords' options. 15 | - hkml list: cleanup help message 16 | 17 | v1.3.8 18 | - Show mail context of current line for current mail's original line, too 19 | - Enhance readability of the context line 20 | - hkml list: extend 'search mails' menu for MAINTAINERS-listed reviewers 21 | - Kudos to Lorenzo Stoakes for idea 22 | 23 | v1.3.7 24 | - Show mail context of current line at the bottom of the screen 25 | - Add menu for jumping context to different depth context of the mail 26 | 27 | v1.3.6 28 | - hkml list: extend 'search mails' menu for 'Reviewed-by:' 29 | - search patch mails received or not yet received the tag 30 | - Kudos to Boris Burkov for idea 31 | - hkml list: let menu open without selected mails 32 | 33 | v1.3.5 34 | - hkml patch format: add Cc: on patch commit messages to CV recipients 35 | - suggest wrapping text if longest line is >2x of screen width 36 | 37 | v1.3.4 38 | - ensure terminal outputs have time to read before starting curses mode 39 | - add Cc: tags to downloaded patch files 40 | 41 | v1.3.3 42 | - hkml list: improve --pisearch reliability 43 | - hkml list: implement --options_for for concise help message for specific purpose 44 | - hkml list: show progress of works and their runtimes interactively 45 | - improve public inbox traffic control 46 | 47 | v1.3.2 48 | - hkml patch: allow not adding Link: tag 49 | - hkml patch: define and support CV as baseline and merge commits for all major 50 | use case 51 | 52 | v1.3.1 53 | - hkml patch apply: support merge-based patchset handling workflow. 54 | - Support exporting mails in valid but human/chatbot-friendly mbox format. 55 | 56 | v1.3.0 57 | - hkml patch format: review patch subjects 58 | - hkml manifest: add an action for updating lore.kernel.org manifest: 'fetch_lore' 59 | - hkml patch format: support specifying commits with their subjects 60 | 61 | v1.2.9 62 | - Support using commit for date specification. 63 | - Convert tabs to eight spaces on interactive screens. 64 | - Support wrapping text on interactive text viewer. 65 | - Support mail body keyword searching from the interactive list. 66 | - Show only older version lists creation dates as dim_old suggestions. 67 | - Hide 'which' stderr output. 68 | 69 | v1.2.8 70 | - Decode any header lines with any encoding 71 | - Add a menu to open new list from interactive list 72 | - Internal code cleanup 73 | 74 | v1.2.7 75 | - Support making coverletter bogus commit (hkml patch commit_cv) 76 | - Support filling up cover letter with file 77 | - Support multiple text editors and respect $EDITOR 78 | - Internal code cleanup 79 | 80 | v1.2.6 81 | - hkml patch format: fill up cover letter to be ready to be posted 82 | - hkml patch format: provide preview of automatically filled cover letter 83 | - hkml patch format: support - commit range input, e.g., hkml patch format -5 84 | - hkml write: remove unremoved recipients draft comments before sending 85 | - hkml patch format: support sending the patches directly 86 | - let user searches keywords without highlighting 87 | - hkml list: set searching '[' without higlighting by default 88 | - let user moves between mails with 'n' and 'N' 89 | 90 | v1.2.5 91 | - hkml patch format: support source file on --to and --cc 92 | 93 | v1.2.4 94 | - Fix display effect no input handling crash 95 | - hkml patch format: suggest to run/show checkpatch.pl output and recipients summary 96 | - hkml patch review: show common and additional recipients 97 | - Display mail subjects at the end of headers 98 | - hkml list: Show row number of the mail of the msgid when the list is made for 99 | the msgid 100 | 101 | v1.2.3 102 | - More features for 'patch format' 103 | - Support --to and --cc 104 | - Set automatically added recipients as only Cc 105 | - Suggest setting someone on Cc as To, if no --to is provided 106 | - Fix incorrect encoding/decoding handling 107 | 108 | v1.2.2 109 | - Suggest removing draft when the mail is sent 110 | - Let cursor moves horizontally 111 | - Remember and restore cursor position of previous-opened mails 112 | - Let users specify where to save patch files (export patch files menu) 113 | - Support formatting patch files 114 | 115 | v1.2.1 116 | - Implement 'dim old mails' mails list menu 117 | - Suggest dimming old mails for cached list, too 118 | - Cleanup draft edit content 119 | - Place cursor at the beginning of the focused line instead of colorizing 120 | 121 | v1.2.0 122 | - Maintain recent list opened dates in a separate file 123 | - Colorize diff and past message lines. 124 | - Suggest deleting re-written drafts even after the subject is changed. 125 | 126 | v1.1.9 127 | - Set default answer to drafts continue question as 'yes' for latest draft 128 | - Implement 'signature' command 129 | - Automatically add signature to mail drafts 130 | - Support arrow keys on list 131 | 132 | v1.1.8 133 | - Add up to ten other dates selection for --dim_old suggestion 134 | - Set --dim_old suggestion answer as 'y' by default 135 | - Suggest to continue draft writing when replying 136 | - Suggest setting --dim_old for threads 137 | - Don't find ancestor mails on cache for tagged mails listing 138 | 139 | v1.1.7 140 | - Support more flexible date formats 141 | - list: Suggest setting --dim_old to last same list generated time 142 | - list: Sort patch series in series order 143 | 144 | v1.1.6 145 | - Implement a menu for adding display effects to mails of specific dates range 146 | - list: Support public inbox link URL as mails source 147 | - list: Find threads parents that older than --since from cache, by default 148 | (can disable using --dont_find_ancestors_from_cache) 149 | - list: implement an option to set old mails dimm-ed (--dim_old) 150 | 151 | v1.1.5 152 | - Support multiple keywords lists options for OR-relation 153 | - Support <3.9 Python 154 | - Fix a few corner cases including UTF-8 encoded mail headers 155 | - Wordsmith exported patch file names 156 | - Ask whether to merge CV into the first patch from the interactive list UI 157 | 158 | v1.1.4 159 | - Support collapsing/expanding threads on list ('c' and 'e' key bindings) 160 | - Support mails list refreshing 161 | 162 | v1.1.3 163 | - Remove TUI menus and use only CLI menus with 'm' key binding 164 | - Support reply/forwarding key binding from mail read screen 165 | - Support patches exporting 166 | - Support patches handling from interactive text viewer 167 | 168 | v1.1.2 169 | - Support horizontal scroll ('h', 'l' key for scroll left/right) 170 | - Support opening files via hkml or vim from hkml text viewer's context menu 171 | - Support commands as 'hkml open' target types 172 | - Support '-C', '--directory' option similar to 'make' and 'git' 173 | - Support CLI menus ('M' key) 174 | 175 | v1.1.1 176 | - Mention DAMON and general Linux kernel workflow as supported 177 | - More key bindings for interactive screen 178 | - 'J', 'K': focus donw/up 1/2 screen 179 | - ':': focus arbitrary line or 'start'/'end' 180 | - 'Q': terminate hkml at once 181 | - 'n', 'N': focus next/prev row having highlighted keyword 182 | - Support attaching files to reply/forward from interactive viewer 183 | - Support saving content of screen to a file or the clipboard 184 | - Fixup slow scroll speed of threads 185 | - Support Message-Id mails source type from 'hkml list' 186 | 187 | v1.1.0 188 | - 'hkml open' improvements 189 | - Use interactive viewer 190 | - Support opening normal text file and git commit 191 | - Interactive viewer improvements 192 | - Implement general menu ('m' key) for mails list and text viewer 193 | - Support forwarding, continuing draft writing, tagging, patches 194 | checking/applying, exporting 195 | - Document interactive viewer on USAGE.md 196 | 197 | v1.0.9 198 | - Support thread listing on interactive mails list ('t' key press) 199 | - Reduce tag syncing failure possibility (let sync before and after change) 200 | - Support actions from mail content screen ('Enter' key press) 201 | - Let git-log/show of commit id 202 | - Let thread-listing /opening of public-inbox URL 203 | 204 | v1.0.8 205 | - Implement --attach option for write,reply,forward commands 206 | - Implement interactive mail list/thread interface 207 | 208 | v1.0.7 209 | - Decode mail payload with proper character sets 210 | - Confirm mail sending only once 211 | - Help git-email SMTP setup from init command 212 | 213 | v1.0.6 214 | - Suggest tagging sent mail as 'sent' instead of 'drafts_sent' 215 | - Save real message-id to 'sent' tagged mails 216 | - Rename --lore options to --url 217 | 218 | v1.0.5 219 | - Decorate last-referenced mail on list/thread 220 | - Fix wrong cover letter identification of 'patch apply' 221 | - Use 'scripts/checkpatch.pl' from 'patch check' by default 222 | 223 | v1.0.4 224 | - Optimize 'hkml list --pisearch' 225 | - Support message id input to 'hkml thread' 226 | - Drop internal b4 usages 227 | - Support merging cover letter into first patch of the series ('hkml patch') 228 | 229 | v1.0.3 230 | - Support public-inbox search ('hkml list --pisearch') 231 | - Support keywords option without '_keywords' suffices 232 | 233 | v1.0.2 234 | - Support tagging written mail as drafts 235 | - Support resuming writing of draft mails ('hkml write --draft') 236 | - Ask synchronization for every tag update 237 | 238 | v1.0.1 239 | - Implement a command, 'sync', to support remote backup/restore 240 | - patch: Support patch series and use b4 internally if available 241 | - init: Use manifests/lore.js as manifest file by default 242 | -------------------------------------------------------------------------------- /scripts/__summary_mm_commits.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # SPDX-License-Identifier: GPL-2.0 3 | 4 | import argparse 5 | import sys 6 | 7 | ''' 8 | mails to parse 9 | 10 | incoming 11 | mmotm 2021-10-05-19-53 uploaded 12 | + mm-damon-dbgfs-support-physical-memory-monitoring.patch added to -mm tree 13 | [to-be-updated] aa-bbb-ccc-blah.patch removed from -mm tree 14 | [obsolete] aaa.patch removed from -mm tree 15 | [withdrawn] blah-blah.patch removed from -mm tree 16 | [nacked] memblock-neaten-logging.patch removed from -mm tree 17 | [folded-merged] aa-bb-cc.patch removed from -mm tree 18 | [merged] aa-bb-cc.patch removed from -mm tree 19 | 20 | expected inputs are for example: 21 | 22 | [0000] + crash_dump-fix-boolreturncocci-warning.patch added to -mm tree 23 | (Andrew Morton, 10/29, 0+ 24 | msgs) 25 | [0001] + crash_dump-remove-duplicate-include-in-crash_dumph.patch added to -mm 26 | tree (Andrew Morton, 10/29, 0+ msgs) 27 | [0002] + seq_file-fix-passing-wrong-private-data.patch added to -mm tree 28 | (Andrew Morton, 10/29, 0+ msgs) 29 | ''' 30 | 31 | class MmCommits: 32 | date = None 33 | action = None 34 | from_to = None 35 | patch_title = None 36 | 37 | def __init__(self, date, action, from_to, patch_title): 38 | self.date = date 39 | self.action = action 40 | self.from_to = from_to 41 | self.patch_title = patch_title 42 | 43 | def parse_mails(msg): 44 | mails = [] 45 | mail = '' 46 | for line in msg.split('\n'): 47 | if not line.startswith('[') and not line.startswith(' '): 48 | continue 49 | if not line.startswith(' '): 50 | if mail != '': 51 | mails.append(mail) 52 | mail = '' 53 | mail = ' '.join([mail, line.strip()]) 54 | if mail != '': 55 | mails.append(mail) 56 | 57 | added = [] 58 | removed = [] 59 | actions = {} 60 | for mail in mails: 61 | tokens = mail.split() 62 | if len(tokens) < 1: 63 | continue 64 | date = tokens[-4] 65 | tokens = tokens[1:] 66 | if len(tokens) < 9: 67 | continue 68 | tag = tokens[0] 69 | patch = tokens[1].split('.patch')[0] 70 | action = ' '.join(tokens[2:6]) 71 | if tag == '+' and action.startswith('added to '): 72 | dst_tree = tokens[4] 73 | added.append(MmCommits(date, 'added', dst_tree, patch)) 74 | actions['added'] = True 75 | if action.startswith('removed from '): 76 | src_tree = tokens[4] 77 | removed.append(MmCommits(date, tag, src_tree, patch)) 78 | actions[tag] = True 79 | return added, removed, actions 80 | 81 | def __pr_parsed_changes(added, removed, actions): 82 | print('added patches') 83 | print('-------------') 84 | print() 85 | for commit in added: 86 | print('%s (%s)' % (commit.patch_title, commit.from_to)) 87 | 88 | print() 89 | print('removed patches') 90 | print('---------------') 91 | print() 92 | for action in actions: 93 | commits = [x for x in removed if x.action == action] 94 | for commit in commits: 95 | print('%s %s (%s)' % 96 | (commit.action, commit.patch_title, commit.from_to)) 97 | 98 | print() 99 | print('%d added, %d removed' % (len(added), len(removed))) 100 | 101 | def pr_parsed_changes(added, removed, actions, daily): 102 | if not daily: 103 | __pr_parsed_changes(added, removed, actions) 104 | return 105 | 106 | days = {} 107 | for commit in added + removed: 108 | days[commit.date] = True 109 | 110 | for day in sorted(days.keys()): 111 | print(day) 112 | print('=' * len(day)) 113 | print() 114 | daily_added = [x for x in added if x.date == day] 115 | daily_removed = [x for x in removed if x.date == day] 116 | daily_actions = {} 117 | for c in daily_removed: 118 | daily_actions[c.action] = True 119 | __pr_parsed_changes(daily_added, daily_removed, daily_actions) 120 | print() 121 | 122 | def main(): 123 | parser = argparse.ArgumentParser() 124 | parser.add_argument('--total', action='store_true', 125 | help='Print in total, not daily') 126 | args = parser.parse_args() 127 | 128 | added, removed, actions = parse_mails(sys.stdin.read()) 129 | 130 | pr_parsed_changes(added, removed, actions, not args.total) 131 | 132 | if __name__ == '__main__': 133 | main() 134 | -------------------------------------------------------------------------------- /scripts/mm_commits_changes.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | pr_usage() 4 | { 5 | echo "Usage: $0 [OPTION]... [duration in days]" 6 | echo 7 | echo "OPTION" 8 | echo " -h, --help Show this message" 9 | } 10 | 11 | function pr_usage_exit { 12 | exit_code=$1 13 | pr_usage 14 | exit "$exit_code" 15 | } 16 | 17 | duration_days=7 18 | 19 | while [ $# -ne 0 ] 20 | do 21 | case $1 in 22 | "--help" | "-h") 23 | pr_usage_exit 0 24 | ;; 25 | *) 26 | if [ $# -ne 1 ] 27 | then 28 | pr_usage_exit 1 29 | fi 30 | duration_days=$1 31 | break 32 | ;; 33 | esac 34 | done 35 | 36 | since=$(date --date="-$duration_days day" +%Y-%m-%d) 37 | 38 | bindir=$(dirname "$0") 39 | 40 | "$bindir/../hkml" list mm-commits --fetch -cn --since "$since" \ 41 | --min_nr_mails 0 --stdout | "$bindir/__summary_mm_commits.py" 42 | -------------------------------------------------------------------------------- /scripts/update_lore_manifest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | bindir=$(dirname "$0") 4 | 5 | wget https://lore.kernel.org/manifest.js.gz 6 | gzip -d manifest.js.gz 7 | "$bindir/../hkml" manifest convert_public_inbox_manifest \ 8 | --public_inbox_manifest ./manifest.js \ 9 | --site https://lore.kernel.org > "$bindir/../manifests/lore.js" 10 | -------------------------------------------------------------------------------- /src/_hkml_cli.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0 2 | 3 | ''' 4 | object for Question.ask_selection() 5 | 'text' is displayed to the user with the selection question. 6 | 'handle_fn' of user-selected one is called if it is not None. The function 7 | receives user-set Question.data, user-entered input (number), and the 8 | selected Selection object. 9 | 'data' can save any selection-specific data. 10 | ''' 11 | class Selection: 12 | text = None 13 | handle_fn = None # function receiving question data, answer, selection 14 | data = None # for carrying selection-specific data 15 | 16 | def __init__(self, text, handle_fn=None, data=None): 17 | self.text = text 18 | self.handle_fn = handle_fn 19 | self.data = data 20 | 21 | ''' 22 | question that will be provided to the user, in shell mode. 23 | ask_input() and ask_selection() are the methods that user will really use. 24 | ''' 25 | class Question: 26 | description = None 27 | prompt = None 28 | 29 | def __init__(self, prompt=None, desc=None): 30 | self.description = desc 31 | self.prompt = prompt 32 | 33 | ''' 34 | internal method. Shouldn't be called directly from Question user. 35 | ''' 36 | def ask(self, data, selections, handle_fn, notify_completion, 37 | default_selection=None): 38 | # return answer, selection, and error 39 | lines = [''] 40 | if self.description is not None: 41 | lines.append(self.description) 42 | lines.append('') 43 | if selections is not None: 44 | for idx, selection in enumerate(selections): 45 | lines.append('%d: %s' % (idx + 1, selection.text)) 46 | lines.append('') 47 | if len(lines) > 0: 48 | print('\n'.join(lines)) 49 | 50 | if default_selection is None: 51 | prompt = '%s (enter \'\' to cancel): ' % self.prompt 52 | else: 53 | prompt = '%s (enter \'\' for \'%s\', \'cancel\' to cancel): ' % ( 54 | self.prompt, default_selection.text) 55 | 56 | answer = input(prompt) 57 | if answer == '': 58 | if default_selection is None: 59 | print('Canceled.') 60 | return None, None, 'canceled' 61 | else: 62 | print('The default (%s) selected.' % default_selection.text) 63 | return '', default_selection, None 64 | 65 | if answer == 'cancel' and default_selection is not None: 66 | print('Canceled.') 67 | return None, None, 'canceled' 68 | 69 | selection = None 70 | selection_handle_fn = None 71 | if selections is not None: 72 | try: 73 | selection = selections[int(answer) - 1] 74 | selection_handle_fn = selection.handle_fn 75 | except: 76 | print('Wrong input.') 77 | return None, None, 'wrong input' 78 | 79 | if selection_handle_fn is not None: 80 | err = selection_handle_fn(data, answer, selection) 81 | elif handle_fn is not None: 82 | err = handle_fn(data, answer) 83 | if err: 84 | return None, None, err 85 | 86 | if notify_completion: 87 | print('Done.') 88 | return answer, selection, None 89 | 90 | ''' 91 | Ask user to answer any text input. If handle_fn is not None, the function 92 | is called with 'data' and user's input to the question. 93 | 94 | Should be invoked in shell mode (show shell_mode_start() and 95 | shell_mode_end()). 96 | 97 | Returns user's input to the question and error. 98 | ''' 99 | def ask_input(self, data=None, handle_fn=None, notify_completion=False): 100 | answer, selection, err = self.ask(data, None, handle_fn, 101 | notify_completion) 102 | return answer, err 103 | 104 | ''' 105 | Ask user to select on of Selection objects. If the selected 106 | Selection has handle_fn field set, it is invoked. 107 | 108 | Should be invoked in shell mode (show shell_mode_start() and 109 | shell_mode_end()). 110 | 111 | Returns user's input to the question, selected Selection, and error. 112 | ''' 113 | def ask_selection(self, selections, data=None, notify_completion=False, 114 | default_selection=None): 115 | if self.prompt is None: 116 | self.prompt = 'Enter the item number' 117 | return self.ask(data, selections, None, notify_completion, 118 | default_selection) 119 | 120 | def ask_input(desc=None, prompt=None, handler_data=None, 121 | handle_fn=None): 122 | ''' 123 | Prints 'desc', a blank line, and 'prompt'. Then, wait for user input. For 124 | given input, 'handle_fn' is called with 'handler_data' and the user input. 125 | 126 | Returns user's input to the question and an error. 127 | ''' 128 | return Question(desc=desc, prompt=prompt).ask_input( 129 | data=handler_data, handle_fn=handle_fn) 130 | 131 | def ask_selection(desc=None, selections=None, prompt=None, 132 | handler_common_data=None, default_selection=None): 133 | ''' 134 | Prints 'desc', a blank line, 'selections', and 'prompt'. Then, wait for 135 | user selection. For given user input, 'handle_fn' of the selected 136 | 'Selection' object is called with 'handler_common_data', the user input and 137 | the selected 'Selection' object. Note that each 'Selection' object can 138 | have its own data for slection-specific one. 139 | 140 | Returns user's input to the question, the 'Selection' object, and an error. 141 | ''' 142 | return Question(desc=desc, prompt=prompt).ask_selection( 143 | selections=selections, data=handler_common_data, 144 | default_selection=default_selection) 145 | -------------------------------------------------------------------------------- /src/_hkml_list_cache.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # SPDX-License-Identifier: GPL-2.0 3 | 4 | import datetime 5 | import os 6 | import json 7 | 8 | import _hkml 9 | import hkml_cache 10 | 11 | ''' 12 | A dict containing history of cache. Saved as file. Will be used for dim_old 13 | suggestion. Keys are the json string of the list command argumetns. 14 | Values are a dict containing below key/values. 15 | - 'create_dates': last up to ten created dates of same key 16 | ''' 17 | cache_history = None 18 | 19 | def cache_history_file_path(): 20 | return os.path.join(_hkml.get_hkml_dir(), 'list_output_cache_history') 21 | 22 | def get_cache_history(): 23 | global cache_history 24 | 25 | if cache_history is None: 26 | if not os.path.isfile(cache_history_file_path()): 27 | cache_history = {} 28 | else: 29 | with open(cache_history_file_path(), 'r') as f: 30 | cache_history = json.load(f) 31 | return cache_history 32 | 33 | def writeback_cache_history(): 34 | history = get_cache_history() 35 | with open(cache_history_file_path(), 'w') as f: 36 | json.dump(history, f, indent=4) 37 | 38 | def record_cache_creation(cache_key): 39 | history = get_cache_history() 40 | if not cache_key in history: 41 | history[cache_key] = {} 42 | create_dates = [] 43 | if 'create_dates' in history[cache_key]: 44 | create_dates = history[cache_key]['create_dates'][-9:] 45 | create_dates.append(datetime.datetime.now().strftime('%Y-%m-%d-%H-%M-%S')) 46 | history[cache_key]['create_dates'] = create_dates 47 | writeback_cache_history() 48 | 49 | ''' 50 | Cache previously generated mails lists data for later fast processing and 51 | context management. 52 | Keys are the json string of the list command arguments if generated by list 53 | command, or 'thread_output' of generated by thread command. 54 | Values are a dict containing below key/values. 55 | - 'output': formatted text to display the mails list. 56 | - 'index_to_cache_key': a dict having the mail index on the output as keys, and 57 | the corresponding mail's key in the mail cache as values. 58 | - 'date': last accessed date 59 | - 'create_date': created date. Removed after v1.1.7. 60 | - 'create_dates': last up to ten created dates of same key. Removed after v1.1.9. 61 | ''' 62 | mails_lists_cache = None 63 | 64 | def list_output_cache_file_path(): 65 | return os.path.join(_hkml.get_hkml_dir(), 'list_output_cache') 66 | 67 | def get_mails_lists_cache(): 68 | global mails_lists_cache 69 | 70 | if mails_lists_cache is None: 71 | if not os.path.isfile(list_output_cache_file_path()): 72 | mails_lists_cache = {} 73 | else: 74 | with open(list_output_cache_file_path(), 'r') as f: 75 | mails_lists_cache = json.load(f) 76 | if mails_lists_cache is None: 77 | mails_lists_cache = {} 78 | return mails_lists_cache 79 | 80 | def writeback_list_output(): 81 | cache = get_mails_lists_cache() 82 | with open(list_output_cache_file_path(), 'w') as f: 83 | json.dump(cache, f, indent=4) 84 | 85 | def get_cached_list_outputs(key): 86 | cache = get_mails_lists_cache() 87 | if not key in cache: 88 | return None 89 | outputs = cache[key] 90 | # update last accessed date 91 | outputs['date'] = datetime.datetime.now().strftime('%Y-%m-%d-%H-%M-%S') 92 | return outputs 93 | 94 | def get_list_for(key): 95 | outputs = get_cached_list_outputs(key) 96 | if outputs is None: 97 | return None, None 98 | return outputs['output'], outputs['index_to_cache_key'] 99 | 100 | def get_cache_creation_dates(key): 101 | cache = get_mails_lists_cache() 102 | if not key in cache: 103 | return [] 104 | outputs = cache[key] 105 | date_strs = [] 106 | # 'create_dates' field has added after v1.1.7, removed after v1.1.9 107 | if 'create_dates' in outputs: 108 | date_strs = outputs['create_dates'] 109 | # 'create_date' field has added after v1.1.6, removed after v1.1.7 110 | if 'create_date' in outputs: 111 | date_strs = [outputs['create_date']] 112 | 113 | # cache history has added after v1.1.9 114 | history = get_cache_history() 115 | if key in history: 116 | date_strs += history[key]['create_dates'] 117 | date_strs = sorted(set(date_strs))[-10:] 118 | return [datetime.datetime.strptime(s, '%Y-%m-%d-%H-%M-%S').astimezone() 119 | for s in date_strs] 120 | 121 | def get_last_mails_list(): 122 | cache = get_mails_lists_cache() 123 | keys = [k for k in cache] 124 | key = sorted(keys, key=lambda x: cache[x]['date'])[-1] 125 | outputs = get_cached_list_outputs(key) 126 | if outputs is None: 127 | return None, None 128 | return outputs['output'], outputs['index_to_cache_key'] 129 | 130 | def get_last_list(): 131 | cache = get_mails_lists_cache() 132 | keys = [k for k in cache if k != 'thread_output'] 133 | key = sorted(keys, key=lambda x: cache[x]['date'])[-1] 134 | outputs = get_cached_list_outputs(key) 135 | if outputs is None: 136 | return None 137 | return outputs['output'], outputs['index_to_cache_key'] 138 | 139 | def get_last_thread(): 140 | cache = get_mails_lists_cache() 141 | outputs = get_cached_list_outputs('thread_output') 142 | if outputs is None: 143 | return None 144 | return outputs['output'], outputs['index_to_cache_key'] 145 | 146 | def invalidate_cached_outputs(source): 147 | keys_to_del = [] 148 | cache = get_mails_lists_cache() 149 | for key in cache.keys(): 150 | try: 151 | key_dict = json.loads(key) 152 | if key_dict['source'] == source: 153 | keys_to_del.append(key) 154 | except: 155 | pass 156 | for key in keys_to_del: 157 | del cache[key] 158 | 159 | def writeback_list_output_cache(): 160 | cache = get_mails_lists_cache() 161 | with open(list_output_cache_file_path(), 'w') as f: 162 | json.dump(cache, f, indent=4) 163 | 164 | def set_item(key, list_data): 165 | list_str = list_data.text 166 | mail_idx_key_map = list_data.mail_idx_key_map 167 | cache = get_mails_lists_cache() 168 | changed = False 169 | if key in cache: 170 | changed = cache[key]['index_to_cache_key'] != mail_idx_key_map 171 | now_str = datetime.datetime.now().strftime('%Y-%m-%d-%H-%M-%S') 172 | 173 | comments_lines = list_data.comments_lines 174 | if len(comments_lines) > 0: 175 | if comments_lines[-1].startswith('# mail of the msgid is at row '): 176 | fields = comments_lines[-1].split() 177 | new_nr = int(fields[8]) + 1 178 | new_line = ' '.join(fields[:8] + ['%d' % new_nr] + fields[9:]) 179 | comments_lines = comments_lines[:-1] + [new_line] 180 | mails_lines = list_data.mail_lines 181 | list_str = '\n'.join(comments_lines + mails_lines) 182 | 183 | cache[key] = { 184 | 'output': '\n'.join(['# (cached output)', list_str]), 185 | 'index_to_cache_key': mail_idx_key_map, 186 | 'date': now_str, # last referenced date 187 | } 188 | max_cache_sz = 64 189 | if len(cache) == max_cache_sz: 190 | keys = sorted(cache.keys(), key=lambda x: cache[x]['date']) 191 | del cache[keys[0]] 192 | writeback_list_output_cache() 193 | if changed: 194 | record_cache_creation(key) 195 | 196 | def get_mail(idx, not_thread_idx=False): 197 | cache = get_mails_lists_cache() 198 | sorted_keys = sorted(cache.keys(), key=lambda x: cache[x]['date']) 199 | if not_thread_idx and sorted_keys[-1] == 'thread_output': 200 | last_key = sorted_keys[-2] 201 | else: 202 | last_key = sorted_keys[-1] 203 | idx_to_keys = cache[last_key]['index_to_cache_key'] 204 | idx_str = '%d' % idx 205 | if not idx_str in idx_to_keys: 206 | return None 207 | 208 | output_string_lines = cache[last_key]['output'].split('\n') 209 | if output_string_lines[0].startswith('# last reference: '): 210 | output_string_lines = output_string_lines[2:] 211 | output_string_lines = ['# last reference: %d' % idx, 212 | '#'] + output_string_lines 213 | cache[last_key]['output'] = '\n'.join(output_string_lines) 214 | writeback_list_output() 215 | 216 | mail_key = idx_to_keys[idx_str] 217 | return hkml_cache.get_mail(key=mail_key) 218 | 219 | def last_listed_mails(): 220 | cache = get_mails_lists_cache() 221 | last_key = sorted(cache.keys(), key=lambda x: cache[x]['date'])[-1] 222 | idx_to_keys = cache[last_key]['index_to_cache_key'] 223 | mails = [] 224 | for idx in sorted([int(idx) for idx in idx_to_keys.keys()]): 225 | cache_key = idx_to_keys['%d' % idx] 226 | mail = hkml_cache.get_mail(key=cache_key) 227 | if mail is not None: 228 | mail.pridx = int(idx) 229 | mails.append(mail) 230 | return mails 231 | -------------------------------------------------------------------------------- /src/hkml.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # SPDX-License-Identifier: GPL-2.0 3 | 4 | import argparse 5 | import os 6 | import subprocess 7 | import sys 8 | 9 | import hkml_init 10 | import hkml_interactive 11 | import hkml_fetch 12 | import hkml_list 13 | import hkml_thread 14 | import hkml_open 15 | import hkml_reply 16 | import hkml_forward 17 | import hkml_tag 18 | import hkml_send 19 | import hkml_write 20 | import hkml_sync 21 | import hkml_export 22 | import hkml_monitor 23 | import hkml_patch 24 | import hkml_manifest 25 | import hkml_cache 26 | import hkml_signature 27 | 28 | import _hkml 29 | 30 | class SubCmdHelpFormatter(argparse.RawDescriptionHelpFormatter): 31 | def _format_action(self, action): 32 | parts = super(argparse.RawDescriptionHelpFormatter, 33 | self)._format_action(action) 34 | # Skips subparsers help 35 | if action.nargs == argparse.PARSER: 36 | parts = '\n'.join(parts.split('\n')[1:]) 37 | return parts 38 | 39 | parser = argparse.ArgumentParser(formatter_class=SubCmdHelpFormatter) 40 | parser.add_argument('--hkml_dir', metavar='hkml dir', type=str) 41 | parser.add_argument('-C', '--directory', metavar='', 42 | help='change to before doing anything') 43 | 44 | subparsers = parser.add_subparsers(title='command', dest='command', 45 | metavar='') 46 | # subparsers.default = 'interactive' 47 | 48 | parser_init = subparsers.add_parser('init', help = 'initialize working dir') 49 | hkml_init.set_argparser(parser_init) 50 | 51 | parser_fetch = subparsers.add_parser('fetch', help = 'fetch mails') 52 | hkml_fetch.set_argparser(parser_fetch) 53 | 54 | parser_list = subparsers.add_parser('list', help = 'list mails') 55 | hkml_list.set_argparser(parser_list) 56 | 57 | parser_thread = subparsers.add_parser( 58 | 'thread', help = 'list mails of a thread') 59 | hkml_thread.set_argparser(parser_thread) 60 | 61 | parser_open = subparsers.add_parser('open', help = 'open a mail') 62 | hkml_open.set_argparser(parser_open) 63 | 64 | parser_reply = subparsers.add_parser('reply', help = 'reply to a mail') 65 | hkml_reply.set_argparser(parser_reply) 66 | 67 | parser_forward = subparsers.add_parser('forward', help = 'forward a mail') 68 | hkml_forward.set_argparser(parser_forward) 69 | 70 | parser_tag = subparsers.add_parser('tag', help = 'manage tags of mails') 71 | hkml_tag.set_argparser(parser_tag) 72 | 73 | parser_fmtml = subparsers.add_parser('write', help = 'write a mail') 74 | hkml_write.set_argparser(parser_fmtml) 75 | 76 | parser_send = subparsers.add_parser('send', help = 'send mails') 77 | hkml_send.set_argparser(parser_send) 78 | 79 | parser_sync = subparsers.add_parser('sync', 80 | help = 'synchronize setups and outputs') 81 | hkml_sync.set_argparser(parser_sync) 82 | 83 | parser_export = subparsers.add_parser('export', help = 'export mails') 84 | hkml_export.set_argparser(parser_export) 85 | 86 | parser_monitor = subparsers.add_parser('monitor', help = 'monitor mails') 87 | hkml_monitor.set_argparser(parser_monitor) 88 | 89 | parser_patch = subparsers.add_parser('patch', help = 'apply mail as patch') 90 | hkml_patch.set_argparser(parser_patch) 91 | 92 | parser_manifest = subparsers.add_parser('manifest', help = 'print manifest') 93 | hkml_manifest.set_argparser(parser_manifest) 94 | 95 | parser_cache = subparsers.add_parser('cache', help = 'manage cache') 96 | hkml_cache.set_argparser(parser_cache) 97 | 98 | parser_signatures = subparsers.add_parser( 99 | 'signature', help = 'manage signatures') 100 | hkml_signature.set_argparser(parser_signatures) 101 | 102 | args = parser.parse_args() 103 | 104 | if args.directory is not None: 105 | os.chdir(args.directory) 106 | 107 | if not args.command in ['init', 'manifest']: 108 | manifest = None 109 | if hasattr(args, 'manifest'): 110 | manifest = args.manifest 111 | _hkml.set_hkml_dir_manifest(args.hkml_dir, manifest) 112 | 113 | if not args.command: 114 | parser.print_help() 115 | exit(1) 116 | 117 | command = globals().get(f'hkml_{args.command}') 118 | if not command: 119 | print('wrong command (%s)' % args.command) 120 | exit(1) 121 | else: 122 | command.main(args) 123 | -------------------------------------------------------------------------------- /src/hkml_cache.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0 2 | 3 | import argparse 4 | import datetime 5 | import json 6 | import os 7 | import sys 8 | import time 9 | 10 | import _hkml 11 | 12 | # Cache is constructed with multiple files. 13 | # active cache: Contains most recently added cache entries. 14 | # archieved cache: Contains cache entries that added older than oldest one in 15 | # the active cache. 16 | # 17 | # Size of cache files are limited to about 100 MiB by default. 18 | # Up to 9 archived cache files can exist by default. 19 | # When the size of active cache becomes >=100 MiB, delete oldest archived 20 | # cache, make the active cache a newest archived cache, and create a new active 21 | # cache. 22 | # 23 | # When reading the cache, active cache is first read, then archived caches one 24 | # by one, recent archive first, until the item is found. 25 | 26 | def load_cache_config(): 27 | cache_config_path = os.path.join(_hkml.get_hkml_dir(), 28 | 'mails_cache_config') 29 | if not os.path.isfile(cache_config_path): 30 | return {'max_active_cache_sz': 100 * 1024 * 1024, 31 | 'max_archived_caches': 9} 32 | 33 | with open(cache_config_path, 'r') as f: 34 | return json.load(f) 35 | 36 | def set_cache_config(max_active_cache_sz, max_archived_caches): 37 | cache_config_path = os.path.join(_hkml.get_hkml_dir(), 38 | 'mails_cache_config') 39 | with open(cache_config_path, 'w') as f: 40 | json.dump({'max_active_cache_sz': max_active_cache_sz, 41 | 'max_archived_caches': max_archived_caches}, f, indent=4) 42 | 43 | # dict having gitid/gitdir as key, Mail kvpairs as value 44 | 45 | archived_caches = [] 46 | active_cache = None 47 | 48 | need_file_update = False 49 | 50 | def get_cache_key(gitid=None, gitdir=None, msgid=None): 51 | if gitid is not None: 52 | return '%s/%s' % (gitid, gitdir) 53 | return msgid 54 | 55 | def list_archive_files(): 56 | """Return a list of archived cache files sorted in recent one first""" 57 | archive_files = [] 58 | for file_ in os.listdir(_hkml.get_hkml_dir()): 59 | if file_.startswith('mails_cache_archive_'): 60 | archive_files.append( 61 | os.path.join(_hkml.get_hkml_dir(), file_)) 62 | # name is mails_cache_archive_ 63 | archive_files.sort(reverse=True) 64 | return archive_files 65 | 66 | def get_active_mails_cache(): 67 | global active_cache 68 | 69 | if active_cache is not None: 70 | return active_cache 71 | 72 | active_cache = {} 73 | cache_path = os.path.join(_hkml.get_hkml_dir(), 'mails_cache_active') 74 | if os.path.isfile(cache_path): 75 | stat = os.stat(cache_path) 76 | if stat.st_size >= load_cache_config()['max_active_cache_sz']: 77 | os.rename( 78 | cache_path, os.path.join( 79 | _hkml.get_hkml_dir(), 'mails_cache_archive_%s' % 80 | datetime.datetime.now().strftime('%Y-%m-%d-%H-%M-%S'))) 81 | archive_files = list_archive_files() 82 | if len(archive_files) > load_cache_config()['max_archived_caches']: 83 | os.remove(archive_files[-1]) 84 | else: 85 | with open(cache_path, 'r') as f: 86 | active_cache = json.load(f) 87 | return active_cache 88 | 89 | def load_one_more_archived_cache(): 90 | global archived_caches 91 | 92 | archive_files = list_archive_files() 93 | if len(archive_files) == len(archived_caches): 94 | return False 95 | with open(archive_files[len(archived_caches)], 'r') as f: 96 | archived_caches.append(json.load(f)) 97 | return True 98 | 99 | def __get_kvpairs(key, cache): 100 | if not key in cache: 101 | # msgid_key_map has introduced from v1.1.6 102 | if 'msgid_key_map' in cache and key in cache['msgid_key_map']: 103 | key = cache['msgid_key_map'][key] 104 | if not key in cache: 105 | return None 106 | return cache[key] 107 | 108 | def get_kvpairs(gitid=None, gitdir=None, key=None): 109 | global archived_caches 110 | 111 | if key is None: 112 | key = get_cache_key(gitid, gitdir) 113 | 114 | cache = get_active_mails_cache() 115 | kvpairs = __get_kvpairs(key, cache) 116 | if kvpairs is not None: 117 | return kvpairs 118 | 119 | for cache in archived_caches: 120 | kvpairs = __get_kvpairs(key, cache) 121 | if kvpairs is not None: 122 | return kvpairs 123 | 124 | while load_one_more_archived_cache() == True: 125 | kvpairs = __get_kvpairs(key, archived_caches[-1]) 126 | if kvpairs is not None: 127 | return kvpairs 128 | 129 | return None 130 | 131 | def get_mail(gitid=None, gitdir=None, key=None): 132 | kvpairs = get_kvpairs(gitid, gitdir, key) 133 | if kvpairs is not None: 134 | return _hkml.Mail(kvpairs=kvpairs) 135 | return None 136 | 137 | def get_mbox(gitid=None, gitdir=None, key=None): 138 | kvpairs = get_kvpairs(gitid, gitdir, key) 139 | if 'mbox' in kvpairs: 140 | return kvpairs['mbox'] 141 | return None 142 | 143 | def skip_overwrite(mail, cache, key): 144 | if not key in cache: 145 | return True 146 | cached_kvpair = cache[key] 147 | mail_kvpair = mail.to_kvpairs() 148 | for mkey in mail_kvpair.keys(): 149 | if not mkey in cached_kvpair: 150 | return False 151 | if mail_kvpair[mkey] != cached_kvpair[mkey]: 152 | return False 153 | return True 154 | 155 | def set_mail(mail, overwrite=False): 156 | global need_file_update 157 | 158 | if mail.broken(): 159 | return 160 | 161 | cache = get_active_mails_cache() 162 | msgid = mail.get_field('message-id') 163 | if mail.gitid is not None and mail.gitdir is not None: 164 | key = get_cache_key(mail.gitid, mail.gitdir) 165 | # msgid_key_map has introduced from v1.1.6 166 | if not 'msgid_key_map' in cache: 167 | cache['msgid_key_map'] = {} 168 | cache['msgid_key_map'][msgid] = key 169 | else: 170 | key = msgid 171 | if overwrite is False: 172 | if key in cache: 173 | return 174 | for archived_cache in archived_caches: 175 | if key in archived_cache: 176 | return 177 | else: 178 | if skip_overwrite(mail, cache, key): 179 | return 180 | 181 | cache[key] = mail.to_kvpairs() 182 | need_file_update = True 183 | 184 | def writeback_mails(): 185 | if not need_file_update: 186 | return 187 | cache_path = os.path.join(_hkml.get_hkml_dir(), 'mails_cache_active') 188 | with open(cache_path, 'w') as f: 189 | json.dump(get_active_mails_cache(), f, indent=4) 190 | 191 | def pr_cache_stat(cache_path, profile_mail_parsing_time): 192 | print('Stat of %s' % cache_path) 193 | cache_stat = os.stat(cache_path) 194 | print('cache size: %.3f MiB' % (cache_stat.st_size / 1024 / 1024)) 195 | 196 | before_timestamp = time.time() 197 | with open(cache_path, 'r') as f: 198 | cache = json.load(f) 199 | print('%d mails in cache' % len(cache)) 200 | print('%f seconds for json-loading cache' % 201 | (time.time() - before_timestamp)) 202 | 203 | if profile_mail_parsing_time is not True: 204 | return 205 | before_timestamp = time.time() 206 | for key in cache: 207 | mail = _hkml.Mail(kvpairs=cache[key]) 208 | print('%f seconds for parsing mails' % (time.time() - before_timestamp)) 209 | 210 | def show_cache_status(config_only, profile_mail_parsing_time): 211 | cache_config = load_cache_config() 212 | print('max active cache file size: %s bytes' % 213 | cache_config['max_active_cache_sz']) 214 | print('max archived caches: %d' % cache_config['max_archived_caches']) 215 | if config_only is True: 216 | return 217 | print() 218 | 219 | cache_path = os.path.join(_hkml.get_hkml_dir(), 'mails_cache_active') 220 | if not os.path.isfile(cache_path): 221 | print('no cache exist') 222 | exit(1) 223 | 224 | pr_cache_stat(cache_path, profile_mail_parsing_time) 225 | print('') 226 | for archived_cache in list_archive_files(): 227 | pr_cache_stat(archived_cache, profile_mail_parsing_time) 228 | print('') 229 | 230 | def main(args): 231 | if args.action == 'status': 232 | show_cache_status(args.config_only, args.profile_mail_parsing_time) 233 | elif args.action == 'config': 234 | set_cache_config(args.max_active_cache_sz, args.max_archived_caches) 235 | 236 | def set_argparser(parser): 237 | parser.description = 'manage mails cache' 238 | 239 | if sys.version_info >= (3,7): 240 | subparsers = parser.add_subparsers( 241 | title='action', dest='action', metavar='', required=True) 242 | else: 243 | subparsers = parser.add_subparsers( 244 | title='action', dest='action', metavar='') 245 | 246 | parser_status = subparsers.add_parser('status', help='show cache status') 247 | parser_status.add_argument('--config_only', action='store_true', 248 | help='show configuration status only') 249 | parser_status.add_argument('--profile_mail_parsing_time', 250 | action='store_true', 251 | help='measure and show mails parsing time') 252 | 253 | parser_config = subparsers.add_parser( 254 | 'config', help='setup cache configuration') 255 | parser_config.add_argument( 256 | 'max_active_cache_sz', type=int, metavar='', 257 | help='maximum size of active cache') 258 | parser_config.add_argument( 259 | 'max_archived_caches', type=int, metavar='', 260 | help='maximum number of archived caches') 261 | -------------------------------------------------------------------------------- /src/hkml_common.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0 2 | 3 | import datetime 4 | import subprocess 5 | 6 | def parse_date_diff(date_str, now): 7 | # format: {-,+}[0-9]+ {days,hours,minutes} 8 | # e.g., -2 days, +3 days, +1 hours 9 | fields = date_str.split() 10 | if len(fields) != 2: 11 | return None 12 | nr, unit = fields 13 | try: 14 | nr = int(nr) 15 | except: 16 | return None 17 | if not unit in ['days', 'hours', 'minutes']: 18 | return None 19 | if unit == 'days': 20 | timedelta = datetime.timedelta(days=nr) 21 | elif unit == 'hours': 22 | timedelta = datetime.timedelta(hours=nr) 23 | elif unit == 'minutes': 24 | timedelta = datetime.timedelta(minutes=nr) 25 | return now + timedelta 26 | 27 | def parse_date(date_str): 28 | now = datetime.datetime.now().astimezone() 29 | the_date = parse_date_diff(date_str, now) 30 | if the_date is not None: 31 | return the_date, None 32 | for s in ['-', ':', '/']: 33 | date_str = date_str.replace(s, ' ') 34 | fields = date_str.split() 35 | if not len(fields) in [5, 3, 2, 1]: 36 | return None, 'unexpected number of fields (%d)' % len(fields) 37 | if fields[0] == 'yesterday': 38 | yesterday = now - datetime.timedelta(1) 39 | fields = [yesterday.year, yesterday.month, yesterday.day] + fields[1:] 40 | try: 41 | numbers = [int(x) for x in fields] 42 | except ValueError as e: 43 | return None, '%s' % e 44 | if not len(numbers) in [5, 3, 2]: 45 | # 5: year month day hour minute 46 | # 3: year month day 47 | # 2: hour minute 48 | return None, 'only 5, 3, or 2 numbers are supported date input' 49 | if len(numbers) == 2: 50 | numbers = [now.year, now.month, now.day] + numbers 51 | try: 52 | return datetime.datetime(*numbers).astimezone(), None 53 | except Exception as e: 54 | return None, '%s' % e 55 | 56 | def commit_date(commit): 57 | try: 58 | text = subprocess.check_output( 59 | ['git', 'log', '-1', commit, '--pretty=%cd', 60 | '--date=iso-strict'], 61 | stderr=subprocess.DEVNULL).decode().strip() 62 | except Exception as e: 63 | return None, 'git log %s fail (%s)' % (commit, e) 64 | try: 65 | return datetime.datetime.fromisoformat(text).astimezone(), None 66 | except Exception as e: 67 | return None, 'parsing date (%s) fail (%s)' % (text, e) 68 | 69 | def parse_date_arg_non_commit(tokens): 70 | try: 71 | date_str = ' '.join(tokens) 72 | except Exception as e: 73 | return None, 'tokens to string conversion fail (%s)' % e 74 | return parse_date(date_str) 75 | 76 | def parse_date_arg(tokens): 77 | parsed, err = parse_date_arg_non_commit(tokens) 78 | if err is None: 79 | return parsed, err 80 | if len(tokens) != 1: 81 | return parsed, err 82 | parsed, err2 = commit_date(tokens[0]) 83 | if err2 != None: 84 | err = 'parsing date argument fail (%s, %s)' % (err2, err) 85 | else: 86 | err = None 87 | return parsed, err 88 | 89 | def date_format_description(): 90 | return ' '.join([ 91 | 'For date argument, following formats are supported/.', 92 | 'Relative date ({-,+}[0-9]+ {days,hours,minutes}), e.g., -3 days.', 93 | 'Explicit date ("YYYY MM DD", "YYYY MM DD HH MM", or "HH MM").', 94 | '"-", "/", ":" on explicit daste input are treated as space.', 95 | '\'yesterday\'.', 96 | 'Commit (the commit date), e.g., v6.14).' 97 | ]) 98 | 99 | def add_date_arg(parser, option_name, help_msg): 100 | format_msg = date_format_description() 101 | if parser.epilog is None: 102 | parser.epilog = format_msg 103 | elif parser.epilog.find(format_msg) == -1: 104 | parser.epilog += format_msg 105 | help_msg += ' Format: show end of this message.' 106 | parser.add_argument( 107 | option_name, metavar='', nargs='+', 108 | help=help_msg) 109 | 110 | def cmd_available(cmd): 111 | try: 112 | subprocess.check_output(['which', cmd], stderr=subprocess.DEVNULL) 113 | return True 114 | except: 115 | return False 116 | -------------------------------------------------------------------------------- /src/hkml_export.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0 2 | 3 | import json 4 | import os 5 | 6 | import _hkml 7 | import _hkml_list_cache 8 | import hkml_open 9 | 10 | def export_mails(mails, export_file, human_readable=False): 11 | if export_file[-5:] == '.json': 12 | with open(export_file, 'w') as f: 13 | json.dump([m.to_kvpairs() for m in mails], f, indent=4) 14 | return 15 | 16 | with open(export_file, 'w') as f: 17 | for mail in mails: 18 | if mail.mbox is None: 19 | mail.get_field('message-id') 20 | if human_readable: 21 | f.write(hkml_open.mail_display_str( 22 | mail, head_columns=None, valid_mbox=True)) 23 | else: 24 | f.write('\n'.join( 25 | ['From hackermail Thu Jan 1 00:00:00 1970', mail.mbox,''])) 26 | 27 | def main(args): 28 | mails = _hkml_list_cache.last_listed_mails() 29 | if args.range is not None: 30 | mails = [mail for mail in mails 31 | if mail.pridx >= args.range[0] and mail.pridx < args.range[1]] 32 | return export_mails(mails, args.export_file, args.human_readable) 33 | 34 | def set_argparser(parser): 35 | parser.description = 'export mails' 36 | parser.add_argument( 37 | 'export_file', metavar='', 38 | help='file to save exported mail (mbox or json)') 39 | parser.add_argument( 40 | '--range', nargs=2, metavar=('', ''), type=int, 41 | help='a half-open range of mails from the list to export') 42 | parser.add_argument( 43 | '--human_readable', action='store_true', 44 | help='export in more human readable form') 45 | -------------------------------------------------------------------------------- /src/hkml_fetch.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # SPDX-License-Identifier: GPL-2.0 3 | 4 | import argparse 5 | import os 6 | import subprocess 7 | 8 | import _hkml 9 | import _hkml_list_cache 10 | import hkml_list 11 | 12 | def fetch_mail(mail_lists, quiet=False, epochs=1): 13 | site = _hkml.get_manifest()['site'] 14 | for mlist in mail_lists: 15 | _hkml_list_cache.invalidate_cached_outputs(mlist) 16 | repo_paths = _hkml.mail_list_repo_paths(mlist)[:epochs] 17 | local_paths = _hkml.mail_list_data_paths(mlist)[:epochs] 18 | 19 | for idx, repo_path in enumerate(repo_paths): 20 | git_url = '%s%s' % (site, repo_path) 21 | local_path = local_paths[idx] 22 | if not os.path.isdir(local_path): 23 | cmd = 'git clone --mirror %s %s' % (git_url, local_path) 24 | else: 25 | cmd = 'git --git-dir=%s remote update' % local_path 26 | if not quiet: 27 | print(cmd) 28 | subprocess.call(cmd.split()) 29 | else: 30 | with open(os.devnull, 'w') as f: 31 | subprocess.call(cmd.split(), stdout=f) 32 | _hkml_list_cache.writeback_list_output_cache() 33 | 34 | def fetched_mail_lists(): 35 | archive_dir = os.path.join(_hkml.get_hkml_dir(), 'archives') 36 | return [d for d in os.listdir(archive_dir) 37 | if os.path.isdir(os.path.join(archive_dir, d))] 38 | 39 | def main(args): 40 | mail_lists = args.mlist 41 | if not mail_lists: 42 | mail_lists = fetched_mail_lists() 43 | if not mail_lists: 44 | print('mail lists to fetch is not specified') 45 | exit(1) 46 | quiet = args.quiet 47 | fetch_mail(mail_lists, quiet, args.epochs) 48 | 49 | def set_argparser(parser): 50 | parser.description = 'fetch mails' 51 | _hkml.set_manifest_option(parser) 52 | parser.add_argument('mlist', metavar='', nargs='*', 53 | help='mailing list to fetch.') 54 | parser.add_argument('--quiet', '-q', default=False, action='store_true', 55 | help='Work silently.') 56 | parser.add_argument('--epochs', type=int, default=1, 57 | help='Minimum number of last epochs to fetch') 58 | -------------------------------------------------------------------------------- /src/hkml_forward.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0 2 | 3 | import os 4 | import subprocess 5 | import tempfile 6 | 7 | import _hkml 8 | import _hkml_list_cache 9 | import hkml_list 10 | import hkml_open 11 | import hkml_send 12 | import hkml_write 13 | 14 | def forward(mail, subject=None, in_reply_to=None, to=None, cc=None, 15 | attach_files=None, format_only=None): 16 | mail_str = '\n'.join(['=== >8 ===', 17 | hkml_open.mail_display_str(mail)]) 18 | 19 | if subject is None: 20 | subject = 'Fwd: %s' % mail.subject 21 | 22 | mbox = hkml_write.format_mbox( 23 | subject, in_reply_to, to, cc, mail_str, from_=None, 24 | draft_mail=None, attach_files=attach_files) 25 | 26 | if format_only: 27 | print(mbox) 28 | return 29 | 30 | fd, tmp_path = tempfile.mkstemp(prefix='hkml_forward_') 31 | with open(tmp_path, 'w') as f: 32 | f.write(mbox) 33 | err = hkml_write.open_editor(tmp_path) 34 | if err is not None: 35 | print(err) 36 | exit(1) 37 | hkml_send.send_mail(tmp_path, get_confirm=True, erase_mbox=True, 38 | orig_draft_subject=None) 39 | 40 | def main(args): 41 | if args.mail.isdigit(): 42 | mail = _hkml_list_cache.get_mail(int(args.mail)) 43 | elif args.mail == 'clipboard': 44 | mails, err = _hkml.read_mails_from_clipboard() 45 | if err != None: 46 | print('reading mails in clipboard failed: %s' % err) 47 | exit(1) 48 | if len(mails) != 1: 49 | print('multiple mails in clipboard') 50 | exit(1) 51 | mail = mails[0] 52 | else: 53 | print('unsupported (%s)' % args.mail) 54 | 55 | forward(mail, args.subject, args.in_reply_to, args.to, args.cc, 56 | args.attach, args.format_only) 57 | 58 | def set_argparser(parser): 59 | parser.description = 'forward a mail' 60 | parser.add_argument( 61 | 'mail', metavar='', 62 | help=' '.join( 63 | ['The mail to forward.', 64 | 'Could be index on the list, or \'clipboard\''])) 65 | parser.add_argument('--subject', metavar='', type=str, 66 | help='Subject of the mail.') 67 | parser.add_argument('--in-reply-to', metavar='', 68 | help='Add in-reply-to field in the mail header') 69 | parser.add_argument('--to', metavar='', nargs='+', 70 | help='recipients of the mail') 71 | parser.add_argument('--cc', metavar='', nargs='+', 72 | help='cc recipients of the mail') 73 | hkml_write.add_common_arguments(parser) 74 | -------------------------------------------------------------------------------- /src/hkml_init.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # SPDX-License-Identifier: GPL-2.0 3 | 4 | import argparse 5 | import os 6 | import subprocess 7 | 8 | import hkml_manifest 9 | 10 | def config_sendemail(): 11 | send_configured = subprocess.call( 12 | ['git', 'config', 'sendemail.smtpserver'], 13 | stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) == 0 14 | if send_configured is True: 15 | return 16 | question = ' '.join([ 17 | 'Seems git send-email is not configured.', 18 | 'Please configure it if you want to send email using hkml.', 19 | 'If you use gmail, I can do the configuration instead.', 20 | 'Are you gonna use gmail, and want me to do the configuration? [Y/n] ' 21 | ]) 22 | answer = input(question) 23 | if answer.lower() == 'n': 24 | return 25 | mail_account = input('enter your gmail account (e.g., foo@gmail.com): ') 26 | cmd = ['git', 'config'] 27 | subprocess.call(cmd + ['sendemail.smtpserver', 'smtp.gmail.com']) 28 | subprocess.call(cmd + ['sendemail.smtpserverport', '587']) 29 | subprocess.call(cmd + ['sendemail.smtpencryption', 'tls']) 30 | subprocess.call(cmd + ['sendemail.smtpuser', mail_account]) 31 | 32 | def main(args): 33 | os.mkdir('.hkm') 34 | os.mkdir('.hkm/archives') 35 | 36 | if args.manifest is None: 37 | question = ' '.join([ 38 | '--manifest is not specified.', 39 | 'May I set it up for lore.kernel.org? [Y/n] ']) 40 | answer = input(question) 41 | if answer.lower() == 'n': 42 | print('Cannot proceed initialization') 43 | os.rmdir('.hkm/archives') 44 | os.rmdir('.hkm') 45 | exit(1) 46 | err = hkml_manifest.fetch_lore() 47 | if err: 48 | print('Fetching lore manifest failed (err).') 49 | print('Please check if you have internet access to kernel.org.') 50 | os.rmdir('.hkm/archives') 51 | os.rmdir('.hkm') 52 | exit(1) 53 | else: 54 | if not os.path.isfile(args.manifest): 55 | print('--manifest (%s) not found' % args.manifest) 56 | exit(1) 57 | 58 | with open(args.manifest, 'r') as f: 59 | content = f.read() 60 | with open(os.path.join('.hkm', 'manifest'), 'w') as f: 61 | f.write(content) 62 | 63 | config_sendemail() 64 | 65 | def set_argparser(parser=None): 66 | parser.add_argument('--manifest', metavar='', 67 | help='manifest file to use') 68 | -------------------------------------------------------------------------------- /src/hkml_interactive.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0 2 | 3 | def main(args): 4 | print('Under construction') 5 | print('Please use "hkml -h" to get some guidance') 6 | exit(1) 7 | print('What to do?') 8 | print('1: List mails') 9 | print('2: Fetch mails') 10 | print('3: Write mail') 11 | print('4: Synchronize remote backup') 12 | print('5: Show tags') 13 | print('6: Manage mails cache') 14 | try: 15 | answer = int(input('Select: ')) 16 | except: 17 | print('wrong answer') 18 | exit(1) 19 | 20 | if answer == 1: 21 | print('Type of mails source') 22 | print('1: Mailing list') 23 | print('2: Thread of a mail') 24 | print('3: Mbox file') 25 | -------------------------------------------------------------------------------- /src/hkml_manifest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # SPDX-License-Identifier: GPL-2.0 3 | 4 | import argparse 5 | import json 6 | import os 7 | import shutil 8 | import subprocess 9 | import tempfile 10 | 11 | import _hkml 12 | 13 | def need_to_print(key, depth, mlists): 14 | if depth > 0: 15 | return True 16 | if not mlists: 17 | return True 18 | 19 | # expected key: /linux-bluetooth/git/0.git 20 | if key[-4:].strip() != '.git': 21 | return False 22 | fields = key.split('/') 23 | if len(fields) != 4: 24 | print(fields) 25 | return False 26 | return fields[1] in mlists 27 | 28 | def pr_directory(directory, mlists, depth=0): 29 | indent = ' ' * 4 * depth 30 | for key in directory: 31 | if not need_to_print(key, depth, mlists): 32 | continue 33 | 34 | val = directory[key] 35 | 36 | if type(val) == dict: 37 | print('%s%s: {' % (indent, key)) 38 | pr_directory(val, mlists, depth + 1) 39 | print('%s}' % indent) 40 | else: 41 | print('%s%s: %s' % (indent, key, val)) 42 | 43 | def fetch_lore(output_file=None): 44 | ''' 45 | Fetch lore manifest and use it. 46 | Returns an error string or None if no error happened. 47 | ''' 48 | # Get the current working directory 49 | original_dir = os.getcwd() 50 | temp_dir = tempfile.mkdtemp(prefix='hkml_manifest_dir_') 51 | os.chdir(temp_dir) 52 | 53 | err = subprocess.call(['wget', 'https://lore.kernel.org/manifest.js.gz']) 54 | if err: 55 | return 'downloading lore manifest fail (%s); please cleanup %s' % ( 56 | err, temp_dir) 57 | err = subprocess.call(['gzip', '-d', 'manifest.js.gz']) 58 | if err: 59 | return 'gunzip fail (%s); please cleanup %s' % (err, temp_dir) 60 | with open('manifest.js') as f: 61 | manifest = json.load(f) 62 | os.chdir(original_dir) 63 | shutil.rmtree(temp_dir) 64 | manifest['site'] = 'https://lore.kernel.org' 65 | if output_file is None: 66 | _hkml.update_manifest(manifest) 67 | else: 68 | with open(output_file, 'w') as f: 69 | json.dump(manifest, f, indent=4) 70 | return None 71 | 72 | def main(args): 73 | if args.action == 'fetch_lore': 74 | err = fetch_lore(args.fetch_lore_output) 75 | if err: 76 | print(err) 77 | exit(1) 78 | return 79 | 80 | manifest = args.manifest 81 | _hkml.set_hkml_dir_manifest(args.hkml_dir, manifest) 82 | if args.action == 'list': 83 | if args.mailing_lists is True: 84 | for key in _hkml.get_manifest(): 85 | fields = key.split('/') 86 | if len(fields) > 1: 87 | print(fields[1]) 88 | return 89 | pr_directory(_hkml.get_manifest(), args.mlists) 90 | elif args.action == 'convert_public_inbox_manifest': 91 | if not args.public_inbox_manifest or not args.site: 92 | print('--public_inbox_manifest or --site is not set') 93 | exit(1) 94 | with open(args.public_inbox_manifest) as f: 95 | manifest = json.load(f) 96 | manifest['site'] = args.site 97 | print(json.dumps(manifest)) 98 | elif args.action == 'fetch_lore': 99 | err = fetch_lore(args.fetch_lore_output) 100 | if err is not None: 101 | print(err) 102 | exit(1) 103 | 104 | def set_argparser(parser): 105 | _hkml.set_manifest_option(parser) 106 | parser.add_argument( 107 | 'action', metavar='', nargs='?', 108 | choices=['list', 'convert_public_inbox_manifest', 'fetch_lore'], 109 | default='list', 110 | help='action to do: list, fetch_lore or convert_public_inbox_manifest') 111 | parser.add_argument('--mlists', metavar='', nargs='+', 112 | help='print manifest entries for specific mailing lists') 113 | parser.add_argument('--public_inbox_manifest', metavar='', 114 | help='public inbox manifest which want to convert for hackermail') 115 | parser.add_argument('--site', metavar='', 116 | help='site to fetch mail archives') 117 | parser.add_argument('--mailing_lists', action='store_true', 118 | help='list only names of mailine lists') 119 | parser.add_argument('--fetch_lore_output', metavar='', 120 | help='store fetched lore manifest to given file') 121 | -------------------------------------------------------------------------------- /src/hkml_monitor.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0 2 | 3 | import copy 4 | import datetime 5 | import json 6 | import math 7 | import os 8 | import sys 9 | import tempfile 10 | import time 11 | 12 | import _hkml 13 | import hkml_list 14 | 15 | class HkmlMonitorRequest: 16 | mailing_lists = None 17 | mail_list_filter = None 18 | mail_list_decorator = None 19 | 20 | noti_mails = None 21 | noti_files = None 22 | 23 | monitor_interval = None 24 | 25 | name = None 26 | 27 | def __init__(self, mailing_lists, mail_list_filter, mail_list_decorator, 28 | noti_mails, noti_files, monitor_interval, name): 29 | self.mailing_lists = mailing_lists 30 | self.mail_list_filter = mail_list_filter 31 | self.mail_list_decorator = mail_list_decorator 32 | self.noti_mails = noti_mails 33 | self.noti_files = noti_files 34 | self.monitor_interval = monitor_interval 35 | self.name = name 36 | 37 | def to_kvpairs(self): 38 | kvpairs = copy.deepcopy(vars(self)) 39 | kvpairs['mail_list_filter'] = self.mail_list_filter.to_kvpairs() 40 | kvpairs['mail_list_decorator'] = self.mail_list_decorator.to_kvpairs() 41 | return {k: v for k, v in kvpairs.items() if v is not None} 42 | 43 | def set_mail_list_decorator_from_kvpairs(self, kvpairs): 44 | if kvpairs is None: 45 | list_decorator = hkml_list.MailListDecorator(None) 46 | list_decorator.show_stat = True 47 | list_decorator.ascend = False 48 | list_decorator.sort_threads_by = ['first_date'] 49 | list_decorator.collapse = False 50 | list_decorator.show_url = ( 51 | _hkml.get_manifest()['site'] == 'https://lore.kernel.org') 52 | list_decorator.show_runtime_profile = False 53 | self.mail_list_decorator = list_decorator 54 | return 55 | self.mail_list_decorator = hkml_list.MailListDecorator.from_kvpairs( 56 | kvpairs) 57 | 58 | @classmethod 59 | def from_kvpairs(cls, kvpairs): 60 | self = cls(*[None] * 7) 61 | for key, value in kvpairs.items(): 62 | if key in ['mail_list_filter', 'mail_list_decorator']: 63 | continue 64 | setattr(self, key, value) 65 | 66 | self.mail_list_filter = hkml_list.MailListFilter.from_kvpairs( 67 | kvpairs['mail_list_filter']) 68 | self.set_mail_list_decorator_from_kvpairs( 69 | kvpairs['mail_list_decorator'] 70 | if 'mail_list_decorator' in kvpairs else None) 71 | return self 72 | 73 | def __str__(self): 74 | return json.dumps(self.to_kvpairs(), indent=4, sort_keys=True) 75 | 76 | # list of HkmlMonitorRequest objects 77 | requests = None 78 | 79 | def get_requests_file_path(): 80 | return os.path.join(_hkml.get_hkml_dir(), 'monitor_requests') 81 | 82 | def get_requests(): 83 | global requests 84 | 85 | if requests is None: 86 | requests = [] 87 | requests_file_path = get_requests_file_path() 88 | if os.path.isfile(requests_file_path): 89 | with open(requests_file_path, 'r') as f: 90 | requests = [HkmlMonitorRequest.from_kvpairs(kvp) 91 | for kvp in json.load(f)] 92 | 93 | return requests 94 | 95 | def write_requests_file(): 96 | requests = get_requests() 97 | requests_file_path = get_requests_file_path() 98 | with open(requests_file_path, 'w') as f: 99 | json.dump([r.to_kvpairs() for r in requests], f, indent=4) 100 | 101 | def add_requests(request): 102 | requests = get_requests() 103 | requests.append(request) 104 | write_requests_file() 105 | 106 | def remove_requests(name=None, idx=None): 107 | '''Returns whether removal has success''' 108 | requests = get_requests() 109 | if name is not None: 110 | found = False 111 | for idx, request in enumerate(requests): 112 | if request.name == name: 113 | found = True 114 | break 115 | if found is False: 116 | return False 117 | 118 | if idx >= len(requests): 119 | return False 120 | del requests[idx] 121 | write_requests_file() 122 | return True 123 | 124 | def pr_w_time(text): 125 | '''Print text with timestamp''' 126 | print('[%s] %s' % 127 | (datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), text)) 128 | 129 | def mail_in(mail, mails): 130 | for m in mails: 131 | if m.get_field('message-id') == mail.get_field('message-id'): 132 | return True 133 | return False 134 | 135 | def get_mails_to_check(request, ignore_mails_before, last_monitored_mails): 136 | mails_to_check = [] 137 | msgids = {} 138 | for mailing_list in request.mailing_lists: 139 | if not mailing_list in last_monitored_mails: 140 | last_monitored_mails[mailing_list] = None 141 | last_mail = last_monitored_mails[mailing_list] 142 | if last_mail is None: 143 | since = ignore_mails_before 144 | commits_range = None 145 | else: 146 | since = None 147 | commits_range = '%s..' % last_mail.gitid 148 | 149 | fetched_mails, err = hkml_list.get_mails( 150 | source=mailing_list, fetch=True, 151 | since=since, until=None, min_nr_mails=None, max_nr_mails=None, 152 | commits_range=commits_range) 153 | if err is not None: 154 | print('hkml_list.get_mails() failed (%s)' % err) 155 | return [] 156 | if len(fetched_mails) > 0: 157 | last_monitored_mails[mailing_list] = fetched_mails[-1] 158 | for mail in fetched_mails: 159 | msgid = mail.get_field('message-id') 160 | if not msgid in msgids: 161 | mails_to_check.append(mail) 162 | msgids[msgid] = True 163 | return mails_to_check 164 | 165 | def get_mails_to_noti(mails_to_check, request): 166 | mails_to_noti = [] 167 | 168 | for mail in mails_to_check: 169 | if request.mail_list_filter.should_filter_out(mail): 170 | continue 171 | 172 | mails_to_noti.append(mail) 173 | 174 | return mails_to_noti 175 | 176 | def format_noti_text(request, mails_to_noti): 177 | lines = [ 178 | 'monitor result noti at %s' % datetime.datetime.now(), 179 | 'monitor request', 180 | '%s' % request, 181 | '', 182 | ] 183 | 184 | list_decorator = request.mail_list_decorator 185 | 186 | list_data, err = hkml_list.mails_to_list_data( 187 | mails_to_noti, do_find_ancestors_from_cache=False, mails_filter=None, 188 | list_decorator=list_decorator, show_thread_of=None, 189 | runtime_profile=[], stat_only=False, stat_authors=False) 190 | if err is not None: 191 | return 'mails_to_list_data() fail (%s)' % err 192 | lines.append(list_data.text) 193 | noti_text = '\n'.join(lines) 194 | return noti_text 195 | 196 | def do_monitor(request, ignore_mails_before, last_monitored_mails): 197 | mails_to_check = get_mails_to_check(request, ignore_mails_before, 198 | last_monitored_mails) 199 | mails_to_noti = get_mails_to_noti(mails_to_check, request) 200 | 201 | print('%d mails to noti' % len(mails_to_noti)) 202 | if len(mails_to_noti) == 0: 203 | print('request was') 204 | print('%s' % request) 205 | return 206 | 207 | noti_text = format_noti_text(request, mails_to_noti) 208 | 209 | print('#') 210 | print('# noti text start') 211 | print(noti_text) 212 | print('# noti text end') 213 | print('#') 214 | 215 | if request.noti_files is not None: 216 | for file in request.noti_files: 217 | lines = [] 218 | if os.path.isfile(file): 219 | with open(file, 'r') as f: 220 | lines.append(f.read()) 221 | with open(file, 'w') as f: 222 | f.write('\n'.join(lines + [noti_text])) 223 | 224 | if request.noti_mails is not None: 225 | mail_content = '\n'.join([ 226 | 'Subject: [hkml-noti] for monitor request %s' % request.name, 227 | '', 228 | noti_text]) 229 | fd, tmp_path = tempfile.mkstemp(prefix='hkml_monitor_') 230 | with open(tmp_path, 'w') as f: 231 | f.write(mail_content) 232 | 233 | _hkml.cmd_str_output(['git', 'send-email', tmp_path, 234 | '--8bit-encoding=UTF-8', '--confirm', 'never', 235 | '--to'] + request.noti_mails) 236 | os.remove(tmp_path) 237 | 238 | def get_monitor_stop_file_path(): 239 | return os.path.join(_hkml.get_hkml_dir(), 'monitor_stop') 240 | 241 | def start_monitoring(ignore_mails_before): 242 | requests = get_requests() 243 | 244 | # math.gcd() supports arbitrary number of positional args starting from 245 | # Python 3.9. Support lower versions. 246 | 247 | monitor_interval_gcd = requests[0].monitor_interval 248 | for r in requests[1:]: 249 | monitor_interval_gcd = math.gcd(monitor_interval_gcd, r.monitor_interval) 250 | if monitor_interval_gcd < 60: 251 | print('<60 seconds monitoring interval is too short!') 252 | return 1 253 | 254 | last_monitor_time = [None] * len(requests) 255 | last_monitored_mails = [] 256 | for i in range(len(requests)): 257 | last_monitored_mails.append({}) 258 | 259 | while not os.path.isfile(get_monitor_stop_file_path()): 260 | for idx, req in enumerate(requests): 261 | last_monitor = last_monitor_time[idx] 262 | now = time.time() 263 | if (last_monitor is None or 264 | now - last_monitor >= req.monitor_interval): 265 | do_monitor(req, ignore_mails_before, last_monitored_mails[idx]) 266 | 267 | last_monitor_time[idx] = now 268 | pr_w_time('sleep %d seconds' % monitor_interval_gcd) 269 | time.sleep(monitor_interval_gcd) 270 | 271 | os.remove(get_monitor_stop_file_path()) 272 | return 0 273 | 274 | def stop_monitoring(): 275 | with open(get_monitor_stop_file_path(), 'w') as f: 276 | f.write('issued at %s' % datetime.datetime.now()) 277 | 278 | def main(args): 279 | if args.action == 'add': 280 | add_requests(HkmlMonitorRequest( 281 | args.mailing_lists, hkml_list.MailListFilter(args), 282 | hkml_list.MailListDecorator(args), 283 | args.noti_mails, args.noti_files, args.monitor_interval, 284 | args.name)) 285 | elif args.action == 'status': 286 | for idx, request in enumerate(get_requests()): 287 | print('request %d' % idx) 288 | print('%s' % request) 289 | elif args.action == 'remove': 290 | if args.request.isdigit(): 291 | if remove_requests(idx=int(args.request)) is False: 292 | print('failed removing the request') 293 | else: 294 | if remove_requests(name=args.request) is False: 295 | print('failed removing the request') 296 | elif args.action == 'start': 297 | if args.since is None: 298 | ignore_mails_before = datetime.datetime.now() 299 | else: 300 | try: 301 | ignore_mails_before = datetime.datetime.strptime( 302 | args.since, '%Y-%m-%d') 303 | except: 304 | try: 305 | ignore_mails_before = datetime.datetime.strptime( 306 | args.since, '%Y-%m-%d %H:%M:%S') 307 | except: 308 | print('parsing --since failed') 309 | print(' '.join(['the argument should be in \'%Y-%m-%d\'', 310 | 'or \'%Y-%m-%d %H:%M:%S\' format'])) 311 | exit(1) 312 | 313 | return start_monitoring(ignore_mails_before) 314 | elif args.action == 'stop': 315 | stop_monitoring() 316 | 317 | def set_add_arguments(parser): 318 | parser.add_argument( 319 | 'mailing_lists', nargs='+', metavar='', 320 | help='monitoring target mailing lists') 321 | 322 | hkml_list.add_mails_filter_arguments(parser) 323 | hkml_list.add_decoration_arguments(parser, show_help=True) 324 | 325 | parser.add_argument( 326 | '--noti_mails', nargs='+', metavar='', 327 | help='mail addresses to send monitoring results notification') 328 | parser.add_argument( 329 | '--noti_files', nargs='+', metavar='', 330 | help='file paths to write monitoring results notification') 331 | parser.add_argument( 332 | '--monitor_interval', type=int, metavar='', default=300, 333 | help='do monitoring once per this time interval') 334 | parser.add_argument( 335 | '--name', metavar='', 336 | help='name of the request') 337 | 338 | def set_argparser(parser): 339 | _hkml.set_manifest_option(parser) 340 | if sys.version_info >= (3,7): 341 | subparsers = parser.add_subparsers( 342 | title='action', dest='action', metavar='', required=True) 343 | else: 344 | subparsers = parser.add_subparsers( 345 | title='action', dest='action', metavar='') 346 | 347 | parser_add = subparsers.add_parser('add', help='add a monitoring request') 348 | set_add_arguments(parser_add) 349 | 350 | parser_remove = subparsers.add_parser( 351 | 'remove', help='remove a given monitoring request') 352 | parser_remove.add_argument( 353 | 'request', metavar='', 354 | help='name or index of the request to remove') 355 | 356 | parser_status = subparsers.add_parser( 357 | 'status', help='show monitoring status including requests') 358 | 359 | parser_start = subparsers.add_parser( 360 | 'start', help='start monitoring') 361 | parser_start.add_argument( 362 | '--since', metavar='<%Y-%m-%d[ %H:%M:%S]>', 363 | help='Ignore monitoring target mails that sent before this time') 364 | 365 | parser_stop = subparsers.add_parser( 366 | 'stop', help='stop monitoring') 367 | -------------------------------------------------------------------------------- /src/hkml_open.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0 2 | 3 | import json 4 | import os 5 | import subprocess 6 | import tempfile 7 | 8 | import _hkml 9 | import _hkml_list_cache 10 | import hkml_common 11 | import hkml_list 12 | import hkml_view 13 | 14 | def decorate_last_reference(text): 15 | lines = text.split('\n') 16 | if not lines[0].startswith('# last reference: '): 17 | return text 18 | 19 | fields = lines[0].split() 20 | if len(fields) != 4: 21 | return text 22 | last_reference_idx = int(lines[0].split()[3]) 23 | for idx, line in enumerate(lines): 24 | fields = line.split() 25 | if len(fields) == 0: 26 | continue 27 | if not fields[0].startswith('[') or not fields[0].endswith(']'): 28 | continue 29 | try: 30 | mail_idx = int(fields[0][1:-1]) 31 | except: 32 | continue 33 | if mail_idx != last_reference_idx: 34 | continue 35 | line = u'\u001b[32m' + line + u'\u001b[0m' 36 | line = '\x1B[3m' + line + '\x1B[0m' 37 | del lines[idx] 38 | lines.insert(idx, line) 39 | text = '\n'.join(lines) 40 | return text 41 | 42 | def pr_with_pager_if_needed(text): 43 | text = decorate_last_reference(text) 44 | 45 | try: 46 | if text.count('\n') < (os.get_terminal_size().lines * 9 / 10): 47 | print(text) 48 | return 49 | except OSError as e: 50 | # maybe the user is using pipe to the output 51 | pass 52 | 53 | fd, tmp_path = tempfile.mkstemp(prefix='hkml_open-') 54 | with open(tmp_path, 'w') as f: 55 | f.write(text) 56 | subprocess.call(['less', '-R', '-M', '--no-init', tmp_path]) 57 | os.remove(tmp_path) 58 | 59 | def mail_display_str(mail, head_columns=None, valid_mbox=False, 60 | for_draft_continue=False, recipients_per_line=False): 61 | lines = [] 62 | if valid_mbox is True: 63 | lines.append('From hackermail Thu Jan 1 00:00:00 1970') 64 | if for_draft_continue is True: 65 | head_fields = ['From', 'To', 'Cc', 'In-Reply-To'] 66 | else: 67 | head_fields = ['From', 'To', 'Cc', 'Message-Id', 68 | 'In-Reply-To', 'Date'] 69 | if valid_mbox is False and for_draft_continue is False: 70 | head_fields.append('Local-Date') 71 | head_fields.append('Subject') 72 | for head in head_fields: 73 | value = mail.get_field(head) 74 | if value: 75 | if head in ['To', 'Cc'] and recipients_per_line is True: 76 | recipients = value.split(',') 77 | for recipient in recipients: 78 | lines.append('%s: %s' % (head, recipient.strip())) 79 | continue 80 | if head_columns is not None: 81 | lines += hkml_list.wrap_line('%s:' % head, value, head_columns) 82 | else: 83 | lines.append('%s: %s' % (head, value)) 84 | lines.append('\n%s' % mail.get_field('body')) 85 | return '\n'.join(lines) 86 | 87 | def last_open_mail_idx(): 88 | with open(os.path.join(_hkml.get_hkml_dir(), 'last_open_idx'), 'r') as f: 89 | return int(f.read()) 90 | 91 | def show_text(text, to_stdout, use_less, string_after_less, data=None): 92 | if to_stdout: 93 | print(text) 94 | return 95 | if use_less: 96 | pr_with_pager_if_needed(text) 97 | if string_after_less is not None: 98 | print(string_after_less) 99 | else: 100 | if type(data) == _hkml.Mail: 101 | hkml_view.view_mail(text, data) 102 | else: 103 | hkml_view.view_text(text) 104 | 105 | def show_git_commit(commit, to_stdout, use_less, string_after_less): 106 | try: 107 | show_text(subprocess.check_output(['git', 'show', commit]).decode(), 108 | to_stdout, use_less, string_after_less, data=None) 109 | return None 110 | except: 111 | return 'git show failed' 112 | 113 | def handle_command_target(args): 114 | print('...') 115 | cmd = args.target.split()[0] 116 | is_cmd = hkml_common.cmd_available(cmd) 117 | if not is_cmd: 118 | return False 119 | 120 | try: 121 | output = subprocess.check_output(args.target, shell=True).decode() 122 | except: 123 | print('failed running the target command') 124 | return False 125 | show_text(output, args.stdout, args.use_less, None, data=None) 126 | return True 127 | 128 | def main(args): 129 | if handle_command_target(args): 130 | return 131 | if os.path.isfile(args.target): 132 | with open(args.target, 'r') as f: 133 | return show_text(f.read(), args.stdout, args.use_less, None, 134 | data=None) 135 | if not args.target.isdigit(): 136 | return show_git_commit(args.target, args.stdout, args.use_less, None) 137 | 138 | noti_current_index = True 139 | if args.target == 'prev': 140 | args.target = last_open_mail_idx() - 1 141 | elif args.target == 'next': 142 | args.target = last_open_mail_idx() + 1 143 | else: 144 | noti_current_index = False 145 | args.target = int(args.target) 146 | 147 | mail = _hkml_list_cache.get_mail(args.target) 148 | if mail is None: 149 | print('mail is not cached. Try older list') 150 | mail = _hkml_list_cache.get_mail(args.target, not_thread_idx=True) 151 | if mail is None: 152 | print('even not an older list index. Maybe git commit?') 153 | error = show_git_commit('%s' % args.target, args.stdout, 154 | args.use_less, None) 155 | if error is not None: 156 | print('cannot handle the request: %s' % error) 157 | exit(1) 158 | 159 | with open(os.path.join(_hkml.get_hkml_dir(), 'last_open_idx'), 'w') as f: 160 | f.write('%d' % args.target) 161 | 162 | try: 163 | head_columns = int(os.get_terminal_size().columns * 9 / 10) 164 | except: 165 | # maybe user is pipe-ing the output 166 | head_columns = None 167 | mail_str = mail_display_str(mail, head_columns) 168 | 169 | string_after_less = None 170 | if args.use_less and noti_current_index: 171 | string_after_less = '# you were reading %d-th index' % args.target 172 | show_text(mail_str, args.stdout, args.use_less, string_after_less, 173 | data=mail) 174 | 175 | def set_argparser(parser): 176 | parser.description = 'open a mail' 177 | parser.add_argument( 178 | 'target', metavar='', 179 | help=' '.join( 180 | [ 181 | 'Target to open. Following types are supported.', 182 | '1. Index of a mail from the last open mails list/thread.', 183 | '2. \'next\': last open mail index plus one.', 184 | '3. \'prev\': last open mail index minus one.', 185 | '4. text file', 186 | '5. Git commit', 187 | '6. command.', 188 | ])) 189 | parser.add_argument( 190 | '--stdout', action='store_true', help='print without a pager') 191 | parser.add_argument( 192 | '--use_less', action='store_true', 193 | help='use less instead of hkml viewer') 194 | -------------------------------------------------------------------------------- /src/hkml_patch.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0 2 | 3 | import os 4 | import shutil 5 | import subprocess 6 | import sys 7 | import tempfile 8 | 9 | import _hkml 10 | import _hkml_list_cache 11 | import hkml_list 12 | import hkml_open 13 | import hkml_patch_format 14 | 15 | def find_mail_from_thread(thread, msgid): 16 | if thread.get_field('message-id') == msgid: 17 | return thread 18 | if thread.replies is None: 19 | return None 20 | for reply in thread.replies: 21 | found_mail = find_mail_from_thread(reply, msgid) 22 | if found_mail is not None: 23 | return found_mail 24 | 25 | def get_mail_with_replies(msgid): 26 | mails = _hkml_list_cache.last_listed_mails() 27 | threads = hkml_list.threads_of(mails) 28 | for thread_root_mail in threads: 29 | mail_with_replies = find_mail_from_thread(thread_root_mail, msgid) 30 | if mail_with_replies is not None: 31 | return mail_with_replies 32 | 33 | def user_pointed_mail(mail_identifier): 34 | if mail_identifier.isdigit(): 35 | mail = _hkml_list_cache.get_mail(int(mail_identifier)) 36 | if mail is None: 37 | return None, 'cache search fail' 38 | elif mail_identifier == 'clipboard': 39 | mails, err = _hkml.read_mails_from_clipboard() 40 | if err != None: 41 | return None, 'reading mails in clipboard failed: %s' % err 42 | if len(mails) != 1: 43 | return None, 'multiple mails in clipboard' 44 | mail = mails[0] 45 | else: 46 | return None, 'unsupported (%s)' % mail_identifier 47 | 48 | mail = get_mail_with_replies(mail.get_field('message-id')) 49 | if mail is None: 50 | return None, 'get replies of the mail fail' 51 | return mail, None 52 | 53 | def rm_tmp_patch_dir(patch_files): 54 | dirname = os.path.dirname(patch_files[-1]) 55 | for patch_file in patch_files: 56 | os.remove(patch_file) 57 | os.rmdir(dirname) 58 | 59 | def check_patches(checker, patch_files, patch_mails, rm_patches): 60 | if patch_mails is None: 61 | patch_mails = [] 62 | for patch_file in patch_files: 63 | patch_mails.append(_hkml.read_mbox_file(patch_file)[0]) 64 | checkpatch = os.path.join('scripts', 'checkpatch.pl') 65 | if checker is None: 66 | if os.path.isfile(checkpatch): 67 | checker = checkpatch 68 | else: 69 | return ' is not given; checkpatch.pl is also not found' 70 | 71 | complained_patches = [] 72 | for idx, patch_file in enumerate(patch_files): 73 | try: 74 | output = subprocess.check_output([checker, patch_file]).decode() 75 | if checker == checkpatch: 76 | last_par = output.split('\n\n')[-1] 77 | if not 'and is ready for submission.' in last_par: 78 | raise Exception('checkpatch.pl output seems wrong') 79 | except Exception as e: 80 | print('[!!!] %s complained by %s (%s)' % ( 81 | patch_mails[idx].subject, checker, e)) 82 | subprocess.call([checker, patch_file]) 83 | print() 84 | complained_patches.append(patch_file) 85 | print('Below %d patches may have problems' % len(complained_patches)) 86 | for patch_file in complained_patches: 87 | print(' - %s' % patch_file) 88 | 89 | if rm_patches: 90 | rm_tmp_patch_dir(patch_files) 91 | return None 92 | 93 | def git_am(patch_files, repo): 94 | for patch_file in patch_files: 95 | rc = subprocess.call(['git', '-C', repo, 'am', patch_file]) 96 | if rc != 0: 97 | return 'applying patch (%s) failed' % patch_file 98 | return None 99 | 100 | def add_noff_merge_commit(base_commit, message, git_cmd=['git']): 101 | final_commit = subprocess.check_output( 102 | git_cmd + ['rev-parse', 'HEAD']).decode().strip() 103 | subprocess.call(git_cmd + ['reset', '--hard', base_commit]) 104 | subprocess.call(git_cmd + ['merge', '--no-ff', '--no-edit', final_commit]) 105 | subprocess.call(git_cmd + ['commit', '--amend', '-s', '-m', message]) 106 | 107 | def git_cherrypick_merge(patch_files, cv_mail, repo): 108 | git_cmd = ['git', '-C', repo] 109 | head_commit = subprocess.check_output( 110 | git_cmd + ['rev-parse', 'HEAD']).decode().strip() 111 | err = git_am(patch_files[1:], repo) 112 | if err is not None: 113 | return err 114 | 115 | cv_merge_msg = '\n'.join([ 116 | 'Merge patch series \'%s\'' % cv_mail.subject, '', 117 | 'Below is the cover letter of the series', '', 118 | cv_mail.get_field('body')]) 119 | add_noff_merge_commit(head_commit, cv_merge_msg, git_cmd) 120 | return None 121 | 122 | def apply_patches(patch_mails, repo): 123 | err = None 124 | 125 | has_cv = len(patch_mails) > 0 and is_cover_letter(patch_mails[0]) 126 | do_merge = False 127 | if has_cv: 128 | print('How should I apply the cover letter?') 129 | print() 130 | print('1: add to first patch\'s commit message # default') 131 | print('2: add as a bogus baseline commit') 132 | print('3: add as a merge commit') 133 | print('4: Ignore it.') 134 | print() 135 | answer = input('Enter the number: ') 136 | try: 137 | answer = int(answer) 138 | except: 139 | pass 140 | if not answer in [1, 2, 3, 4]: 141 | answer = 1 142 | if answer == 1: 143 | patch_mails[1].add_cv(patch_mails[0], len(patch_mails) - 1) 144 | elif answer == 2: 145 | cv_mail = patch_mails[0] 146 | subject = '==== %s ====' % cv_mail.subject 147 | content = '%s\n\n%s' % (cv_mail.subject, cv_mail.get_field('body')) 148 | make_cover_letter_commit(subject, content) 149 | elif answer == 3: 150 | do_merge = True 151 | 152 | patch_files, err = write_patch_mails(patch_mails) 153 | if err is not None: 154 | return 'writing patch files failed (%s)' % err 155 | if do_merge: 156 | err = git_cherrypick_merge(patch_files, patch_mails[0], repo) 157 | else: 158 | if has_cv: 159 | err = git_am(patch_files[1:], repo) 160 | else: 161 | err = git_am(patch_files, repo) 162 | if err is not None: 163 | return err 164 | # cleanup tempoeral patches only when success, to let investigation easy 165 | rm_tmp_patch_dir(patch_files) 166 | return None 167 | 168 | def add_patch_suffix(basename, count): 169 | patch_sections = basename.split('.') 170 | suffix = '-' + str(count) 171 | if len(patch_sections) < 2: # No file extension 172 | return basename + suffix 173 | 174 | patch_sections[-2] += suffix 175 | return '.'.join(patch_sections) 176 | 177 | def move_patches(patch_files, dest_dir): 178 | if len(patch_files) == 0: 179 | print('no patch to export') 180 | saved_dir = os.path.dirname(patch_files[-1]) 181 | if dest_dir is not None: 182 | for idx, patch_file in enumerate(patch_files): 183 | basename = os.path.basename(patch_file) 184 | new_path = os.path.join(dest_dir, basename) 185 | 186 | # Avoid overwriting existing patches; append -N to the end until 187 | # the file path is unique 188 | if (os.path.isfile(new_path)): 189 | count = 1 190 | while os.path.isfile(new_path): 191 | new_path = os.path.join(dest_dir, 192 | add_patch_suffix(basename, count)) 193 | count += 1 194 | shutil.move(patch_file, new_path) 195 | patch_files[idx] = new_path 196 | 197 | os.rmdir(saved_dir) 198 | saved_dir = dest_dir 199 | print('\npatch files are saved at \'%s\' with below names:' % saved_dir) 200 | for patch_file in patch_files: 201 | print('- %s' % os.path.basename(patch_file)) 202 | print() 203 | 204 | def get_patch_index(mail): 205 | tag_end_idx = mail.subject.find(']') 206 | for field in mail.subject[:tag_end_idx].split(): 207 | idx_total = field.split('/') 208 | if len(idx_total) != 2: 209 | continue 210 | if idx_total[0].isdigit() and idx_total[1].isdigit(): 211 | return int(idx_total[0]) 212 | return None 213 | 214 | def find_add_tags(patch_mail, mail_to_check): 215 | for line in mail_to_check.get_field('body').split('\n'): 216 | for tag in ['Tested-by:', 'Reviewed-by:', 'Acked-by:', 'Fixes:']: 217 | if not line.startswith(tag): 218 | continue 219 | print('Found below from "%s"' % 220 | mail_to_check.get_field('subject')) 221 | print(' %s' % line) 222 | answer = input('add the tag to the patch? [Y/n] ') 223 | if answer.lower() != 'n': 224 | err = patch_mail.add_patch_tag(line) 225 | if err is not None: 226 | print(err) 227 | if mail_to_check.replies is None: 228 | return 229 | for reply in mail_to_check.replies: 230 | find_add_tags(patch_mail, reply) 231 | 232 | def is_cover_letter(mail): 233 | return mail.series is not None and mail.series[0] == 0 234 | 235 | def get_link_tag_domain(): 236 | if not _hkml.is_for_lore_kernel_org(): 237 | return None 238 | site = _hkml.get_manifest()['site'] 239 | print() 240 | print(' '.join([ 241 | 'Should we add Link: tag to the patch?', 242 | 'If so, what domain to use?'])) 243 | print() 244 | print('1. Yes. Use https://patch.msgid.link (default)') 245 | print('2. Yes. Use %s' % site) 246 | print('3. No. Don\'t add Link: tag') 247 | answer = input('Select: ') 248 | try: 249 | answer = int(answer) 250 | except: 251 | answer = 1 252 | if answer == 1: 253 | return 'https://patch.msgid.link' 254 | elif answer == 2: 255 | return site 256 | else: 257 | return None 258 | 259 | def add_cc_tags(patch_mail): 260 | for recipient in recipients_of(patch_mail, 'cc'): 261 | if recipient == patch_mail.get_field('from'): 262 | continue 263 | if len(recipient.split()) > 1: 264 | continue 265 | patch_mail.add_patch_tag('Cc: %s' % recipient) 266 | 267 | def get_patch_mails(mail, dont_add_cv): 268 | patch_mails = [mail] 269 | is_cv = is_cover_letter(mail) 270 | if is_cv is True: 271 | if dont_add_cv == 'ask': 272 | answer = input('Add cover letter to first patch? [Y/n] ') 273 | if answer.lower() != 'n': 274 | dont_add_cv = False 275 | else: 276 | dont_add_cv = True 277 | 278 | patch_mails += [r for r in mail.replies 279 | if 'patch' in r.subject_tags] 280 | link_domain = get_link_tag_domain() 281 | for patch_mail in patch_mails: 282 | if link_domain is not None: 283 | msgid = patch_mail.get_field('message-id') 284 | if msgid.startswith('<') and msgid.endswith('>'): 285 | msgid = msgid[1:-1] 286 | url = '%s/%s' % (link_domain, msgid) 287 | patch_mail.add_patch_tag('Link: %s' % url) 288 | if patch_mail.replies is None: 289 | continue 290 | if is_cover_letter(patch_mail): 291 | continue 292 | for reply in patch_mail.replies: 293 | find_add_tags(patch_mail, reply) 294 | add_cc_tags(patch_mail) 295 | user_name = subprocess.check_output( 296 | ['git', 'config', 'user.name']).decode().strip() 297 | user_email = subprocess.check_output( 298 | ['git', 'config', 'user.email']).decode().strip() 299 | patch_mail.add_patch_tag('Signed-off-by: %s <%s>' % (user_name, user_email)) 300 | patch_mails.sort(key=lambda m: get_patch_index(m)) 301 | if is_cv and dont_add_cv is False: 302 | print('Given mail seems the cover letter of the patchset.') 303 | print('Adding the cover letter on the first patch.') 304 | patch_mails[1].add_cv(mail, len(patch_mails) - 1) 305 | return patch_mails 306 | 307 | def write_patch_mails(patch_mails): 308 | if len(patch_mails) > 9999: 309 | return None, '>9999 patches' 310 | files = [] 311 | temp_dir = tempfile.mkdtemp(prefix='hkml_patch_') 312 | # give index 0 to only coverletter 313 | if is_cover_letter(patch_mails[0]): 314 | idx_offset = 0 315 | else: 316 | idx_offset = 1 317 | for idx, mail in enumerate(patch_mails): 318 | file_name_words = ['%04d-' % (idx + idx_offset)] 319 | subject = mail.subject.lower() 320 | # exclude [PATCH ...] like suffix 321 | tag_closing_idx = subject.find(']') 322 | subject = subject[tag_closing_idx + 1:] 323 | for c in subject: 324 | if not c.isalpha() and not c.isdigit(): 325 | # avoid multiple '-' in the name 326 | if file_name_words[-1][-1] == '-': 327 | continue 328 | c = '-' 329 | file_name_words.append(c) 330 | file_name_words.append('.patch') 331 | file_name = ''.join(file_name_words) 332 | file_name = os.path.join(temp_dir, file_name) 333 | with open(file_name, 'w') as f: 334 | f.write(hkml_open.mail_display_str( 335 | mail, head_columns=None, valid_mbox=True)) 336 | files.append(file_name) 337 | return files, None 338 | 339 | def check_apply_or_export(mail, args): 340 | patch_mails = get_patch_mails(mail, args.dont_add_cv) 341 | if args.action == 'apply': 342 | return apply_patches(patch_mails, args.repo) 343 | 344 | patch_files, err = write_patch_mails(patch_mails) 345 | if err is not None: 346 | return 'writing patch files failed (%s)' % err 347 | 348 | if args.action == 'check': 349 | return check_patches( 350 | args.checker, patch_files, patch_mails, rm_patches=True) 351 | elif args.action == 'export': 352 | move_patches(patch_files, args.export_dir) 353 | return None 354 | 355 | def recipients_of(mail, to_cc): 356 | field = mail.get_field(to_cc) 357 | if field is None: 358 | return [] 359 | return [r.strip() for r in field.split(',')] 360 | 361 | def common_recipients(patch_mails, to_cc): 362 | if len(patch_mails) == 0: 363 | return [] 364 | to_return = [] 365 | for recipient in recipients_of(patch_mails[0], to_cc): 366 | is_common = True 367 | for mail in patch_mails[1:]: 368 | if not recipient in recipients_of(mail, to_cc): 369 | is_common = False 370 | break 371 | if is_common is True: 372 | to_return.append(recipient) 373 | return to_return 374 | 375 | def list_recipients(patch_files): 376 | patch_mails = [] 377 | for patch_file in patch_files: 378 | patch_mails.append(_hkml.read_mbox_file(patch_file)[0]) 379 | common_to = common_recipients(patch_mails, 'to') 380 | common_cc = common_recipients(patch_mails, 'cc') 381 | print('Common recipients:') 382 | for to in common_to: 383 | print(' To: %s' % to) 384 | for cc in common_cc: 385 | print(' Cc: %s' % cc) 386 | if len(common_to) + len(common_cc) == 0: 387 | print(' No one') 388 | for patch_mail in patch_mails: 389 | exclusive_to = [] 390 | exclusive_cc = [] 391 | for recipient in recipients_of(patch_mail, 'to'): 392 | if not recipient in common_to: 393 | exclusive_to.append(recipient) 394 | for recipient in recipients_of(patch_mail, 'cc'): 395 | if not recipient in common_cc: 396 | exclusive_cc.append(recipient) 397 | if len(exclusive_to + exclusive_cc) > 0: 398 | print('Additional recipients for "%s"' % patch_mail.subject) 399 | for to in exclusive_to: 400 | print(' To: %s' % to) 401 | for cc in exclusive_cc: 402 | print(' Cc: %s' % cc) 403 | 404 | def is_files_argument(arg): 405 | if type(arg) is not list: 406 | return False 407 | for entry in arg: 408 | if not os.path.isfile(entry): 409 | return False 410 | return True 411 | 412 | def make_cover_letter_commit(subject, content=None): 413 | bogus_dir = 'hkml_cv_bogus' 414 | if not os.path.isdir(bogus_dir): 415 | os.mkdir(bogus_dir) 416 | _, bogus_path = tempfile.mkstemp(prefix='hkml_cv_bogus_', dir=bogus_dir) 417 | err = subprocess.call(['git', 'add', bogus_path]) 418 | if err: 419 | print('git add failed') 420 | return -1 421 | message = subject 422 | if content is not None: 423 | message = '%s\n\n%s' % (message, content) 424 | return subprocess.call(['git', 'commit', '-s', '-m', message]) 425 | 426 | def main(args): 427 | if args.action == 'format': 428 | return hkml_patch_format.main(args) 429 | elif args.action == 'recipients': 430 | return list_recipients(args.patch_files) 431 | elif args.action == 'commit_cv': 432 | if args.as_merge is not None: 433 | return add_noff_merge_commit( 434 | args.as_merge, args.subject, git_cmd=['git']) 435 | return make_cover_letter_commit(args.subject) 436 | 437 | if args.action == 'check': 438 | if is_files_argument(args.patch): 439 | err = check_patches( 440 | args.checker, args.patch, None, rm_patches=False) 441 | if err is not None: 442 | print(err) 443 | return 1 444 | return 0 445 | elif len(args.patch) > 1: 446 | print('wrong patch argument') 447 | return 1 448 | else: 449 | args.mail = args.patch[0] 450 | 451 | mail, err = user_pointed_mail(args.mail) 452 | if err is not None: 453 | print(err) 454 | exit(1) 455 | 456 | err = check_apply_or_export(mail, args) 457 | if err is not None: 458 | print(err) 459 | exit(1) 460 | 461 | def set_argparser(parser): 462 | parser.description = 'handle patch series mail thread' 463 | parser.add_argument('--dont_add_cv', action='store_true', 464 | help='don\'t add cover letter to first patch') 465 | 466 | if sys.version_info >= (3,7): 467 | subparsers = parser.add_subparsers( 468 | title='action', dest='action', metavar='', 469 | required=True) 470 | else: 471 | subparsers = parser.add_subparsers( 472 | title='action', dest='action', metavar='') 473 | 474 | parser_apply = subparsers.add_parser('apply', help='apply the patch') 475 | parser_apply.add_argument( 476 | 'mail', metavar='', 477 | help=' '.join( 478 | ['The mail to apply as a patch.', 479 | 'Could be index on the list, or \'clipboard\''])) 480 | parser_apply.add_argument('--repo', metavar='', default='./', 481 | help='git repo to apply the patch') 482 | 483 | parser_check = subparsers.add_parser('check', 484 | help='run a checker for the patch') 485 | parser_check.add_argument( 486 | 'patch', metavar='', nargs='+', 487 | help=' '.join( 488 | ['The mail or patch files to check.', 489 | 'In case of a mail, this could be index on the list,', 490 | 'or \'clipboard\''])) 491 | parser_check.add_argument('checker', metavar='', nargs='?', 492 | help='patch checker program') 493 | 494 | parser_export = subparsers.add_parser('export', help='save as patch files') 495 | parser_export.add_argument( 496 | 'mail', metavar='', 497 | help=' '.join( 498 | ['The mail to apply as a patch.', 499 | 'Could be index on the list, or \'clipboard\''])) 500 | parser_export.add_argument('--export_dir', metavar='', 501 | help='directory to save the patch files') 502 | 503 | parser_format = subparsers.add_parser('format', help='format patch files') 504 | hkml_patch_format.set_argparser(parser_format) 505 | 506 | parser_recipients = subparsers.add_parser( 507 | 'recipients', help='show recipients of patch files') 508 | parser_recipients.add_argument('patch_files', metavar='', nargs='+', 509 | help='the patch files') 510 | 511 | parser_cv_commit = subparsers.add_parser( 512 | 'commit_cv', help='make a commit of cover letter message') 513 | parser_cv_commit.add_argument('subject', metavar='', 514 | help='subject of the cover letter commit') 515 | parser_cv_commit.add_argument('--as_merge', metavar='', 516 | help='make it as a no-ff merge commit') 517 | -------------------------------------------------------------------------------- /src/hkml_patch_format.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0 2 | 3 | import os 4 | import subprocess 5 | import sys 6 | 7 | import _hkml 8 | import hkml_open 9 | import hkml_patch 10 | 11 | def add_patch_recipients(patch_file, to, cc): 12 | mail = _hkml.read_mbox_file(patch_file)[0] 13 | mail.add_recipients('to', to) 14 | mail.add_recipients('cc', cc) 15 | to_write = hkml_open.mail_display_str( 16 | mail, head_columns=80, valid_mbox=True, recipients_per_line=True) 17 | with open(patch_file, 'w') as f: 18 | f.write(to_write) 19 | 20 | def is_linux_tree(dir): 21 | try: 22 | # 1da177e4c3f41524e886b7f1b8a0c1fc7321cac2 is the initial commit of 23 | # Linux' git-era. 24 | output = subprocess.check_output( 25 | ['git', '-C', dir, 'log', '--pretty=%s', 26 | '1da177e4c3f41524e886b7f1b8a0c1fc7321cac2'], 27 | stderr=subprocess.DEVNULL).decode() 28 | except: 29 | return False 30 | return output.strip() == 'Linux-2.6.12-rc2' 31 | 32 | def is_kunit_patch(patch_file): 33 | # kunit maintainers willing to be Cc-ed for any kunit tests. But kunit 34 | # tests are spread over tree, and therefore MAINTAINERS file cannot handle 35 | # it always. Add some additional rules here. 36 | 37 | # damon kunit tests are at mm/damon/tests/ 38 | if 'mm/damon/tests' in patch_file: 39 | return True 40 | # most kunit test files are assumed to be named with kunit suffix. 41 | if 'kunit' in patch_file: 42 | return True 43 | return False 44 | 45 | def linux_maintainers_of(patch_or_source_file): 46 | cmd = ['./scripts/get_maintainer.pl', '--nogit', '--nogit-fallback', 47 | '--norolestats', patch_or_source_file] 48 | try: 49 | recipients = subprocess.check_output(cmd).decode().strip().split('\n') 50 | except Exception as e: 51 | return None, '%s fail' % (' '.join(cmd)) 52 | 53 | return [r for r in recipients if r != ''], None 54 | 55 | def find_linux_patch_recipients(patch_file): 56 | if not os.path.exists('./scripts/get_maintainer.pl'): 57 | return [], None 58 | recipients, err = linux_maintainers_of(patch_file) 59 | if err is not None: 60 | return [], err 61 | if is_kunit_patch(patch_file): 62 | recipients += ['Brendan Higgins ', 63 | 'David Gow ', 64 | 'kunit-dev@googlegroups.com', 65 | 'linux-kselftest@vger.kernel.org'] 66 | return recipients, None 67 | 68 | def handle_special_recipients(recipients): 69 | handled = [] 70 | for r in recipients: 71 | if os.path.exists(r): 72 | maintainers, err = linux_maintainers_of(r) 73 | if err is None: 74 | handled += maintainers 75 | else: 76 | return None, err 77 | else: 78 | handled.append(r) 79 | return handled, None 80 | 81 | def get_patch_tag_cc(patch_file): 82 | cc_list = [] 83 | with open(patch_file, 'r') as f: 84 | txt = f.read() 85 | pars = txt.split('---') 86 | if len(pars) < 2: 87 | return cc_list 88 | tags_par = pars[0].split('\n\n')[-1] 89 | for line in tags_par.split('\n'): 90 | if line.startswith('Cc: '): 91 | cc_list.append(' '.join(line.split()[1:])) 92 | return cc_list 93 | 94 | def add_patches_recipients(patch_files, to, cc, first_patch_is_cv, 95 | on_linux_tree): 96 | to, err = handle_special_recipients(to) 97 | if err is not None: 98 | return err 99 | cc, err = handle_special_recipients(cc) 100 | if err is not None: 101 | return err 102 | if on_linux_tree and os.path.exists('./scripts/get_maintainer.pl'): 103 | print('get_maintainer.pl found. add recipients using it.') 104 | 105 | total_cc = [] + cc 106 | cc_for_patches = {} 107 | for idx, patch_file in enumerate(patch_files): 108 | if first_patch_is_cv and idx == 0: 109 | continue 110 | if on_linux_tree: 111 | linux_cc, err = find_linux_patch_recipients(patch_file) 112 | if err is not None: 113 | return err 114 | total_cc += linux_cc 115 | else: 116 | linux_cc = [] 117 | patch_tag_cc = get_patch_tag_cc(patch_file) 118 | total_cc += patch_tag_cc 119 | patch_cc = sorted(list(set(cc + linux_cc + patch_tag_cc))) 120 | cc_for_patches[patch_file] = patch_cc 121 | if first_patch_is_cv: 122 | total_cc = sorted(list(set(total_cc))) 123 | cc_for_patches[patch_files[0]] = total_cc 124 | 125 | if len(to) == 0 and len(total_cc) > 0: 126 | print('\nYou did not set --to, and we will set below as Cc:') 127 | for idx, recipient in enumerate(total_cc): 128 | print('%d. %s' % (idx, recipient)) 129 | answer = input( 130 | 'Shall I set one of above as To: for all mails? [N/index/q] ') 131 | if answer == 'q': 132 | return 'user request quit' 133 | try: 134 | to = [total_cc[int(answer)]] 135 | except: 136 | to = [] 137 | for patch_file in patch_files: 138 | patch_cc = cc_for_patches[patch_file] 139 | for t in to: 140 | if t in patch_cc: 141 | patch_cc.remove(t) 142 | add_patch_recipients(patch_file, to, patch_cc) 143 | 144 | def fillup_cv(patch_file, subject, content): 145 | print('I will do below to the coverletter (%s)' % patch_file) 146 | print('- replace "*** SUBJECT HERE ***" with') 147 | print() 148 | print(' %s' % subject) 149 | print() 150 | print('- replace "*** BLURB HERE ***" with') 151 | content_lines = content.split('\n') 152 | preview_lines = [] 153 | if len(content_lines) > 5: 154 | preview_lines += content_lines[:2] 155 | preview_lines.append('[...]') 156 | preview_lines += content_lines[-2:] 157 | else: 158 | preview_lines = content_lines 159 | print() 160 | for l in preview_lines: 161 | print(' %s' % l) 162 | print() 163 | answer = input('looks good? [Y/n] ') 164 | if answer.lower() == 'n': 165 | print('ok, I will keep it (%s) untouched' % patch_file) 166 | return 167 | 168 | with open(patch_file, 'r') as f: 169 | cv_orig_content = f.read() 170 | cv_content = cv_orig_content.replace('*** SUBJECT HERE ***', subject) 171 | cv_content = cv_content.replace('*** BLURB HERE ***', content) 172 | with open(patch_file, 'w') as f: 173 | f.write(cv_content) 174 | 175 | def fillup_cv_from_commit(patch_file, commit): 176 | cv_content = subprocess.check_output( 177 | ['git', 'log', '-1', '--pretty=%b', commit]).decode().strip() 178 | # paragraphs 179 | cv_pars = cv_content.split('\n\n') 180 | if len(cv_pars) < 2: 181 | print('Less than two paragraphs. Forgiving coverletter update.') 182 | return 183 | 184 | subject = cv_pars[0] 185 | body_pars = cv_pars[1:] 186 | for line in body_pars[-1].split('\n'): 187 | if line.startswith('Signed-off-by:'): 188 | body_pars = body_pars[:-1] 189 | break 190 | content = '\n\n'.join(body_pars) 191 | 192 | fillup_cv(patch_file, subject, content) 193 | 194 | def find_topic_merge_commit(base_commit, last_commit): 195 | hashes_lines = subprocess.check_output( 196 | ['git', 'log', '-900', '--pretty=%H %P'] 197 | ).decode().strip().split('\n') 198 | for hashes in [l.split() for l in hashes_lines]: 199 | if len(hashes) != 3: 200 | continue 201 | if hashes[1] != base_commit or hashes[2] != last_commit: 202 | continue 203 | return hashes[0] 204 | return None 205 | 206 | def add_base_or_merge_commit_as_cv(patch_file, base_commit, commit_ids): 207 | merge_commit = find_topic_merge_commit(base_commit, commit_ids[0]) 208 | if merge_commit is None: 209 | answer = input( 210 | '\nMay I add the base commit to the coverletter? [Y/n] ') 211 | if answer.lower() == 'n': 212 | return 213 | 214 | fillup_cv_from_commit(patch_file, base_commit) 215 | return 216 | 217 | base_commit_title = subprocess.check_output( 218 | ['git', 'log', '-1', '--pretty=%s', base_commit]).decode().strip() 219 | merge_commit_title = subprocess.check_output( 220 | ['git', 'log', '-1', '--pretty=%s', merge_commit]).decode().strip() 221 | 222 | print('\nMay I add below as the coverletter?') 223 | print() 224 | print('1. Message of the baseline commit (%s) # default' % base_commit_title) 225 | print('2. Message of the merge commit (%s)' % merge_commit_title) 226 | print('3. No, do noting for the coverletter') 227 | print() 228 | answer = input('Select: ') 229 | selections = [base_commit, merge_commit, None] 230 | try: 231 | cv_commit = selections[int(answer) - 1] 232 | except: 233 | cv_commit = base_commit 234 | if cv_commit is None: 235 | return 236 | fillup_cv_from_commit(patch_file, cv_commit) 237 | 238 | def fillup_cv_from_file(patch_file, cv_file): 239 | with open(cv_file, 'r') as f: 240 | content = f.read() 241 | pars = content.split('\n\n') 242 | subject = pars[0] 243 | content = '\n\n'.join(pars[1:]) 244 | 245 | print("Adding cover letter content from '%s' as you requested." % cv_file) 246 | fillup_cv(patch_file, subject, content) 247 | 248 | def commit_subject_to_id(subject): 249 | subjects_txt = subprocess.check_output( 250 | 'git log -2000 --pretty=%s'.split()).decode() 251 | for idx, commit_subject in enumerate(subjects_txt.strip().split('\n')): 252 | if commit_subject != subject: 253 | continue 254 | commit_ids_txt = subprocess.check_output( 255 | 'git log -2000 --pretty=%H'.split()).decode() 256 | return commit_ids_txt.strip().split('\n')[idx] 257 | return None 258 | 259 | def convert_commits_range_txt(txt): 260 | idx = txt.find('subject(') 261 | if idx == -1: 262 | return txt, len(txt) 263 | if idx != 0: 264 | return txt[:idx], idx 265 | 266 | subject_chrs = [] 267 | parentheses_to_close = 1 268 | for idx, c in enumerate(txt[len('subject('):]): 269 | if c == '(': 270 | parentheses_to_close += 1 271 | elif c == ')': 272 | parentheses_to_close -= 1 273 | if parentheses_to_close > 0: 274 | subject_chrs.append(c) 275 | continue 276 | # assume the subject to have balanced parentheses. If not, this logic 277 | # fails. 278 | else: 279 | break 280 | processed_len = len('subject(') + idx + 1 281 | 282 | subject = ''.join(subject_chrs) 283 | commit_id = commit_subject_to_id(subject) 284 | return commit_id, processed_len 285 | 286 | def convert_commit_subjects_to_ids(commits_range_txt): 287 | ''' 288 | commits_range_txt is 'git'-supporting commits range specification. For 289 | easy specification of commits with frequent rebasing, hkml supports having 290 | 'subject()' format in the text, to specify a commit of 291 | subject. 292 | ''' 293 | converted_chrs = [] 294 | idx = 0 295 | while idx < len(commits_range_txt): 296 | converted_txt, converted_len = convert_commits_range_txt( 297 | commits_range_txt[idx:]) 298 | converted_chrs.append(converted_txt) 299 | idx += converted_len 300 | return ''.join(converted_chrs) 301 | 302 | def format_patches(args, on_linux_tree): 303 | commits_range = convert_commit_subjects_to_ids(args.commits) 304 | commit_ids = [hash for hash in subprocess.check_output( 305 | ['git', 'log', '--pretty=%H', commits_range] 306 | ).decode().strip().split('\n') if hash != ''] 307 | if len(commit_ids) == 0: 308 | return None, 'no commit to format patch' 309 | if len(commit_ids) > 1: 310 | add_cv = True 311 | else: 312 | add_cv = False 313 | 314 | base_commit = subprocess.check_output( 315 | ['git', 'rev-parse', '%s^' % commit_ids[-1]]).decode().strip() 316 | cmd = ['git', 'format-patch', commits_range, '--base', base_commit, 317 | '-o', args.output_dir] 318 | if add_cv: 319 | cmd.append('--cover-letter') 320 | if args.subject_prefix is not None: 321 | cmd.append('--subject-prefix=%s' % args.subject_prefix) 322 | elif args.rfc is True: 323 | cmd.append('--rfc') 324 | patch_files = subprocess.check_output(cmd).decode().strip().split('\n') 325 | print('made below patch files') 326 | print('\n'.join(patch_files)) 327 | print() 328 | 329 | err = add_patches_recipients(patch_files, args.to, args.cc, add_cv, 330 | on_linux_tree) 331 | if err is not None: 332 | return None, 'adding recipients fail (%s)' % err 333 | 334 | if add_cv: 335 | if args.cv is None: 336 | add_base_or_merge_commit_as_cv( 337 | patch_files[0], base_commit, commit_ids) 338 | else: 339 | fillup_cv_from_file(patch_files[0], args.cv) 340 | return patch_files, None 341 | 342 | def notify_abort(patch_files): 343 | print('Aborting remaining works.') 344 | print(' '.join([ 345 | 'Patches are generated as below.', 346 | "You can manually modify those or use 'hkml patch format' again."])) 347 | print() 348 | for patch_file in patch_files: 349 | print(' %s' % patch_file) 350 | 351 | def ok_to_continue(patch_files): 352 | answer = input('Looks good? [Y/n] ') 353 | if answer.lower() != 'n': 354 | return True 355 | notify_abort(patch_files) 356 | return False 357 | 358 | def review_patches(on_linux_tree, patch_files): 359 | '''Return whether to abort remaining works''' 360 | if on_linux_tree and os.path.exists('./scripts/checkpatch.pl'): 361 | print('\ncheckpatch.pl found. shall I run it?') 362 | print('(hint: you can do this manually via \'hkml patch check\')') 363 | answer = input('[Y/n/q] ') 364 | if answer.lower() == 'q': 365 | return True 366 | if answer.lower() != 'n': 367 | hkml_patch.check_patches( 368 | './scripts/checkpatch.pl', patch_files, None, 369 | rm_patches=False) 370 | if not ok_to_continue(patch_files): 371 | return True 372 | 373 | print('\nwould you review subjects of formatted patches?') 374 | answer = input('[Y/n/q] ') 375 | if answer.lower() == 'q': 376 | return True 377 | if answer.lower() != 'n': 378 | print('below are the subjects') 379 | for patch_file in patch_files: 380 | print(_hkml.read_mbox_file(patch_file)[0].subject) 381 | print() 382 | if not ok_to_continue(patch_files): 383 | return True 384 | 385 | print('\nwould you review recipients of formatted patches?') 386 | print('(hint: you can do this manually via \'hkml patch recipients\')') 387 | answer = input('[Y/n/q] ') 388 | if answer.lower() == 'q': 389 | return True 390 | if answer.lower() != 'n': 391 | hkml_patch.list_recipients(patch_files) 392 | if not ok_to_continue(patch_files): 393 | return True 394 | return False 395 | 396 | def main(args): 397 | on_linux_tree = is_linux_tree('./') 398 | 399 | patch_files, err = format_patches(args, on_linux_tree) 400 | if err is not None: 401 | print('generating patch files failed (%s)' % err) 402 | return -1 403 | 404 | abort = review_patches(on_linux_tree, patch_files) 405 | if abort is True: 406 | return 407 | 408 | print("\nMay I send the patches? If you say yes, I will do below") 409 | print() 410 | print(' git send-email \\') 411 | for patch_file in patch_files: 412 | print(' %s \\' % patch_file) 413 | print() 414 | print(' '.join([ 415 | 'You can manually review and modify the patch files', 416 | 'before answering the next question.'])) 417 | answer = input('Do it? [y/N] ') 418 | 419 | if answer.lower() == 'y': 420 | subprocess.call(['git', 'send-email'] + patch_files) 421 | 422 | def set_argparser(parser): 423 | parser.add_argument('commits', metavar='', 424 | help='commits to convert to patch files') 425 | parser.add_argument('-o', '--output_dir', metavar='', default='./', 426 | help='directory to save formatted patch files') 427 | parser.add_argument('--rfc', action='store_true', 428 | help='mark as RFC patches') 429 | parser.add_argument('--subject_prefix', metavar='', 430 | help='subject prefix') 431 | if sys.version_info >= (3, 7): 432 | parser.add_argument('--to', metavar='', nargs='+', 433 | default=[], action='extend', 434 | help='To: recipients') 435 | parser.add_argument('--cc', metavar='', nargs='+', 436 | default=[], action='extend', 437 | help='Cc: recipients') 438 | else: 439 | parser.add_argument('--to', metavar='', nargs='+', 440 | default=[], 441 | help='To: recipients') 442 | parser.add_argument('--cc', metavar='', nargs='+', 443 | default=[], 444 | help='Cc: recipients') 445 | parser.add_argument('--cv', metavar='', 446 | help='file containing cover letter content') 447 | parser.epilog = ' '.join([ 448 | 'If this is called on linux tree and a source file is given to', 449 | '--to and/or --cc, get_maintainer.pl found maintainers of the file', 450 | 'are added as To and/or Cc, respectively.', 451 | ]) 452 | -------------------------------------------------------------------------------- /src/hkml_reply.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0 2 | 3 | import subprocess 4 | import tempfile 5 | 6 | import _hkml 7 | import _hkml_list_cache 8 | import hkml_list 9 | import hkml_send 10 | import hkml_write 11 | import hkml_tag 12 | 13 | def format_reply_subject(mail): 14 | subject = mail.get_field('subject') 15 | if subject and subject.split()[0].lower() != 're:': 16 | subject = 'Re: %s' % subject 17 | return subject 18 | 19 | def format_reply(mail, attach_file): 20 | subject = format_reply_subject(mail) 21 | in_reply_to = mail.get_field('message-id') 22 | cc = [x for x in [mail.get_field('to'), mail.get_field('cc')] if x] 23 | to = [mail.get_field('from')] 24 | 25 | body_lines = [] 26 | date = mail.get_field('date') 27 | if date and to[0]: 28 | body_lines.append('On %s %s wrote:' % (date, to[0])) 29 | body_lines.append('') 30 | body = mail.get_field('body') 31 | for line in body.split('\n'): 32 | body_lines.append('> %s' % line) 33 | body = '\n'.join(body_lines) 34 | return hkml_write.format_mbox(subject, in_reply_to, to, cc, body, 35 | from_=None, draft_mail=None, 36 | attach_files=attach_file) 37 | 38 | def reply(mail, attach_files, format_only): 39 | reply_mbox_str = format_reply(mail, attach_files) 40 | if format_only: 41 | print(reply_mbox_str) 42 | return 43 | 44 | fd, reply_tmp_path = tempfile.mkstemp(prefix='hkml_reply_') 45 | with open(reply_tmp_path, 'w') as f: 46 | f.write(reply_mbox_str) 47 | err = hkml_write.open_editor(reply_tmp_path) 48 | if err is not None: 49 | print(err) 50 | exit(1) 51 | hkml_send.send_mail(reply_tmp_path, get_confirm=True, erase_mbox=True, 52 | orig_draft_subject=None) 53 | 54 | def main(args): 55 | if args.mail.isdigit(): 56 | mail = _hkml_list_cache.get_mail(int(args.mail)) 57 | elif args.mail == 'clipboard': 58 | mails, err = _hkml.read_mails_from_clipboard() 59 | if err != None: 60 | print('reading mails in clipboard failed: %s' % err) 61 | exit(1) 62 | if len(mails) != 1: 63 | print('multiple mails in clipboard') 64 | exit(1) 65 | mail = mails[0] 66 | else: 67 | print('unsupported (%s)' % args.mail) 68 | 69 | if mail is None: 70 | print('mail is not cached') 71 | exit(1) 72 | 73 | reply(mail, args.attach, args.format_only) 74 | 75 | def set_argparser(parser): 76 | parser.description = 'reply to a mail' 77 | parser.add_argument( 78 | 'mail', metavar='', 79 | help=' '.join( 80 | ['The mail to reply to.', 81 | 'Could be index on the list, or \'clipboard\''])) 82 | hkml_write.add_common_arguments(parser) 83 | -------------------------------------------------------------------------------- /src/hkml_send.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # SPDX-License-Identifier: GPL-2.0 3 | 4 | import argparse 5 | import datetime 6 | import os 7 | import subprocess 8 | 9 | import _hkml 10 | import hkml_tag 11 | import hkml_write 12 | 13 | def draft_or_sent_mail(draft_file, msgid): 14 | with open(draft_file, 'r') as f: 15 | draft_content = f.read() 16 | paragraphs = draft_content.split('\n\n') 17 | if len(paragraphs) == 0: 18 | header_lines = [] 19 | else: 20 | header_lines = paragraphs[0].split('\n') 21 | has_date = False 22 | has_msgid = False 23 | has_from = False 24 | for line in header_lines: 25 | if line.startswith('Date: '): 26 | has_date = True 27 | if line.startswith('Messagge-ID: '): 28 | has_msgid = True 29 | if line.startswith('From: '): 30 | has_from = True 31 | 32 | fake_header = ['From hkml_draft Thu Jan 1 00:00:00 1970'] 33 | if has_date is False: 34 | fake_header.append('Date: %s' % datetime.datetime.now().strftime( 35 | '%a, %d %b %Y %H:%M:%S %z')) 36 | if has_msgid is False: 37 | if msgid is None: 38 | msgid = '%s' % datetime.datetime.now().strftime( 39 | 'hkml_draft-%Y-%m-%d-%H-%M-%S') 40 | fake_header.append('Message-ID: %s' % msgid) 41 | if has_from is False: 42 | fake_header.append('From: ') 43 | draft_mbox_str = '\n'.join(fake_header + [draft_content]) 44 | return _hkml.Mail(mbox=draft_mbox_str) 45 | 46 | def handle_user_edit_mistakes(tmp_path): 47 | with open(tmp_path, 'r') as f: 48 | written_mail = f.read() 49 | pars = written_mail.split('\n\n') 50 | header = pars[0] 51 | header_lines = [] 52 | for line in header.split('\n'): 53 | if line in [ 54 | 'To: /* write recipients here and REMOVE this comment */', 55 | 'Cc: /* wrtite cc recipients here and REMOVE this comment */']: 56 | continue 57 | header_lines.append(line) 58 | header = '\n'.join(header_lines) 59 | 60 | # Seems silly, but we have to re-join the split body, then turn them 61 | # into individual lines again. This preserves all empty lines. 62 | body = '\n\n'.join(pars[1:]).split('\n') 63 | body_lines = [] 64 | idx = 0 65 | while idx < len(body): 66 | # A user might delete the newline on top of the signature, so just check 67 | # for the contents of the comment block. 68 | if len(body) - idx >= hkml_write.SIGNATURE_WARNING_LEN and \ 69 | body[idx:idx + hkml_write.SIGNATURE_WARNING_LEN] == \ 70 | hkml_write.SIGNATURE_WARNING[1:]: 71 | 72 | # If the warning's newline was not touched, remove it as well 73 | if idx > 0 and body[idx-1] == '': 74 | body_lines.pop() 75 | idx += hkml_write.SIGNATURE_WARNING_LEN 76 | continue 77 | 78 | line = body[idx] 79 | if line != '/* write your message here (keep the above blank line) */': 80 | body_lines.append(line) 81 | idx += 1 82 | body = '\n'.join(body_lines) 83 | 84 | written_mail = '\n\n'.join([header] + [body]) 85 | with open(tmp_path, 'w') as f: 86 | f.write(written_mail) 87 | 88 | def send_mail(mboxfile, get_confirm, erase_mbox, orig_draft_subject=None): 89 | do_send = True 90 | handle_user_edit_mistakes(mboxfile) 91 | if get_confirm: 92 | with open(mboxfile, 'r') as f: 93 | print(f.read()) 94 | print('Above is what you wrote.') 95 | sent = False 96 | msgid = None 97 | for line in _hkml.cmd_lines_output(['git', 'send-email', mboxfile, 98 | # for getting message-id 99 | '--confirm', 'always']): 100 | fields = line.split() 101 | if len(fields) == 2 and fields[0].lower() == 'message-id:': 102 | msgid = fields[1] 103 | if fields == ['Result:', '250'] or fields == ['Result:' , 'OK']: 104 | sent = True 105 | hkml_tag.handle_may_sent_mail( 106 | draft_or_sent_mail(mboxfile, msgid), sent, orig_draft_subject) 107 | if erase_mbox: 108 | os.remove(mboxfile) 109 | 110 | def main(args): 111 | send_mail(args.mbox_file, get_confirm=False, erase_mbox=False) 112 | 113 | def set_argparser(parser=None): 114 | parser.description = 'send a mail' 115 | parser.add_argument('mbox_file', metavar='', 116 | help='Mbox format file of the mail to send.') 117 | -------------------------------------------------------------------------------- /src/hkml_signature.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0 2 | 3 | import json 4 | import os 5 | import subprocess 6 | import sys 7 | import tempfile 8 | 9 | import _hkml 10 | import hkml_write 11 | 12 | ''' 13 | The file is a json format, having a list of strings. 14 | ''' 15 | def signatures_file_path(): 16 | return os.path.join(_hkml.get_hkml_dir(), 'signatures') 17 | 18 | def read_signatures_file(): 19 | if not os.path.isfile(signatures_file_path()): 20 | return ['Sent using hkml (https://github.com/sjp38/hackermail)'] 21 | with open(signatures_file_path(), 'r') as f: 22 | return json.load(f) 23 | 24 | def write_signatures_file(signatures): 25 | with open(signatures_file_path(), 'w') as f: 26 | json.dump(signatures, f, indent=4) 27 | 28 | def add_signature(): 29 | fd, tmp_path = tempfile.mkstemp(prefix='hkml_signature_') 30 | with open(tmp_path, 'w') as f: 31 | f.write('\n'.join([ 32 | '', 33 | '# Please enter the signature you want to add.', 34 | '# Lines starting with "#" will be ingored.'])) 35 | err = hkml_write.open_editor(tmp_path, 'signature') 36 | if err is not None: 37 | print(err) 38 | eixt(1) 39 | with open(tmp_path, 'r') as f: 40 | lines = [] 41 | for line in f: 42 | if line.startswith('#'): 43 | continue 44 | lines.append(line.strip()) 45 | signature = '\n'.join(lines) 46 | os.remove(tmp_path) 47 | signatures = read_signatures_file() 48 | signatures.append(signature) 49 | write_signatures_file(signatures) 50 | return 51 | 52 | def edit_signature(signature_idx): 53 | signatures = read_signatures_file() 54 | signature = signatures[signature_idx] 55 | fd, tmp_path = tempfile.mkstemp(prefix='hkml_signature_') 56 | with open(tmp_path, 'w') as f: 57 | f.write('\n'.join([ 58 | signature, 59 | '', 60 | '# Please edit the signature above as you want.', 61 | '# Lines starting with "#" will be ingored.'])) 62 | hkml_write.open_editor(tmp_path, 'signature') 63 | with open(tmp_path, 'r') as f: 64 | lines = [] 65 | for line in f: 66 | if line.startswith('#'): 67 | continue 68 | lines.append(line.strip()) 69 | signature = '\n'.join(lines) 70 | os.remove(tmp_path) 71 | signatures[signature_idx] = signature 72 | write_signatures_file(signatures) 73 | 74 | def number_suffix(n): 75 | if n % 100 in (11, 12, 13): 76 | return 'th' 77 | elif n % 10 == 1: 78 | return 'st' 79 | elif n % 10 == 2: 80 | return 'nd' 81 | elif n % 10 == 3: 82 | return 'rd' 83 | else: 84 | return 'th' 85 | 86 | def main(args): 87 | if args.action == 'list': 88 | signatures = read_signatures_file() 89 | for idx, signature in enumerate(signatures): 90 | print('%d%s signature' % (idx, number_suffix(idx))) 91 | print('```') 92 | print(signature) 93 | print('```') 94 | elif args.action == 'add': 95 | add_signature() 96 | elif args.action == 'edit': 97 | edit_signature(args.signature_idx) 98 | elif args.action == 'remove': 99 | signatures = read_signatures_file() 100 | del signatures[args.signature_idx] 101 | write_signatures_file(signatures) 102 | 103 | def set_argparser(parser): 104 | parser.description = 'manage signature for mails' 105 | if sys.version_info >= (3,8): 106 | subparsers = parser.add_subparsers( 107 | title='action', dest='action', metavar='', required=True) 108 | else: 109 | subparsers = parser.add_subparsers( 110 | title='action', dest='action', metavar='') 111 | 112 | parser_add = subparsers.add_parser('add', help='add a signature') 113 | parser_list = subparsers.add_parser('list', help='list signatures') 114 | parser_edit = subparsers.add_parser('edit', help='edit a signature') 115 | parser_edit.add_argument( 116 | 'signature_idx', metavar='', type=int, 117 | help='index of the signature from the "list" output') 118 | parser_remove = subparsers.add_parser('remove', help='remove signature') 119 | parser_remove.add_argument( 120 | 'signature_idx', metavar='', type=int, 121 | help='index of the signature from the "list" output') 122 | parser.epilog = ' '.join([ 123 | 'If any signature exists,', 124 | 'hkml will add those on mail drafts with instructions.', 125 | ]) 126 | -------------------------------------------------------------------------------- /src/hkml_sync.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0 2 | 3 | import os 4 | import subprocess 5 | 6 | import _hkml 7 | 8 | ''' 9 | Synchronize personal files in .hkm/ via user-specified git repo. 10 | ''' 11 | 12 | def files_to_sync(hkml_dir): 13 | files = ['manifest', 'monitor_requests', 'tags'] 14 | for filename in os.listdir(hkml_dir): 15 | if filename.startswith('tags_'): 16 | files.append(filename) 17 | return files 18 | 19 | def commit_changes(hkml_dir): 20 | git_cmd = ['git', '-C', hkml_dir] 21 | for file in files_to_sync(hkml_dir): 22 | file_path = os.path.join(hkml_dir, file) 23 | if os.path.isfile(file_path): 24 | if subprocess.call(git_cmd + ['add', file]) != 0: 25 | print('git-addding file (%s) failed' % file_path) 26 | exit(1) 27 | else: 28 | # may fail if already removed, or this is first call 29 | subprocess.call(git_cmd + ['rm', file_path]) 30 | # don't check the return value, since it could fail if no change is really 31 | # made. 32 | subprocess.call(git_cmd + ['commit', '-m', 'hkml sync commit']) 33 | 34 | def setup_git(hkml_dir, remote): 35 | if remote is None: 36 | print('This is initial time of sync. Please provide --remote') 37 | exit(1) 38 | git_cmd = ['git', '-C', hkml_dir] 39 | if subprocess.call(git_cmd + ['init']) != 0: 40 | print('git initializing failed') 41 | eixt(1) 42 | if subprocess.call( 43 | git_cmd + ['remote', 'add', 'sync-target', remote]) != 0: 44 | print('adding remote failed') 45 | exit(1) 46 | if subprocess.call(git_cmd + ['fetch', 'sync-target']) != 0: 47 | print('fetching remote failed') 48 | exit(1) 49 | branches = subprocess.check_output( 50 | git_cmd + ['branch', '-r']).decode().split() 51 | if 'sync-target/latest' in branches: 52 | if subprocess.call( 53 | git_cmd + ['reset', '--hard', 'sync-target/latest']) != 0: 54 | print('checking remote out failed') 55 | exit(1) 56 | 57 | def syncup(hkml_dir, remote): 58 | git_cmd = ['git', '-C', hkml_dir] 59 | 60 | commit_changes(hkml_dir) 61 | 62 | if remote is not None: 63 | cmd = git_cmd + ['remote', 'get-url', 'sync-target'] 64 | current_sync_target = subprocess.check_output(cmd).decode().strip() 65 | if remote != current_sync_target: 66 | cmd = git_cmd + ['remote', 'set-url', 'sync-target', remote] 67 | if subprocess.call(cmd) != 0: 68 | print('remote url update failed') 69 | 70 | if subprocess.call(git_cmd + ['fetch', 'sync-target']) != 0: 71 | print('fetching remote failed') 72 | exit(1) 73 | if subprocess.call(git_cmd + ['rebase', 'sync-target/latest']) != 0: 74 | print('rebasing failed') 75 | exit(1) 76 | 77 | if subprocess.call(git_cmd + ['push', 'sync-target', 'HEAD:latest']) != 0: 78 | print('push failed') 79 | exit(1) 80 | 81 | def syncup_ready(): 82 | return os.path.isdir(os.path.join(_hkml.get_hkml_dir(), '.git')) 83 | 84 | def main(args): 85 | if not syncup_ready(): 86 | setup_git(_hkml.get_hkml_dir(), args.remote) 87 | syncup(_hkml.get_hkml_dir(), args.remote) 88 | 89 | def set_argparser(parser): 90 | parser.description = 'synchronize the outputs and setups' 91 | parser.add_argument('--remote', metavar='', 92 | help='remote git repo to synchronize with') 93 | -------------------------------------------------------------------------------- /src/hkml_tag.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0 2 | 3 | import json 4 | import os 5 | import sys 6 | 7 | import _hkml 8 | import _hkml_list_cache 9 | import hkml_sync 10 | 11 | ''' 12 | Tags information is saved in a json file called 'tags' under the hkml 13 | directory. 14 | 15 | The data structure is a map. Keys are msgid of mails. Values are map having 16 | keys 'mail' and 'tags'. 'mail' is _hkml.Mail.to_kvpairs() output of the mail 17 | of the message id. 'tags' is a list of tags for the mail. 18 | ''' 19 | 20 | def tag_file_path(): 21 | return os.path.join(_hkml.get_hkml_dir(), 'tags') 22 | 23 | def read_tags_file(): 24 | tags_map = {} 25 | for filename in os.listdir(_hkml.get_hkml_dir()): 26 | if not filename.startswith('tags'): 27 | continue 28 | with open(os.path.join(_hkml.get_hkml_dir(), filename), 'r') as f: 29 | for k, v in json.load(f).items(): 30 | tags_map[k] = v 31 | return tags_map 32 | 33 | def write_tags_single_file(tags_map, file_idx): 34 | if file_idx is not None: 35 | suffix = '_%d' % file_idx 36 | else: 37 | suffix = '' 38 | file_path = os.path.join(_hkml.get_hkml_dir(), 'tags%s' % suffix) 39 | with open(file_path, 'w') as f: 40 | json.dump(tags_map, f, indent=4, sort_keys=True) 41 | 42 | def write_tags_file(tags_map, sync_after): 43 | max_mails_per_file = 100 44 | tags_map_to_write = {} 45 | file_idx = 0 46 | for msgid in sorted(tags_map.keys()): 47 | tags_map_to_write[msgid] = tags_map[msgid] 48 | if len(tags_map_to_write) == max_mails_per_file: 49 | write_tags_single_file(tags_map_to_write, file_idx) 50 | tags_map_to_write = {} 51 | file_idx += 1 52 | write_tags_single_file(tags_map_to_write, None) 53 | 54 | if hkml_sync.syncup_ready() and sync_after is True: 55 | hkml_sync.syncup(_hkml.get_hkml_dir(), remote=None) 56 | 57 | class TagChange: 58 | mail = None 59 | add = None 60 | remove = None 61 | 62 | def __init__(self, mail, add=False, remove=False): 63 | self.mail = mail 64 | self.add = add 65 | self.remove = remove 66 | 67 | def mails_of_tag(tag): 68 | tags_map = read_tags_file() 69 | mails = [] 70 | for msgid in tags_map: 71 | tags = tags_map[msgid]['tags'] 72 | if tag in tags: 73 | mails.append(_hkml.Mail(kvpairs=tags_map[msgid]['mail'])) 74 | return mails 75 | 76 | def ask_sync_before_change(): 77 | if hkml_sync.syncup_ready(): 78 | answer = input('Gonna read/write tags. Sync before and after? [Y/n] ') 79 | if answer.lower() != 'n': 80 | hkml_sync.syncup(_hkml.get_hkml_dir(), remote=None) 81 | return True 82 | return False 83 | 84 | def get_mails_of_subject_tag(subject, tag): 85 | mails = [] 86 | tags_map = read_tags_file() 87 | for msgid in tags_map: 88 | tags = tags_map[msgid]['tags'] 89 | if not tag in tags: 90 | continue 91 | mail = _hkml.Mail(kvpairs=tags_map[msgid]['mail']) 92 | if mail.subject == subject: 93 | mails.append(mail) 94 | return mails 95 | 96 | def suggest_removing_drafts_of_subject(subject, tags_map): 97 | for msgid in tags_map: 98 | tags = tags_map[msgid]['tags'] 99 | if not 'drafts' in tags: 100 | continue 101 | draft_mail = _hkml.Mail(kvpairs=tags_map[msgid]['mail']) 102 | if draft_mail.subject != subject: 103 | continue 104 | while True: 105 | prompt = 'remove draft of subject "%s" that written on %s? [y/n] ' % ( 106 | subject, draft_mail.date) 107 | answer = input(prompt) 108 | if answer.lower() not in ['y', 'n']: 109 | continue 110 | if answer.lower() == 'y': 111 | tags.remove('drafts') 112 | break 113 | 114 | def add_tags_to_map(mail, tags, tags_map): 115 | msgid = mail.get_field('message-id') 116 | 117 | if not msgid in tags_map: 118 | tags_map[msgid] = {'mail': mail.to_kvpairs(), 'tags': tags} 119 | else: 120 | existing_tags = tags_map[msgid]['tags'] 121 | for tag in tags: 122 | if not tag in existing_tags: 123 | existing_tags.append(tag) 124 | 125 | def do_add_tags(mail, tags, draft_subject=None): 126 | sync_after = ask_sync_before_change() 127 | 128 | tags_map = read_tags_file() 129 | 130 | if 'drafts' in tags or 'sent' in tags: 131 | if draft_subject is None: 132 | draft_subject = mail.subject 133 | suggest_removing_drafts_of_subject(draft_subject, tags_map) 134 | 135 | add_tags_to_map(mail, tags, tags_map) 136 | write_tags_file(tags_map, sync_after) 137 | 138 | def add_tags(mail_idx, tags): 139 | mail = _hkml_list_cache.get_mail(mail_idx) 140 | if mail is None: 141 | print('failed getting mail of the index. Maybe wrong index?') 142 | exit(1) 143 | 144 | do_add_tags(mail, tags, None) 145 | 146 | def do_remove_tags(mail, tags): 147 | msgid = mail.get_field('message-id') 148 | 149 | sync_after = ask_sync_before_change() 150 | tags_map = read_tags_file() 151 | if not msgid in tags_map: 152 | print('seems the index is wrong, or having no tag') 153 | exit(1) 154 | existing_tags = tags_map[msgid]['tags'] 155 | for tag in tags: 156 | if not tag in existing_tags: 157 | print('the mail is not having the tag') 158 | exit(1) 159 | existing_tags.remove(tag) 160 | write_tags_file(tags_map, sync_after) 161 | 162 | def remove_tags(mail_idx, tags): 163 | mail = _hkml_list_cache.get_mail(mail_idx) 164 | if mail is None: 165 | print('failed getting mail of the index. Maybe wrong index?') 166 | exit(1) 167 | 168 | do_remove_tags(mail, tags) 169 | 170 | def get_tag_nr_mails(): 171 | ''' 172 | Return dict having tags as key, numbers of mails of the tag as value 173 | ''' 174 | tag_nr_mails = {} 175 | tags_map = read_tags_file() 176 | for msgid in tags_map: 177 | for tag in tags_map[msgid]['tags']: 178 | if not tag in tag_nr_mails: 179 | tag_nr_mails[tag] = 0 180 | tag_nr_mails[tag] += 1 181 | return tag_nr_mails 182 | 183 | def tag_exists(tagname): 184 | return tagname in get_tag_nr_mails() 185 | 186 | def list_tags(): 187 | tag_nr_mails = get_tag_nr_mails() 188 | for tag in sorted(tag_nr_mails.keys()): 189 | print('%s: %d mails' % (tag, tag_nr_mails[tag])) 190 | 191 | def main(args): 192 | if args.action == 'add': 193 | return add_tags(args.mail_idx, args.tags) 194 | elif args.action == 'remove': 195 | if args.mail_idx is None and len(args.mails) == 0: 196 | print('mail to remove tags are not specified') 197 | exit(1) 198 | if args.mail_idx is not None: 199 | args.mails.append(args.mail_idx) 200 | for mail_idx in args.mails: 201 | remove_tags(mail_idx, args.tags) 202 | return 203 | elif args.action == 'list': 204 | return list_tags() 205 | 206 | def set_argparser(parser): 207 | parser.description = 'manage tags of mails' 208 | if sys.version_info >= (3,7): 209 | subparsers = parser.add_subparsers( 210 | title='action', dest='action', metavar='', 211 | required=True) 212 | else: 213 | subparsers = parser.add_subparsers( 214 | title='action', dest='action', metavar='') 215 | 216 | parser_add = subparsers.add_parser('add', help='add tags to a mail') 217 | parser_add.add_argument( 218 | 'mail_idx', metavar='', type=int, 219 | help='index of the mail to add tags') 220 | parser_add.add_argument( 221 | 'tags', metavar='', nargs='+', 222 | help='tags to add to the mail') 223 | 224 | parser_remove = subparsers.add_parser( 225 | 'remove', help='remove tags from a mail') 226 | parser_remove.add_argument( 227 | 'mail_idx', metavar='', type=int, nargs='?', 228 | help='index of the mail to remove tags') 229 | parser_remove.add_argument( 230 | 'tags', metavar='', nargs='+', 231 | help='tags to remove from the mail') 232 | parser_remove.add_argument('--mails', metavar='', type=int, 233 | nargs='+', default=[], 234 | help='indexes of the mails to remove tags') 235 | 236 | parser_list = subparsers.add_parser('list', help='list tags') 237 | 238 | def handle_may_sent_mail(mail, sent, orig_draft_subject): 239 | '''Handle tags of a mail that may sent or not''' 240 | 241 | sync_after = ask_sync_before_change() 242 | 243 | # suggest tagging the may or may not sent mail 244 | if sent: 245 | tag_name = 'sent' 246 | else: 247 | tag_name = 'drafts' 248 | answer = input('Tag the mail (%s) as %s? [Y/n] ' 249 | % (mail.subject, tag_name)) 250 | tag_may_sent_mail = answer.lower() != 'n' 251 | 252 | tags_map = read_tags_file() 253 | 254 | # regardless of the answer to the above question, suggest removing 255 | # drafts 256 | if orig_draft_subject is None: 257 | orig_draft_subject = mail.subject 258 | suggest_removing_drafts_of_subject(orig_draft_subject, tags_map) 259 | 260 | # do the tagging of the mail. Do this after the above duplicate drafts 261 | # removing, since otherwise this mail may tagged as draft and the duplicate 262 | # draft removing may find it as the draft. 263 | if tag_may_sent_mail: 264 | add_tags_to_map(mail, [tag_name], tags_map) 265 | 266 | write_tags_file(tags_map, sync_after) 267 | -------------------------------------------------------------------------------- /src/hkml_thread.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # SPDX-License-Identifier: GPL-2.0 3 | 4 | import os 5 | import subprocess 6 | import tempfile 7 | 8 | import _hkml 9 | import _hkml_list_cache 10 | import hkml_cache 11 | import hkml_list 12 | 13 | def thread_str(mail_id, dont_use_internet, show_url): 14 | if mail_id.isdigit(): 15 | mail_id = int(mail_id) 16 | msgid = None 17 | else: 18 | msgid = mail_id 19 | 20 | mails_to_show = None 21 | if dont_use_internet is False: 22 | if msgid is None: 23 | mail = _hkml_list_cache.get_mail(mail_id, not_thread_idx=True) 24 | if mail is None: 25 | print('wrong ') 26 | exit(1) 27 | msgid = mail.get_field('message-id') 28 | 29 | mails_to_show, err = hkml_list.get_thread_mails_from_web(msgid) 30 | if err is not None: 31 | print(err) 32 | else: 33 | mail_id = None 34 | if mails_to_show is None: 35 | mails_to_show = _hkml_list_cache.last_listed_mails() 36 | # TODO: Support msgid 37 | 38 | nr_cols_in_line = int(os.get_terminal_size().columns * 9 / 10) 39 | list_decorator = hkml_list.MailListDecorator(None) 40 | list_decorator.show_stat = False 41 | list_decorator.ascend = True, 42 | list_decorator.sort_threads_by = ['first_date'], 43 | list_decorator.collapse = False 44 | list_decorator.show_url = show_url 45 | list_decorator.cols = nr_cols_in_line 46 | list_decorator.show_runtime_profile = False 47 | 48 | list_data, err = hkml_list.mails_to_list_data( 49 | mails_to_show, do_find_ancestors_from_cache=False, mails_filter=None, 50 | list_decorator=list_decorator, show_thread_of=mail_id, 51 | runtime_profile=[], stat_only=False, stat_authors=False) 52 | if err is not None: 53 | print('mails_to_list_data() fail (%s)' % err) 54 | exit(1) 55 | return list_data 56 | 57 | def main(args): 58 | if args.mail_id is None: 59 | cached = _hkml_list_cache.get_last_thread() 60 | if cached is None: 61 | print("No message identifier or cached thread") 62 | return 63 | to_show, mail_idx_key_map = cached 64 | _hkml_list_cache.writeback_list_output() 65 | hkml_list.show_list(to_show, to_stdout=False, 66 | to_less=args.no_interactive, 67 | mail_idx_key_map=mail_idx_key_map) 68 | return 69 | 70 | list_data = thread_str(args.mail_id, 71 | args.dont_use_internet, args.url) 72 | to_show = list_data.text 73 | mail_idx_key_map = list_data.mail_idx_key_map 74 | if args.dont_use_internet is False: 75 | hkml_cache.writeback_mails() 76 | _hkml_list_cache.set_item('thread_output', list_data) 77 | hkml_list.show_list(to_show, to_stdout=False, to_less=args.no_interactive, 78 | mail_idx_key_map=mail_idx_key_map) 79 | 80 | def set_argparser(parser=None): 81 | parser.description='list mails of a thread' 82 | _hkml.set_manifest_option(parser) 83 | parser.add_argument( 84 | 'mail_id', metavar='', nargs='?', 85 | help=' '.join([ 86 | 'Identifier of any mail in the thread to list.', 87 | 'Could be the index on the last-generated list or thread,', 88 | 'or the Message-ID of the mail.', 89 | 'If this is not given, shows last thread output.', 90 | ])) 91 | parser.add_argument('--url', action='store_true', 92 | help='print URLs for mails') 93 | parser.add_argument( 94 | '--dont_use_internet', action='store_true', 95 | help='don\'t use internet do get the mails') 96 | parser.add_argument( 97 | '--no_interactive', action='store_true', 98 | help='don\'t use hkml interactive list viewer') 99 | -------------------------------------------------------------------------------- /src/hkml_view_text.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0 2 | 3 | # text viewer 4 | 5 | import os 6 | import subprocess 7 | import tempfile 8 | 9 | import _hkml 10 | import _hkml_cli 11 | import _hkml_list_cache 12 | import hkml_cache 13 | import hkml_common 14 | import hkml_list 15 | import hkml_open 16 | import hkml_view 17 | import hkml_view_mails 18 | import hkml_write 19 | 20 | class TextViewData: 21 | mail = None 22 | mails_list = None 23 | 24 | def __init__(self, mail, mails_list): 25 | self.mail = mail 26 | self.mails_list = mails_list 27 | 28 | def menu_exec_git(slist, answer, selection): 29 | hkml_view.shell_mode_end(slist) 30 | git_cmd = selection.data 31 | words = git_cmd.split() 32 | try: 33 | output = subprocess.check_output( 34 | words, stderr=subprocess.DEVNULL).decode().split('\n') 35 | except Exception as e: 36 | output = ['failed: %s' % e, '', 37 | 'wrong commit id, or you are not on the git repo?'] 38 | show_text_viewer(slist.screen, output) 39 | hkml_view.shell_mode_start(slist) 40 | 41 | def menu_hkml_thread(slist, answer, selection): 42 | hkml_view.shell_mode_end(slist) 43 | msgid = selection.data 44 | args = hkml_view_mails.hkml_list_args_for_msgid(msgid) 45 | hkml_view_mails.gen_show_mails_list(slist.screen, args) 46 | hkml_view.shell_mode_start(slist) 47 | 48 | def menu_hkml_open(slist, answer, selection): 49 | hkml_view.shell_mode_end(slist) 50 | msgid = selection.data 51 | args = hkml_view_mails.hkml_list_args_for_msgid(msgid) 52 | args.stdout = True # to bypass dim_old suggestion 53 | list_data, err = hkml_list.args_to_mails_list_data( args) 54 | for idx, cache_key in list_data.mail_idx_key_map.items(): 55 | mail = hkml_cache.get_mail(key=cache_key) 56 | if mail is None: 57 | continue 58 | if mail.get_field('message-id') == msgid: 59 | _, cols = slist.screen.getmaxyx() 60 | lines = hkml_open.mail_display_str(mail, cols).split('\n') 61 | show_text_viewer(slist.screen, lines) 62 | break 63 | hkml_view.shell_mode_start(slist) 64 | 65 | def menu_open_file(slist, answer, selection): 66 | hkml_view.shell_mode_end(slist) 67 | file_path = selection.data 68 | with open(file_path, 'r') as f: 69 | lines = f.read().split('\n') 70 | show_text_viewer(slist.screen, lines) 71 | hkml_view.shell_mode_start(slist) 72 | 73 | def menu_open_file_editor(slist, answer, selection): 74 | file_path = selection.data 75 | err = hkml_write.open_editor(file_path, 'file') 76 | if err is not None: 77 | print(err) 78 | exit(1) 79 | 80 | def is_git_hash(word): 81 | if len(word) < 10: 82 | return False 83 | for c in word: 84 | if c not in '0123456789abcdef': 85 | return False 86 | return True 87 | 88 | def menu_selections_for_commit(line): 89 | for separator in [',', '(', ')', '/', '[', ']', '"']: 90 | line = line.replace(separator, ' ') 91 | selections = [] 92 | for word in line.split(): 93 | if not is_git_hash(word): 94 | continue 95 | show_cmd = 'git show %s' % word 96 | log_five_cmd = 'git log -n 5 %s' % word 97 | log_oneline_cmd = 'git log --oneline -n 64 %s' % word 98 | selections.append( 99 | _hkml_cli.Selection( 100 | show_cmd, handle_fn=menu_exec_git, data=show_cmd)) 101 | selections.append( 102 | _hkml_cli.Selection( 103 | log_five_cmd, handle_fn=menu_exec_git, data=log_five_cmd)) 104 | selections.append( 105 | _hkml_cli.Selection( 106 | log_oneline_cmd, handle_fn=menu_exec_git, 107 | data=log_oneline_cmd)) 108 | return selections 109 | 110 | def get_msgid_from_public_inbox_link(word): 111 | ''' 112 | If it is http url and has @ in a field, assume it is msgid link 113 | ''' 114 | if not word.startswith('http'): 115 | return None 116 | tokens = word.split('/') 117 | if len(tokens) < 4: 118 | return None 119 | for token in tokens[3:]: 120 | if '@' in token: 121 | return token 122 | return None 123 | 124 | def menu_selections_for_msgid(line): 125 | for separator in [',', '(', ')', '[', ']', '"']: 126 | line = line.replace(separator, ' ') 127 | selections = [] 128 | for word in line.split(): 129 | msgid = get_msgid_from_public_inbox_link(word) 130 | if msgid is None: 131 | continue 132 | selections.append(_hkml_cli.Selection( 133 | 'hkml thread %s' % msgid, handle_fn=menu_hkml_thread, data=msgid)) 134 | selections.append(_hkml_cli.Selection( 135 | 'hkml open %s' % msgid, handle_fn=menu_hkml_open, data=msgid)) 136 | return selections 137 | 138 | def menu_exec_web(slist, answer, selection): 139 | cmd = selection.data 140 | subprocess.call(cmd.split()) 141 | 142 | def menu_selections_for_url(line): 143 | for separator in [',', '(', ')', '[', ']', '"']: 144 | line = line.replace(separator, ' ') 145 | selections = [] 146 | for word in line.split(): 147 | if not word.startswith('http://') and not word.startswith('https://'): 148 | continue 149 | if hkml_common.cmd_available('lynx'): 150 | cmd = 'lynx %s' % word 151 | selections.append(_hkml_cli.Selection( 152 | cmd, handle_fn=menu_exec_web, data=cmd)) 153 | if hkml_common.cmd_available('w3m'): 154 | cmd = 'w3m %s' % word 155 | selections.append(_hkml_cli.Selection( 156 | cmd, handle_fn=menu_exec_web, data=cmd)) 157 | return selections 158 | 159 | def menu_selections_for_files(line): 160 | for separator in [',', '(', ')', '[', ']', '"', ':']: 161 | line = line.replace(separator, ' ') 162 | 163 | found_files = {} 164 | selections = [] 165 | for word in line.split(): 166 | # file paths on diff starts with a/ and b/, e.g., 167 | # 168 | # --- a/tools/testing/selftests/damon/damon_nr_regions.py 169 | # +++ b/tools/testing/selftests/damon/damon_nr_regions.py 170 | if word.startswith('a/') or word.startswith('b/'): 171 | word = word[2:] 172 | if not word in found_files and os.path.isfile(word): 173 | found_files[word] = True 174 | selections.append(_hkml_cli.Selection( 175 | text='hkml open file %s' % word, handle_fn=menu_open_file, 176 | data=word)) 177 | selections.append(_hkml_cli.Selection( 178 | text='open %s with a text editor' % word, 179 | handle_fn=menu_open_file_editor, data=word)) 180 | return selections 181 | 182 | def is_showing_mail(slist): 183 | # slist.data should be TextViewData 184 | return slist.data.mail is not None 185 | 186 | def get_showing_mail(slist): 187 | # slist.data should be TextViewData 188 | mail = slist.data.mail 189 | if mail is None: 190 | return None, 'not showing mail?' 191 | return mail, None 192 | 193 | def reply_mail(c, slist): 194 | # maybe called from tui/cli menu 195 | if slist.parent_list is not None: 196 | slist = slist.parent_list 197 | mail, err = get_showing_mail(slist) 198 | if err is not None: 199 | slist.toast('parent is not a mail?') 200 | return 201 | 202 | hkml_view_mails.reply_mail(slist, mail) 203 | 204 | def menu_reply_mail(slist, answer, selection): 205 | mail, err = get_showing_mail(slist) 206 | # todo: handle err is not None case 207 | hkml_view.shell_mode_end(slist) 208 | hkml_view_mails.reply_mail(slist, mail) 209 | hkml_view.shell_mode_start(slist) 210 | 211 | def forward_mail(c, slist): 212 | # maybe called from tui/cli menu 213 | if slist.parent_list is not None: 214 | slist = slist.parent_list 215 | mail, err = get_showing_mail(slist) 216 | if err is not None: 217 | slist.toast('parent is not a mail?') 218 | return 219 | 220 | hkml_view_mails.forward_mail(slist, mail) 221 | 222 | def menu_forward_mail(slist, answer, selection): 223 | mail, err = get_showing_mail(slist) 224 | # todo: handle err is not None case 225 | hkml_view.shell_mode_end(slist) 226 | hkml_view_mails.forward_mail(slist, mail) 227 | hkml_view.shell_mode_start(slist) 228 | 229 | def write_draft_mail(c, slist): 230 | # maybe called from tui/cli menu 231 | if slist.parent_list is not None: 232 | slist = slist.parent_list 233 | mail, err = get_showing_mail(slist) 234 | if err is not None: 235 | slist.toast('parent is not a mail?') 236 | return 237 | hkml_view_mails.write_mail_draft(slist, mail) 238 | 239 | def menu_write_draft(slist, answer, selection): 240 | mail, err = get_showing_mail(slist) 241 | # todo: handle err is not None case 242 | hkml_view.shell_mode_end(slist) 243 | hkml_view_mails.write_mail_draft(slist, mail) 244 | hkml_view.shell_mode_start(slist) 245 | 246 | def menu_manage_tags(slist, answer, selection): 247 | mail, err = get_showing_mail(slist) 248 | # todo: handle err is not None case 249 | hkml_view_mails.manage_tags_of_mail(slist, mail) 250 | 251 | def menu_handle_patches(slist, answer, selection): 252 | mail, err = get_showing_mail(slist) 253 | # todo: handle err is not None case 254 | hkml_view_mails.handle_patches_of_mail(mail, slist.data.mails_list) 255 | 256 | def menu_jump(slist, answer, selection): 257 | selections = [ 258 | _hkml_cli.Selection('beginning of next different depth'), 259 | _hkml_cli.Selection('end of previous different depth')] 260 | 261 | answer, selection, err = _hkml_cli.ask_selection( 262 | desc='Select where to jump.', selections=selections, 263 | default_selection=selections[0]) 264 | current_depth = mail_depth(slist.lines[slist.focus_row]) 265 | 266 | if selection == selections[0]: 267 | for row_idx in range(slist.focus_row + 1, len(slist.lines)): 268 | line = slist.lines[row_idx] 269 | if mail_depth(line) != current_depth: 270 | slist.focus_row = row_idx 271 | return 272 | return 273 | elif selection == selections[1]: 274 | for row_idx in range(slist.focus_row - 1, -1, -1): 275 | line = slist.lines[row_idx] 276 | if mail_depth(line) != current_depth: 277 | slist.focus_row = row_idx 278 | return 279 | return 280 | 281 | def menu_selections_for_mail(): 282 | return [ 283 | _hkml_cli.Selection('reply', handle_fn=menu_reply_mail), 284 | _hkml_cli.Selection('forward', handle_fn=menu_forward_mail), 285 | _hkml_cli.Selection( 286 | 'continue draft writing', handle_fn=menu_write_draft), 287 | _hkml_cli.Selection('manage tags', 288 | handle_fn=menu_manage_tags), 289 | _hkml_cli.Selection( 290 | 'handle as patches', handle_fn=menu_handle_patches), 291 | _hkml_cli.Selection( 292 | 'jump cursor to ...', handle_fn=menu_jump), 293 | ] 294 | 295 | def menu_wrap_text(slist, answer, selection): 296 | if slist.wrapped_text(): 297 | slist.unwrap_text() 298 | else: 299 | slist.wrap_text() 300 | 301 | def menu_save_content_as(slist, answer, selection): 302 | hkml_view.save_as('\n'.join(slist.lines)) 303 | 304 | def menu_open_content_with(slist, answer, selection): 305 | err = hkml_view.open_content_with('\n'.join(slist.lines)) 306 | if err is not None: 307 | print(err) 308 | 309 | def menu_selections(slist): 310 | line = slist.lines[slist.focus_row] 311 | 312 | selections = menu_selections_for_commit(line) 313 | selections += menu_selections_for_msgid(line) 314 | selections += menu_selections_for_url(line) 315 | selections += menu_selections_for_files(line) 316 | 317 | if is_showing_mail(slist): 318 | selections += menu_selections_for_mail() 319 | 320 | if slist.wrapped_text(): 321 | menu_text = 'unwrap text' 322 | else: 323 | menu_text = 'wrap text' 324 | selections.append( 325 | _hkml_cli.Selection(menu_text, handle_fn=menu_wrap_text, 326 | data=slist)) 327 | selections.append( 328 | _hkml_cli.Selection( 329 | text='save screen content to ...', 330 | handle_fn=menu_save_content_as, data=slist)) 331 | selections.append( 332 | _hkml_cli.Selection( 333 | text='open screen content with ...', 334 | handle_fn=menu_open_content_with, data=slist)) 335 | 336 | return selections 337 | 338 | def show_text_viewer_menu(c, slist): 339 | hkml_view.shell_mode_start(slist) 340 | _hkml_cli.ask_selection( 341 | desc='selected line: %s' % slist.lines[slist.focus_row], 342 | prompt='Enter menu item number', 343 | handler_common_data=slist, 344 | selections=menu_selections(slist)) 345 | hkml_view.shell_mode_end(slist) 346 | 347 | def get_text_viewer_handlers(data): 348 | if data is not None and data.mail is not None: 349 | handlers = [ 350 | hkml_view.InputHandler(['r'], reply_mail, 'reply'), 351 | hkml_view.InputHandler(['f'], forward_mail, 'forward'), 352 | ] 353 | else: 354 | handlers = [] 355 | return handlers + [ 356 | hkml_view.InputHandler( 357 | ['m'], show_text_viewer_menu, 'open menu'), 358 | ] 359 | 360 | def text_color_callback(slist, line_idx): 361 | is_hunk = False 362 | for start, end in slist.hunk_lines: 363 | if start <= line_idx and line_idx < end: 364 | is_hunk = True 365 | break 366 | 367 | line = slist.lines[line_idx] 368 | if len(line) == 0: 369 | return hkml_view.normal_color 370 | if is_hunk: 371 | if line[0] == '+': 372 | return hkml_view.add_color 373 | elif line[0] == '-': 374 | return hkml_view.delete_color 375 | elif line[0] == '>': 376 | return hkml_view.original_color 377 | return hkml_view.normal_color 378 | 379 | def hunk_length(lines, orig_content, new_content): 380 | length = 0 381 | for line in lines: 382 | if line.startswith('-'): 383 | orig_content -= 1 384 | elif line.startswith('+'): 385 | new_content -= 1 386 | elif line.startswith(' ') or line == '': 387 | orig_content -= 1 388 | new_content -= 1 389 | else: 390 | return length 391 | length += 1 392 | 393 | if orig_content < 0 or new_content < 0 or \ 394 | (orig_content == new_content == 0): 395 | return length 396 | 397 | return length 398 | 399 | def hunk_lines(text_lines): 400 | indices = [] 401 | idx = 0 402 | while idx < len(text_lines): 403 | line = text_lines[idx] 404 | if not line.startswith('@@'): 405 | idx += 1 406 | continue 407 | fields = line.split() 408 | # format: "@@ -l,s +l,s @@ optional section heading" 409 | if len(fields) < 4: 410 | idx += 1 411 | continue 412 | numbers = fields[1].split(',') + fields[2].split(',') 413 | if len(numbers) != 4: 414 | idx += 1 415 | continue 416 | try: 417 | numbers = [int(x) for x in numbers] 418 | except: 419 | idx += 1 420 | continue 421 | orig_content, new_content = numbers[1], numbers[3] 422 | hunk_len = hunk_length(text_lines[idx + 1:], orig_content, new_content) 423 | indices.append([idx + 1, idx + 1 + hunk_len]) 424 | idx += hunk_len + 1 425 | return indices 426 | 427 | def mail_depth(line): 428 | depth = 0 429 | for c in line: 430 | if not c in ['>', ' ']: 431 | break 432 | if c == '>': 433 | depth += 1 434 | return depth 435 | 436 | def is_context_line(line): 437 | # e.g., "On Fri, 2 May 2025 08:49:49 -0700 SeongJae Park wrote:" 438 | if line.endswith('wrote:'): 439 | return True 440 | # e.g., "* SeongJae Park [250509 01:47]:" 441 | # todo: maybe need more test...? 442 | if line.endswith(']:'): 443 | return True 444 | return False 445 | 446 | def parse_mail_contexts(text_lines): 447 | ''' 448 | Parse which depth origin lines are sent by who, when. 449 | ''' 450 | contexts = {} # key: depth (int), value: context line (strting) 451 | for idx, line in enumerate(text_lines): 452 | if not is_context_line(line): 453 | continue 454 | depth = mail_depth(line) + 1 455 | if depth in contexts: 456 | continue 457 | contexts[depth] = line 458 | return contexts 459 | 460 | def set_mail_contexts(slist): 461 | contexts = parse_mail_contexts(slist.lines) 462 | mail = slist.data.mail 463 | contexts[0] = '%s, %s' % ( 464 | mail.get_field('from'), mail.get_field('local-date')) 465 | depth = 1 466 | while mail.parent_mail is not None: 467 | mail = mail.parent_mail 468 | contexts[depth] = '%s, %s' % ( 469 | mail.get_field('from'), mail.get_field('local-date')) 470 | depth += 1 471 | slist.data.mail_contexts = contexts 472 | 473 | def mail_draw_callback(slist): 474 | focused_line = slist.lines[slist.focus_row] 475 | depth = mail_depth(focused_line) 476 | 477 | text_view_data = slist.data 478 | if not depth in text_view_data.mail_contexts: 479 | context = 'unknown' 480 | else: 481 | context = text_view_data.mail_contexts[depth] 482 | slist.bottom_lines = ['# context: %s' % context] 483 | 484 | def show_text_viewer(screen, text_lines, data=None, cursor_position=None): 485 | slist = hkml_view.ScrollableList( 486 | screen, text_lines, get_text_viewer_handlers(data)) 487 | if data is None: 488 | data = TextViewData(mail=None, mails_list=[]) 489 | slist.data = data 490 | slist.hunk_lines = hunk_lines(text_lines) 491 | slist.color_callback = text_color_callback 492 | if cursor_position is not None: 493 | slist.focus_row, slist.focus_col = cursor_position 494 | 495 | better_wrap, longest_columns = slist.better_wrap_text() 496 | if better_wrap is True and not slist.wrapped_text(): 497 | hkml_view.shell_mode_start(slist) 498 | answer = input('\n'.join([ 499 | 'There are lines that may better to be wrapped (%d columns).' % 500 | longest_columns, 501 | 'May I wrap those?', 502 | 'You can [un]wrap later from the menu (\'m\' key).', 503 | 'Answer: [y/N] '])) 504 | if answer.lower() == 'y': 505 | slist.wrap_text() 506 | print('Ok, wrapped the lines') 507 | else: 508 | print('Ok, kept those as is') 509 | hkml_view.shell_mode_end(slist) 510 | 511 | if is_showing_mail(slist): 512 | set_mail_contexts(slist) 513 | slist.draw_callback = mail_draw_callback 514 | 515 | slist.draw() 516 | return slist 517 | -------------------------------------------------------------------------------- /src/hkml_write.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # SPDX-License-Identifier: GPL-2.0 3 | 4 | import argparse 5 | import os 6 | import subprocess 7 | import sys 8 | import tempfile 9 | 10 | import _hkml 11 | import _hkml_list_cache 12 | import hkml_common 13 | import hkml_list 14 | import hkml_open 15 | import hkml_send 16 | import hkml_signature 17 | 18 | SIGNATURE_WARNING = ['', 19 | '/*', 20 | ' * !THE FOLLOWING COMMENT WAS AUTOMATICALLY ADDED BY HKML', 21 | ' * below are signatures added by "hkml".', 22 | ' * edit signatures below, or use "hkml signature".', 23 | ' * If you leave this block untouched, then hkml will', 24 | ' * automatically ignore the block before sending.', 25 | ' */',] 26 | SIGNATURE_WARNING_LEN = len(SIGNATURE_WARNING) - 1 # first line is blank 27 | 28 | def git_sendemail_valid_recipients(recipients): 29 | """each line should be less than 998 char""" 30 | if not recipients: 31 | return '' 32 | # TODO: Could name contain ','? 33 | if len(recipients) < 998: 34 | return recipients 35 | 36 | addresses = recipients.split(',') 37 | lines = [] 38 | line = '' 39 | for addr in addresses[1:]: 40 | if len(line) + len(addr) + len(', ') > 998: 41 | lines.append(line) 42 | line = '\t' 43 | line += '%s,' % addr 44 | lines.append(line) 45 | lines[-1] = lines[-1][:-1] 46 | return '\n'.join(lines) 47 | 48 | def get_git_config(config_name): 49 | try: 50 | result = subprocess.check_output( 51 | ['git', 'config', config_name]).decode().strip() 52 | return result, None 53 | except Exception as e: 54 | return None, '"git config %s" failed (%s)' % (config_name, e) 55 | 56 | def format_mbox(subject, in_reply_to, to, cc, body, from_, draft_mail, 57 | attach_files=None): 58 | if draft_mail is not None: 59 | return hkml_open.mail_display_str(draft_mail, head_columns=None, 60 | valid_mbox=False, 61 | for_draft_continue=True) 62 | lines = [] 63 | if not subject: 64 | subject = '/* write subject here */' 65 | if not to: 66 | to = ['/* write recipients here and REMOVE this comment */'] 67 | if not cc: 68 | cc = ['/* wrtite cc recipients here and REMOVE this comment */'] 69 | if from_ is None: 70 | from_, err = get_git_config('sendemail.from') 71 | if err is not None: 72 | name, err = get_git_config('user.name') 73 | email, err = get_git_config('user.email') 74 | if email is None: 75 | from_ = '/* fill up please */' 76 | else: 77 | from_ = '%s <%s>' % (name, email) 78 | 79 | lines.append('Subject: %s' % subject) 80 | lines.append('From: %s' % from_) 81 | if in_reply_to: 82 | lines.append('In-Reply-To: %s' % in_reply_to) 83 | for addr in to: 84 | addr = git_sendemail_valid_recipients(addr) 85 | lines.append('To: %s' % addr) 86 | for addr in cc: 87 | addr = git_sendemail_valid_recipients(addr) 88 | lines.append('Cc: %s' % addr) 89 | lines.append('') 90 | if not body: 91 | body = '/* write your message here (keep the above blank line) */' 92 | lines.append(body) 93 | 94 | signatures = hkml_signature.read_signatures_file() 95 | if len(signatures) > 0: 96 | lines += SIGNATURE_WARNING 97 | for signature in signatures: 98 | lines.append('') 99 | lines.append(signature) 100 | 101 | if attach_files is not None: 102 | for idx, attach_file in enumerate(attach_files): 103 | marker_line = '==== Attachment %d (%s) ====' % (idx, attach_file) 104 | with open(attach_file, 'r') as f: 105 | lines.append('\n%s\n%s' % (marker_line, f.read())) 106 | return '\n'.join(lines) 107 | 108 | def ask_editor(default_editor): 109 | if default_editor is not None: 110 | choices = [default_editor] 111 | else: 112 | choices = [] 113 | for editor in ['vim', 'nvim', 'emacs', 'nano']: 114 | if not editor in choices: 115 | if hkml_common.cmd_available(editor): 116 | choices.append(editor) 117 | if choices == []: 118 | print('please install vim, nvim, emacs or nano and retry') 119 | exit(1) 120 | print('I will open a text editor to let you edit the mail.') 121 | print('What text editor shall I use?') 122 | print() 123 | for idx, cmd in enumerate(choices): 124 | if idx == 0: 125 | print('%d: %s # DEFAULT' % (idx + 1, cmd)) 126 | else: 127 | print('%d: %s' % (idx + 1, cmd)) 128 | print() 129 | answer = input('Enter the number, please (%s by default): ' % choices[0]) 130 | try: 131 | cmd = choices[int(answer) - 1] 132 | except: 133 | cmd = choices[0] 134 | return cmd 135 | 136 | def open_editor(file_path, target_desc='mail'): 137 | editor = os.environ.get('EDITOR') 138 | editor = ask_editor(editor) 139 | 140 | print('I will open a text editor for the %s.' % target_desc) 141 | if subprocess.call([editor, file_path]) != 0: 142 | return 'The editor for %s exit with an error.' % target_desc 143 | return None 144 | 145 | def write_send_mail(draft_mail, subject, in_reply_to, to, cc, body, attach, 146 | format_only): 147 | mbox = format_mbox(subject, in_reply_to, to, cc, body, None, draft_mail, 148 | attach) 149 | 150 | if format_only: 151 | print(mbox) 152 | return 153 | 154 | fd, tmp_path = tempfile.mkstemp(prefix='hkml_mail_') 155 | with open(tmp_path, 'w') as f: 156 | f.write(mbox) 157 | err = open_editor(tmp_path) 158 | if err is not None: 159 | print(err) 160 | exit(1) 161 | 162 | orig_draft_subject = None 163 | if draft_mail: 164 | orig_draft_subject = draft_mail.subject 165 | hkml_send.send_mail(tmp_path, get_confirm=True, erase_mbox=True, 166 | orig_draft_subject=orig_draft_subject) 167 | 168 | def main(args): 169 | draft_mail = None 170 | if args.draft is not None: 171 | draft_mail = _hkml_list_cache.get_mail(args.draft) 172 | if draft_mail is None: 173 | print('failed getting draft mail of the index.') 174 | exit(1) 175 | 176 | write_send_mail(draft_mail, args.subject, args.in_reply_to, args.to, 177 | args.cc, args.body, args.attach, args.format_only) 178 | 179 | def add_common_arguments(parser): 180 | parser.add_argument('--attach', metavar='', nargs='+', 181 | help='file to paste at the end of the body') 182 | parser.add_argument('--format_only', action='store_true', 183 | help='print formatted mail template only') 184 | 185 | def set_argparser(parser=None): 186 | parser.description = 'write a mail' 187 | parser.add_argument('--subject', metavar='', type=str, 188 | help='Subject of the mail.') 189 | parser.add_argument('--in-reply-to', metavar='', 190 | help='Add in-reply-to field in the mail header') 191 | parser.add_argument('--to', metavar='', nargs='+', 192 | help='recipients of the mail') 193 | parser.add_argument('--cc', metavar='', nargs='+', 194 | help='cc recipients of the mail') 195 | parser.add_argument('--body', metavar='', 196 | help='body message of the mail') 197 | parser.add_argument('--draft', metavar='', type=int, 198 | help='resume writing from the given draft') 199 | add_common_arguments(parser) 200 | -------------------------------------------------------------------------------- /tests/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # SPDX-License-Identifier: GPL-2.0 3 | 4 | bindir=$(dirname "$0") 5 | 6 | for test_file in "$bindir"/test_*.py 7 | do 8 | if python3 "$test_file" &> /dev/null 9 | then 10 | echo "PASS unit $(basename $test_file)" 11 | else 12 | echo "FAIL unit $(basename $test_file)" 13 | exit 1 14 | fi 15 | done 16 | -------------------------------------------------------------------------------- /tests/test_hkml_common.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # SPDX-License-Identifier: GPL-2.0 3 | 4 | import datetime 5 | import unittest 6 | import os 7 | import sys 8 | 9 | bindir = os.path.dirname(os.path.realpath(__file__)) 10 | src_dir = os.path.join(bindir, '..', 'src') 11 | sys.path.append(src_dir) 12 | 13 | import hkml_common 14 | 15 | class TestHkmlCommon(unittest.TestCase): 16 | def test_parse_date_diff_input(self): 17 | now = datetime.datetime(2025, 5, 31, 10, 18) 18 | self.assertEqual( 19 | hkml_common.parse_date_diff('-2 days', now), 20 | datetime.datetime(2025, 5, 29, 10, 18)) 21 | self.assertEqual( 22 | hkml_common.parse_date_diff('+1 days', now), 23 | datetime.datetime(2025, 6, 1, 10, 18)) 24 | 25 | if __name__ == '__main__': 26 | unittest.main() 27 | -------------------------------------------------------------------------------- /tests/test_hkml_patch_format.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # SPDX-License-Identifier: GPL-2.0 3 | 4 | import unittest 5 | import os 6 | import sys 7 | 8 | bindir = os.path.dirname(os.path.realpath(__file__)) 9 | src_dir = os.path.join(bindir, '..', 'src') 10 | sys.path.append(src_dir) 11 | 12 | import hkml_view 13 | 14 | class TestHkmlViewText(unittest.TestCase): 15 | def test_tabs_to_spaces(self): 16 | self.assertEqual( 17 | hkml_view.tabs_to_spaces('01234567\t123\t1', 8), 18 | '01234567 123 1') 19 | 20 | def test_wrap_text(self): 21 | self.assertEqual( 22 | hkml_view.wrap_text( 23 | ['0123 567 9abcd'], 9), 24 | ['0123 567', 25 | '9abcd']) 26 | self.assertEqual( 27 | hkml_view.wrap_text( 28 | ['0123456789abcd'], 5), 29 | ['0123456789abcd']) 30 | self.assertEqual( 31 | hkml_view.wrap_text([''], 5), ['']) 32 | self.assertEqual( 33 | hkml_view.wrap_text([' ab'], 5), [' ab']) 34 | self.assertEqual( 35 | hkml_view.wrap_text(['> abc def'], 5), ['> abc', '> def']) 36 | 37 | if __name__ == '__main__': 38 | unittest.main() 39 | -------------------------------------------------------------------------------- /tests/test_hkml_view.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # SPDX-License-Identifier: GPL-2.0 3 | 4 | import unittest 5 | import os 6 | import sys 7 | 8 | bindir = os.path.dirname(os.path.realpath(__file__)) 9 | src_dir = os.path.join(bindir, '..', 'src') 10 | sys.path.append(src_dir) 11 | 12 | import hkml_view 13 | 14 | class TestHkmlViewText(unittest.TestCase): 15 | def test_tabs_to_spaces(self): 16 | self.assertEqual( 17 | hkml_view.tabs_to_spaces('01234567\t123\t1', 8), 18 | '01234567 123 1') 19 | 20 | def test_wrap_text(self): 21 | self.assertEqual( 22 | hkml_view.wrap_text( 23 | ['0123 567 9abcd'], 9), 24 | ['0123 567', 25 | '9abcd']) 26 | self.assertEqual( 27 | hkml_view.wrap_text( 28 | ['0123456789abcd'], 5), 29 | ['0123456789abcd']) 30 | self.assertEqual( 31 | hkml_view.wrap_text([''], 5), ['']) 32 | self.assertEqual( 33 | hkml_view.wrap_text([' ab'], 5), [' ab']) 34 | self.assertEqual( 35 | hkml_view.wrap_text(['> abc def'], 5), ['> abc', '> def']) 36 | 37 | if __name__ == '__main__': 38 | unittest.main() 39 | -------------------------------------------------------------------------------- /tests/test_hkml_view_mails.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # SPDX-License-Identifier: GPL-2.0 3 | 4 | import unittest 5 | import os 6 | import sys 7 | 8 | bindir = os.path.dirname(os.path.realpath(__file__)) 9 | src_dir = os.path.join(bindir, '..', 'src') 10 | sys.path.append(src_dir) 11 | 12 | import hkml_view_mails 13 | 14 | class TestHkmlViewText(unittest.TestCase): 15 | def test_get_files_for_reviewer(self): 16 | maintainers_file_content = ''' 17 | DATA ACCESS MONITOR 18 | M: SeongJae Park 19 | L: damon@lists.linux.dev 20 | L: linux-mm@kvack.org 21 | S: Maintained 22 | W: https://damonitor.github.io 23 | P: Documentation/mm/damon/maintainer-profile.rst 24 | T: git git://git.kernel.org/pub/scm/linux/kernel/git/akpm/mm 25 | T: quilt git://git.kernel.org/pub/scm/linux/kernel/git/akpm/25-new 26 | T: git git://git.kernel.org/pub/scm/linux/kernel/git/sj/linux.git damon/next 27 | F: Documentation/ABI/testing/sysfs-kernel-mm-damon 28 | F: Documentation/admin-guide/mm/damon/ 29 | F: Documentation/mm/damon/ 30 | F: include/linux/damon.h 31 | F: include/trace/events/damon.h 32 | F: mm/damon/ 33 | F: samples/damon/ 34 | F: tools/testing/selftests/damon/ 35 | 36 | DAVICOM FAST ETHERNET (DMFE) NETWORK DRIVER 37 | L: netdev@vger.kernel.org 38 | S: Orphan 39 | F: Documentation/networking/device_drivers/ethernet/dec/dmfe.rst 40 | F: drivers/net/ethernet/dec/tulip/dmfe.c 41 | ''' 42 | self.assertEqual( 43 | hkml_view_mails.get_files_for_reviewer( 44 | 'SeongJae Park ', maintainers_file_content), 45 | ['Documentation/ABI/testing/sysfs-kernel-mm-damon', 46 | 'Documentation/admin-guide/mm/damon/', 47 | 'Documentation/mm/damon/', 48 | 'include/linux/damon.h', 49 | 'include/trace/events/damon.h', 50 | 'mm/damon/', 51 | 'samples/damon/', 52 | 'tools/testing/selftests/damon/']) 53 | 54 | if __name__ == '__main__': 55 | unittest.main() 56 | -------------------------------------------------------------------------------- /tests/test_hkml_view_text.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # SPDX-License-Identifier: GPL-2.0 3 | 4 | import unittest 5 | import os 6 | import sys 7 | 8 | bindir = os.path.dirname(os.path.realpath(__file__)) 9 | src_dir = os.path.join(bindir, '..', 'src') 10 | sys.path.append(src_dir) 11 | 12 | import hkml_view_text 13 | 14 | class TestHkmlViewText(unittest.TestCase): 15 | def test_hunk_lines_proper(self): 16 | # from 20241211203951.764733-2-joshua.hahnjy@gmail.com 17 | text = ''' 18 | @@ -4540,8 +4552,7 @@ int mem_cgroup_hugetlb_try_charge(struct mem_cgroup *memcg, gfp_t gfp, 19 | * but do not attempt to commit charge later (or cancel on error) either. 20 | */ 21 | if (mem_cgroup_disabled() || !memcg || 22 | - !cgroup_subsys_on_dfl(memory_cgrp_subsys) || 23 | - !(cgrp_dfl_root.flags & CGRP_ROOT_MEMORY_HUGETLB_ACCOUNTING)) 24 | + !cgroup_subsys_on_dfl(memory_cgrp_subsys) || !memcg_accounts_hugetlb()) 25 | return -EOPNOTSUPP; 26 | 27 | if (try_charge(memcg, gfp, nr_pages)) 28 | -- 29 | 2.43.5 30 | ''' 31 | text_lines = text.split('\n') 32 | hunk_indices = hkml_view_text.hunk_lines(text.split('\n')) 33 | self.assertEqual(hunk_indices, [[2, 11]]) 34 | 35 | def test_hunk_lines_malformed_1(self): 36 | # from 20241211203951.764733-2-joshua.hahnjy@gmail.com 37 | # This test case presents a malformed git hunk where there are more 38 | # lines in the diff than the hunk reports. This is very unlikely, as 39 | # developers are unlikely to add extra lines to a diff after having 40 | # formatted the diff previously. 41 | 42 | text = ''' 43 | @@ -4540,8 +4552,7 @@ int mem_cgroup_hugetlb_try_charge(struct mem_cgroup *memcg, gfp_t gfp, 44 | * but do not attempt to commit charge later (or cancel on error) either. 45 | */ 46 | if (mem_cgroup_disabled() || !memcg || 47 | - !cgroup_subsys_on_dfl(memory_cgrp_subsys) || 48 | - !(cgrp_dfl_root.flags & CGRP_ROOT_MEMORY_HUGETLB_ACCOUNTING)) 49 | + !cgroup_subsys_on_dfl(memory_cgrp_subsys) || !memcg_accounts_hugetlb()) 50 | return -EOPNOTSUPP; 51 | 52 | if (try_charge(memcg, gfp, nr_pages)) 53 | + // This line should not be colored by hkml. 54 | + // This line should not be colored by hkml. 55 | -- 56 | 2.43.5 57 | ''' 58 | text_lines = text.split('\n') 59 | hunk_indices = hkml_view_text.hunk_lines(text.split('\n')) 60 | self.assertEqual(hunk_indices, [[2, 11]]) 61 | 62 | def test_hunk_lines_malformed_2(self): 63 | # from 20241211203951.764733-2-joshua.hahnjy@gmail.com 64 | # This test case presents a malformed git hunk where there are less 65 | # lines in the diff than the hunk reports. This is more common than 66 | # the first malformed test case, where developers might want to 67 | # show only a snippet of a diff, and elect to drop some lines without 68 | # properly adjusting the header to reflect this. 69 | 70 | text = ''' 71 | @@ -4540,8 +4552,7 @@ int mem_cgroup_hugetlb_try_charge(struct mem_cgroup *memcg, gfp_t gfp, 72 | * but do not attempt to commit charge later (or cancel on error) either. 73 | */ 74 | if (mem_cgroup_disabled() || !memcg || 75 | - !cgroup_subsys_on_dfl(memory_cgrp_subsys) || 76 | - !(cgrp_dfl_root.flags & CGRP_ROOT_MEMORY_HUGETLB_ACCOUNTING)) 77 | + !cgroup_subsys_on_dfl(memory_cgrp_subsys) || !memcg_accounts_hugetlb()) 78 | return -EOPNOTSUPP; 79 | ''' 80 | text_lines = text.split('\n') 81 | hunk_indices = hkml_view_text.hunk_lines(text.split('\n')) 82 | self.assertEqual(hunk_indices, [[2, 10]]) 83 | 84 | if __name__ == '__main__': 85 | unittest.main() 86 | --------------------------------------------------------------------------------