├── CHANGELOG ├── LICENSE ├── README.md ├── TODO.md ├── completions ├── bash └── zsh ├── docs └── images │ ├── blue_fox_and_yellow_dog.png │ ├── blue_fox_jumping.png │ ├── blue_fox_really_jumping.png │ └── color_and_text_feature_sample.png ├── iris.rb ├── test_watch └── tests ├── iris.messages.json ├── iris.messages.json.read └── iris_test.rb /CHANGELOG: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.1.3 4 | * Fixed bug (https://github.com/Calamitous/iris/issues/62) causing errors when running Iris for the first time. [Thank you, lee2sman!](https://github.com/lee2sman) 5 | * Fixed bug causing message to be deleted when exiting a message edit without making changes. [Thank you, zack466!](https://github.com/zack466) 6 | * Greatly improved the speed of reloading unread messages. [Thank you, teepark!](https://github.com/teepark) 7 | 8 | ## 1.1.2 9 | * Fixed bug where "hostname" portion of author is missing or garbled. 10 | * Added CLI completion scripts for bash and zsh. [Thanks, jlxip!](https://github.com/jlxip) 11 | * Minor tweaks and additions to the documentation. 12 | 13 | ## 1.1.1 14 | * Make discarded message response more prominent 15 | * Try nano if /usr/bin/vim is not set 16 | * Expand the technical documentation 17 | * Flesh out some tests 18 | * Clean up TODO file 19 | 20 | ## 1.1.0 21 | * Iris now composes messages with $EDITOR instead of using an internal editor 22 | * Remove (broken) feature that automatically selects a reply when not provided with a topic ID 23 | * Expand and clean up tests 24 | * Remove dead code 25 | * Move CHANGELOG out of the TODO file and into its own file 26 | 27 | ## 1.0.13 28 | * Fix reply ordering bug 29 | 30 | ## 1.0.12 31 | * Add Asara's "mark all read" functionality 32 | * Fix(?) bug with handling broken UTF-8 characters 33 | * Add feature to read the next unread topic ("next") 34 | * Exclude user''s own messages from "unread" count 35 | 36 | ## 1.0.11 37 | * Speed up the topic listing significantly 38 | * Add 'unread' (short form 'u') to only list topics with unread messages 39 | * Add 'mark_unread' (short form 'm') to mark topics as read without displaying them 40 | * Tweaks to help text 41 | * Default main listing to unread topics instead of listing all topics 42 | * Updates to the way screen dimensions are calculated 43 | * Preliminaary work to support pagination 44 | * Change permissions message from error to warning so it only shows in debug mode 45 | 46 | ## 1.0.10 47 | * ~Fix bug causing system to crash when a user removes read permissions from their directory/iris.messages file~ 48 | 49 | ## 1.0.9 50 | * ~Stop checking domain on user validation~ 51 | * ~Fix bug causing color overflow when color tags break.~ Special thanks go out to Japanoise (https://github.com/japanoise) for reporting this bug! 52 | 53 | ## 1.0.8 54 | * ~Fix bug when UID has been deleted from /etc/passwd, but user''s message file still exists~ 55 | * ~Add debug mode to Iris~ 56 | * ~Refactor Iris to make it easier to load test files to run with~ 57 | 58 | ## 1.0.7 59 | * ~Fix "unread count" bug~ 60 | 61 | ## 1.0.6 62 | * ~Message deletion~ 63 | * ~Message editing~ 64 | * ~Gracefully handle bad message files~ 65 | * ~Fix topic selection when replying without topic ID~ 66 | * ~Automatically display topics when opening~ 67 | * ~Move display headers into frame line~ 68 | * ~Fix truncated message headers being one character too long in topic list~ 69 | * ~Status flag fix~ 70 | * ~Keep order of message on edit~ 71 | * ~Mark unread topics/topics with unread replies in topics list~ 72 | * ~Add column headers for topics~ 73 | * ~Document new features~ 74 | * ~Keep replies on edited topics~ 75 | * ~Add unread topic to overall unread count~ 76 | 77 | ## 1.0.5 78 | * ~Make all output WIDTH-aware~ 79 | * ~Add color~ 80 | * ~Add full message corpus dump for backup/debugging~ 81 | * ~Add startup enviro health check~ 82 | * ~Change listing to show last updated timestamp, instead of thread creation timestamp~ 83 | * ~Add command-line options to README~ 84 | * ~Add documentation for color feature~ 85 | * ~Add command-line options to README~ 86 | * ~Made message file slightly more human-readable~ 87 | -------------------------------------------------------------------------------- /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 | # Iris 2 | ## Serverless text-based forum for tilde-likes 3 | 4 | Iris is a tiny bit of shared message and file convention that pretends to be forum software. 5 | 6 | It is a fully usable message system, designed for use between different users on a single server. 7 | 8 | Iris is strictly text-based, requiring no GUI, database, or web servers. 9 | 10 | * [Installation](#installation) 11 | * [Usage](#usage) 12 | * [Commands](#commands) 13 | * [Command-line Options](#command-line-options) 14 | * [Text Features/Markup](#text-featuresmarkup) 15 | * [Philosophy](#philosophy) 16 | * [Tests](#tests) 17 | * [Cutting A Release](#cutting-a-release) 18 | * [Technical Bits](#technical-bits) 19 | * [License](#license) 20 | 21 | ## Installation 22 | 23 | At its core, Iris is simply a single, executable Ruby script. It has been tested and is known to work with Ruby 2.3.5 and above. No extra gems or libraries are required. 24 | 25 | Copy or symlink `iris.rb` somewhere the whole server can use it; `/usr/local/bin` is a good candidate: 26 | 27 | ```bash 28 | chmod 755 ./iris.rb 29 | mv ./iris.rb /usr/local/bin/iris 30 | ``` 31 | 32 | ## Usage 33 | 34 | Iris has a readline interface that can be used to navigate the message corpus. 35 | 36 | ### Readline Interface Example 37 | 38 | ```bash 39 | %> iris 40 | Welcome to Iris v. 1.1.3. Type "help" for a list of commands.; Ctrl-D or 'quit' to leave. 41 | 42 | | ID | U | TIMESTAMP | AUTHOR | TITLE 43 | | 1 | | 2018-01-24T05:49:53Z | jimmy_foo@ctrl-c.club | Welcome! 44 | | 2 | 1 | 2018-01-24T16:22:05Z | jerry_berry@ctrl-c.club | Suggestions for a... 45 | 46 | jimmy_foo@ctrl-c.club> 47 | ``` 48 | 49 | ## Commands 50 | 51 | * [[t]opics](#topics) 52 | * [[u]nread](#unread) 53 | * [Display topic](#display-topic) 54 | * [[n]ext](#next) 55 | * [[c]ompose](#compose) 56 | * [[r]eply](#reply) 57 | * [[e]dit](#edit) 58 | * [[d]elete and un[d]elete](#delete) 59 | * [[m]ark_read](#mark_read) 60 | * [mark_all_read](#mark_all_read) 61 | * [[f]reshen](#freshen) 62 | * [reset OR clear](#reset-or-clear) 63 | * [[i]nfo](#info) 64 | * [[h]elp](#help) 65 | 66 | --- 67 | 68 | ### [t]opics 69 | `topics, t - List all topics` 70 | 71 | This outputs a list of top-level topics that have been composed by everyone on the server. 72 | 73 | ``` 74 | jimmy_foo@ctrl-c.club> topics 75 | 76 | | ID | U | TIMESTAMP | AUTHOR | TITLE 77 | | 1 | | 2018-01-24T05:49:53Z | jimmy_foo@ctrl-c.club | Welcome! 78 | | 2 | 1 | 2018-01-24T16:22:05Z | jerry_berry@ctrl-c.club | Suggestions for a... 79 | 80 | ``` 81 | 82 | 1. The first column is the topic index. This is the reference number to use when displaying or replying to a topic. 83 | 1. The second column is unread count. This shows how many messages under this topic you haven't seen. 84 | 1. The third column is the timestamp. This is the server-local time when the topic was composed or last replied to. 85 | 1. The fourth column is the author. This is the user who composed the topic. 86 | 1. The fifth column is the title. This is the truncated first line of the topic. 87 | 88 | --- 89 | 90 | ### [u]nread 91 | `unread, u - List all unread topics` 92 | 93 | This outputs a list of top-level topics that have not been read, or have unread messages 94 | 95 | ``` 96 | jimmy_foo@ctrl-c.club> unread 97 | 98 | | ID | U | TIMESTAMP | AUTHOR | TITLE 99 | | 2 | 1 | 2018-01-24T16:22:05Z | jerry_berry@ctrl-c.club | Suggestions for a... 100 | 101 | ``` 102 | 103 | The format of the unread topics list is identical to the format of the [topics](#topics) list 104 | 105 | --- 106 | 107 | ### Display topic 108 | `(topic id #) - Read specified topic` 109 | 110 | Type in the index of the topic you wish to read. This will display the topic and all its replies. 111 | ``` 112 | jimmy_foo@ctrl-c.club> topics 113 | 114 | | ID | U | TIMESTAMP | AUTHOR | TITLE 115 | | 1 | | 2018-01-24T05:49:53Z | jimmy_foo@ctrl-c.club | Welcome! 116 | | 2 | 1 | 2018-01-24T16:22:05Z | jerry_berry@ctrl-c.club | Suggestions for a... 117 | 118 | jimmy_foo@ctrl-c.club> 1 119 | *** [1] On 2018-01-24T05:49:53Z, jimmy_foo@ctrl-c.club posted...----------------- 120 | Welcome! 121 | It's good to see everyone here! 122 | --------------------------------------------------------------------------------- 123 | 124 | | === [M2] On 2018-01-30T22:50:38Z, jerry_berry@ctrl-c.club replied...--------- 125 | | Thanks! 126 | | ----------------------------------------------------------------------------- 127 | 128 | ``` 129 | 130 | --- 131 | 132 | ### [n]ext 133 | `next, n - Read the next unread topic` 134 | 135 | This command displays the first topic which is unread or has unread replies. 136 | ``` 137 | jimmy_foo@ctrl-c.club> topics 138 | 139 | | ID | U | TIMESTAMP | AUTHOR | TITLE 140 | | 1 | | 2018-01-24T05:49:53Z | jimmy_foo@ctrl-c.club | Welcome! 141 | | 2 | 1 | 2018-01-24T16:22:05Z | jerry_berry@ctrl-c.club | Suggestions for a... 142 | 143 | jimmy_foo@ctrl-c.club> next 144 | *** [2] On 2018-01-24T16:22:05Z, jerry_berry@ctrl-c.club posted...--------------- 145 | Suggestions for a tilde home? 146 | 147 | I'm trying to decide on a new place in the tildeverse to call home. Any ideas? 148 | --------------------------------------------------------------------------------- 149 | 150 | | === [M4] On 2018-01-30T22:50:38Z, jimmy_foo@ctrl-c.club replied...----------- 151 | | Have you considered https://ctrl-c.club? 152 | | ----------------------------------------------------------------------------- 153 | 154 | ``` 155 | 156 | --- 157 | 158 | ### [c]ompose 159 | `compose, c - Add a new topic` 160 | 161 | This allows you to add a new top-level topic to the board. The first line of your new topic will be used as the topic title. 162 | 163 | Iris will allow you to type in your message in the editor you have defined in your shell with the `$EDITOR` environment variable. 164 | 165 | If you post an empty message, the system will discard it. 166 | 167 | ``` 168 | jimmy_foo@ctrl-c.club~> compose 169 | Writing a new topic. 170 | 171 | new~> How do I spoo the fleem? 172 | new~> It's not in the docs and my boss is asking. Any help is appreciated! 173 | new~> . 174 | Topic saved! 175 | 176 | 177 | jimmy_foo@ctrl-c.club~> topics 178 | 179 | | ID | U | TIMESTAMP | AUTHOR | TITLE 180 | | 1 | | 2018-01-24T05:49:53Z | jimmy_foo@ctrl-c.club | Welcome! 181 | | 2 | | 2018-01-24T16:22:05Z | jerry_berry@ctrl-c.club | Suggestions for a... 182 | | 3 | 1 | 2018-01-23T00:22:44Z | jimmy_foo@ctrl-c.club | How do I spoo the... 183 | ``` 184 | 185 | --- 186 | 187 | ### [r]eply 188 | `reply #, r # - Reply to a specific topic` 189 | 190 | Replies are responses to a specific topic -- they only appear when displaying the topic. 191 | 192 | Iris will allow you to type in your message in the editor you have defined in your shell with the `$EDITOR` environment variable. 193 | 194 | If you post an empty message, the system will discard it. 195 | 196 | ``` 197 | jennie_minnie@ctrl-c.club~> reply 3 198 | Writing a reply to topic 'How do I spoo the fleem?'. 199 | 200 | reply~> Simple, you just boondoggle the flibbertigibbet. That should be in the manual. 201 | reply~> . 202 | Reply saved! 203 | 204 | jennie_minnie@ctrl-c.club~> 3 205 | 206 | *** [3] On 2018-01-23T00:22:44Z, jimmy_foo@ctrl-c.club posted...----------------- 207 | How do I spoo the fleem? 208 | It's not in the docs and my boss is asking. Any help is appreciated! 209 | --------------------------------------------------------------------------------- 210 | 211 | | === [M5] On 2018-01-31T05:59:27Z, jennie_minnie@ctrl-c.club replied...------- 212 | | Simple, you just boondoggle the flibbertigibbet. That should be in the 213 | | manual. 214 | | ----------------------------------------------------------------------------- 215 | ``` 216 | 217 | --- 218 | 219 | ### [e]dit 220 | `edit #, e # - Edit a topic or message` 221 | 222 | Editing a message or topic will replace the message you select with an all-new message. 223 | 224 | To select the message you wish to edit, use either the topic index or the message number. 225 | 226 | The message number will always start with the capital letter "M", message "M5" for example. 227 | 228 | A topic ID will always be strictly numeric, "3" in the following example. 229 | 230 | The message or topic ID can be found in square brackets in the informational text above each message. 231 | 232 | Iris will allow you to type in your message in the editor you have defined in your shell with the `$EDITOR` environment variable. 233 | 234 | If you post an empty message, the system will discard it and the edit will be ignored. 235 | 236 | After an edit, a status flag will appear on the message, letting others know the content of the message has been changed. 237 | 238 | ``` 239 | jennie_minnie@ctrl-c.club~> 3 240 | 241 | *** [3] On 2018-01-23T00:22:44Z, jimmy_foo@ctrl-c.club posted...----------------- 242 | How do I spoo the fleem? 243 | It's not in the docs and my boss is asking. Any help is appreciated! 244 | --------------------------------------------------------------------------------- 245 | 246 | | === [M5] On 2018-01-31T05:59:27Z, jennie_minnie@ctrl-c.club replied...------- 247 | | Simple, you just boondoggle the flibbertigibbet. That should be in the 248 | | manual. 249 | | ----------------------------------------------------------------------------- 250 | 251 | jennie_minnie@ctrl-c.club~> edit M5 252 | Editing message 'Simple, you just boondoggle the flibbertigibbet. That shoul...' 253 | 254 | edit~> Simple, you just boondoggle the flibbertigibbet. That's in the manual on page 45. 255 | edit~> . 256 | Message edited! 257 | 258 | jennie_minnie@ctrl-c.club~> 3 259 | 260 | *** [3] On 2018-01-23T00:22:44Z, jimmy_foo@ctrl-c.club posted...----------------- 261 | How do I spoo the fleem? 262 | It's not in the docs and my boss is asking. Any help is appreciated! 263 | --------------------------------------------------------------------------------- 264 | 265 | | === [M5] (edited) On 2018-01-31T05:59:27Z, jennie_minnie@ctrl-c.club repli... 266 | | Simple, you just boondoggle the flibbertigibbet. That's in the manual on 267 | | page 45. 268 | | ----------------------------------------------------------------------------- 269 | ``` 270 | 271 | --- 272 | 273 | ### [d]elete 274 | `delete #, d #, undelete # - Delete or undelete a topic or message` 275 | 276 | Deleting a message or topic will remove the message you select. 277 | 278 | To select the message you wish to delete, use either the topic index or the message number. 279 | 280 | The message number will always start with the capital letter "M", message "M5" for example. 281 | 282 | A topic ID will always be strictly numeric, "3" in the following example. 283 | 284 | The message or topic ID can be found in square brackets in the informational text above each message. 285 | 286 | After a deletion, a status flag will appear on the message, letting others know the content of the message has been deliberately removed. 287 | 288 | If you wish to revert your deletion, "delete" the deleted message or topic ID to restore it. 289 | 290 | The `undelete` command is provided as a mnemonic convenience; it is identical in function to the `delete` command. 291 | ``` 292 | jennie_minnie@ctrl-c.club~> 3 293 | 294 | *** [3] On 2018-01-23T00:22:44Z, jimmy_foo@ctrl-c.club posted...----------------- 295 | How do I spoo the fleem? 296 | It's not in the docs and my boss is asking. Any help is appreciated! 297 | --------------------------------------------------------------------------------- 298 | 299 | | === [M5] On 2018-01-31T05:59:27Z, jennie_minnie@ctrl-c.club replied...------- 300 | | Simple, you just boondoggle the flibbertigibbet. That should be in the 301 | | manual. 302 | | ----------------------------------------------------------------------------- 303 | 304 | jennie_minnie@ctrl-c.club~> delete M5 305 | Deleted message 'Simple, you just boondoggle the flibbertigibbet. That shoul...' 306 | 307 | jennie_minnie@ctrl-c.club~> 3 308 | 309 | *** [3] On 2018-01-23T00:22:44Z, jimmy_foo@ctrl-c.club posted...----------------- 310 | How do I spoo the fleem? 311 | It's not in the docs and my boss is asking. Any help is appreciated! 312 | --------------------------------------------------------------------------------- 313 | 314 | | === [M5] (deleted) On 2018-01-31T05:59:27Z, jennie_minnie@ctrl-c.club repl... 315 | | ----------------------------------------------------------------------------- 316 | 317 | jennie_minnie@ctrl-c.club~> undelete M5 318 | Undeleted message 'Simple, you just boondoggle the flibbertigibbet. That sho...' 319 | 320 | jennie_minnie@ctrl-c.club~> 3 321 | 322 | *** [3] On 2018-01-23T00:22:44Z, jimmy_foo@ctrl-c.club posted...----------------- 323 | How do I spoo the fleem? 324 | It's not in the docs and my boss is asking. Any help is appreciated! 325 | --------------------------------------------------------------------------------- 326 | 327 | | === [M5] On 2018-01-31T05:59:27Z, jennie_minnie@ctrl-c.club replied...------- 328 | | Simple, you just boondoggle the flibbertigibbet. That should be in the 329 | | manual. 330 | | ----------------------------------------------------------------------------- 331 | 332 | ``` 333 | 334 | --- 335 | 336 | ### [m]ark_read 337 | `mark_read, m - Mark a topic read` 338 | 339 | Mark a topic and all its replies as read without displaying them. 340 | 341 | --- 342 | 343 | ### [m]ark_all_read 344 | `mark_all_read - Mark all messages as read` 345 | 346 | Marks all topics and their replies as read without displaying them. 347 | 348 | --- 349 | 350 | ### [f]reshen 351 | `freshen, f - Reload to get any new messages` 352 | 353 | This command reloads all users' message files to get any new messages that might have come in since you started the program. 354 | 355 | --- 356 | 357 | ### reset OR clear 358 | `reset, clear - Fix screen in case of text corruption` 359 | 360 | This clears the screen and resets the cursor. If you experience screen corruption due to wide characters or terminal resizing, this may fix your visual issues. 361 | 362 | --- 363 | 364 | ### [i]nfo 365 | `info, i - Display Iris version and message stats` 366 | 367 | This outputs the current version of Iris, along with messsage, topic, and author counts. 368 | 369 | ```bash 370 | jennie_minnie@ctrl-c.club~> info 371 | 372 | Iris 1.1.3 373 | 22 topics, 0 unread. 374 | 50 messages, 0 unread. 375 | 10 authors. 376 | ``` 377 | 378 | --- 379 | 380 | ### [h]elp 381 | `help, h, ? - Display help text` 382 | 383 | This displays helpful reminders of the commands that Iris supports. 384 | 385 | ## Command-line Options 386 | 387 | There are a few options you can pass in from the command-line: 388 | 389 | * [--debug](#--debug) 390 | * [--dump, -d](#--dump-d) 391 | * [--help, -h](#--help-h) 392 | * [--interactive, -i](#--interactive-i) 393 | * [--mark-all-read](#--mark-all-read) 394 | * [--stats, -s](#--stats-s) 395 | * [--test-file, -s](#--test-file) 396 | * [--version, -v](#--version-v) 397 | 398 | ### --debug 399 | 400 | This option turns on debug mode. Warnings and errors will be output as the program is used. 401 | 402 | Having these messages constantly appear can be distracting or annoying during regular Iris usage, but are useful when tracking down issues. 403 | 404 | This option works in both interactive and non-interactive mode. 405 | 406 | --- 407 | 408 | ### --dump/-d 409 | 410 | This reads the entire message corpus and outputs it as a stream of JSON data, suitable for piping into a backup file, `jq` parser, or similar. 411 | 412 | This command does not enter Iris' interactive mode. 413 | 414 | --- 415 | 416 | ### --help/-h 417 | 418 | This command displays a complete list of options that Iris recognizes. 419 | 420 | --- 421 | 422 | ### --interactive/-i 423 | 424 | This command enters Iris' interactive mode, the default mode with which users can compose and read topics and replies. 425 | 426 | This is the mode that Iris enters if no options are passed on the command-line. 427 | 428 | --- 429 | 430 | ### --mark-all-read 431 | 432 | This command simply marks every message as read in Iris. It's a quick way to get to "Irisbox Zero". 433 | 434 | --- 435 | 436 | ### --stats/-s 437 | 438 | This outputs the current version of Iris, along with messsage, topic, and author counts. 439 | 440 | This command does not enter Iris' interactive mode. 441 | 442 | ```bash 443 | iris --stats 444 | ``` 445 | 446 | ```bash 447 | Iris 1.1.3 448 | 22 topics, 0 unread. 449 | 50 messages, 0 unread. 450 | 10 authors. 451 | ``` 452 | 453 | --- 454 | 455 | ### --test-file/-f 456 | 457 | ```bash 458 | iris --test-file junk.messages.iris 459 | ``` 460 | 461 | This option forces Iris to load the specified message file, instead of scanning the `/home` directory. 462 | 463 | This option works in both interactive and non-interactive mode. 464 | 465 | --- 466 | 467 | ### --version/-v 468 | 469 | This displays the current version of Iris and exits. 470 | 471 | ```bash 472 | iris --version 473 | ``` 474 | 475 | ```bash 476 | Iris 1.1.3 477 | ``` 478 | 479 | ## Text Features/Markup 480 | 481 | ### Color 482 | 483 | Iris supports 7 colors and 4 text features. 484 | 485 | #### Colors 486 | 487 | | Marker | Color | 488 | |:------:|:--------| 489 | | r | Red | 490 | | g | Green | 491 | | y | Yellow | 492 | | b | Blue | 493 | | m | Magenta | 494 | | c | Cyan | 495 | | w | White | 496 | 497 | #### Text Features 498 | 499 | | Marker | Feature | 500 | |:------:|:-----------| 501 | | n | Normal | 502 | | i | Intense | 503 | | u | Underlined | 504 | | v | Reversed | 505 | 506 | #### Markup 507 | 508 | Colors and Text Features are applied by a simple markup. Surround the text you want colored with an opening curly brace (`{`), add some number of text modification markers (`riu`, for example), and `}`), and close with a closing curly brace (`}`). 509 | 510 | For example, if you have the text: 511 | 512 | ``` 513 | The blue fox and the yellow dog 514 | ``` 515 | 516 | ...and you wanted to color it appropriately, you would wrap the text "blue fox" and "yellow dog" like so: 517 | 518 | ``` 519 | The {b blue fox} and the {y yellow dog} 520 | ``` 521 | 522 | The result, in your final message, would look like: 523 | 524 | ![blue_fox_and_yellow_dog.png](docs/images/blue_fox_and_yellow_dog.png) 525 | 526 | --- 527 | 528 | Text features can be added as well: 529 | 530 | ``` 531 | The {b blue fox} {u will} jump over the {y yellow dog} 532 | ``` 533 | 534 | ![blue_fox_jumping.png](docs/images/blue_fox_jumping.png) 535 | 536 | --- 537 | 538 | A color can be combined with multiple text features: 539 | 540 | ``` 541 | The {b blue fox} {riuv will} jump over the {y yellow dog} 542 | ``` 543 | 544 | ![blue_fox_really_jumping.png](docs/images/blue_fox_really_jumping.png) 545 | 546 | --- 547 | 548 | Marker order does not matter. These two statements are equivalent: 549 | 550 | ``` 551 | The {bv blue fox} {riuv will} jump over the {yi yellow dog} 552 | The {vb blue fox} {uirv will} jump over the {iy yellow dog} 553 | ``` 554 | 555 | --- 556 | 557 | If you want to type a curly brace, preface it with a backslash (`\`): 558 | 559 | ``` 560 | No colors for me, please. I just want to \{ write: code \} 561 | ``` 562 | 563 | ...yields: 564 | 565 | ``` 566 | No colors for me, please. I just want to { write: code } 567 | ``` 568 | 569 | --- 570 | 571 | #### Color and Text Feature Sample 572 | 573 | ![color_and_text_feature_sample.png](docs/images/color_and_text_feature_sample.png) 574 | 575 | --- 576 | 577 | #### Caveats 578 | 579 | Color and text feature markup cannot be nested. It won't break anything, but it will probably not look like you are expecting. 580 | 581 | ## Philosophy 582 | 583 | Iris must: 584 | * Be a single file 585 | * There should be no specific directory structure or complicated setup required. 586 | * Run a single file, answer one question, and you should be going with Iris! 587 | * Not require administrator intervention to install 588 | * Any user on a tilde, or with his or her own server, should be able to start using Iris just by running the file. 589 | * Not require any other software to function 590 | * No databases, web servers, GUIs, or frameworks are require to use Iris fully. 591 | * Require only Ruby 592 | * Not everybody uses Ruby or is familiar with the Ruby ecosystem. 593 | * Installing gems and libraries can be a major hassle if you don't have admin access or if there are library version conflicts. 594 | * Iris needs no extra gems or libraries to function. 595 | * Be durable 596 | * A user deleting or modifying his or her messages or message file should not break Iris. 597 | * Deleted or edited messages should leave flags or placeholders for other users to know that other content was there before. 598 | * The Iris client should expect that any message file could be missing, altered, or corrupted, and should handle those cases gracefully. 599 | * Be portable 600 | * All Iris data files should be human-readable (and -editable, in a pinch) 601 | * The use of the official Iris client should be optional for a user to manage his or her messages. A text editor should suffice. 602 | * Other clients which follow the Iris file format should work seamlessly with the official Iris client. 603 | * Be secure 604 | * Message files should be owned and only editable by their author. 605 | * Iris should warn the user if this is not the case. 606 | * Be a teacher 607 | * Code should be clean, well-organized, and readable. 608 | * Be limited in scope 609 | * The source code should not exceed 1,000 SLOC 610 | 611 | ## Tests 612 | 613 | The one place we're breaking the rules on requiring gems is in the tests. Mocha's just too good. :) To run the tests, you can install the following (these will end up in your user directory, to minimize the chances of interfering with system gems). 614 | 615 | ```bash 616 | gem install --user-install minitest 617 | gem install --user-install mocha 618 | ``` 619 | 620 | To run the tests: 621 | 622 | ```bash 623 | ruby tests/iris_test.rb 624 | ``` 625 | 626 | ```bash 627 | Run options: --seed 11507 628 | 629 | # Running: 630 | 631 | .........................SSS.......SS.......S.....SSSSSSS....SSSSS 632 | 633 | Finished in 0.107785s, 612.3294 runs/s, 677.2734 assertions/s. 634 | 635 | 66 runs, 73 assertions, 0 failures, 0 errors, 18 skips 636 | 637 | You have skipped tests. Run with --verbose for details. 638 | ``` 639 | 640 | ## Cutting A Release 641 | 642 | ### Prep 643 | * Make all updates in an appropriately named branch. (ie. `1.1.3`) 644 | * Make sure all commits are clean. 645 | * Make sure tests pass. 646 | 647 | ### Updates 648 | * Change version number in `iris.rb` 649 | * Change version numbers in documentation `README.md` 650 | * Add new version and details to `CHANGELOG` 651 | * Create new commit for release (Named "Bump Iris version to 1.1.3" or similar) 652 | 653 | ### Make the Sausage 654 | * Push the branch 655 | * `git push origin` 656 | * Merge the branch (Fast-forward only, for a linear commit history) 657 | * Tag the release 658 | * `git tag 1.1.3` 659 | * Push the tags 660 | * `git push origin --tags` 661 | 662 | ## Technical Bits 663 | 664 | * [Dependencies](#dependencies) 665 | * [Conventions](#conventions) 666 | * [Message Files](#message-files) 667 | * [Messages](#messages) 668 | * [Message Hash](#message-hash) 669 | * [Bad Hashes](#bad-hashes) 670 | * [Edit Chain](#edit-chain) 671 | * [Deleted Messages](#deleted-messages) 672 | * [Topic List](#topic-list) 673 | * [Replies](#replies) 674 | 675 | ### Dependencies 676 | 677 | While trying to stay reasonably lightweight, Iris does dependend on a few tools being installed: 678 | 679 | * `ls` is used to get a list of all the Iris message files on the system. 680 | * `hostname` is used to find the name of the server Iris is running on. 681 | * `tput` is used to get the terminal reset command. 682 | 683 | ### Conventions 684 | 685 | Iris leans heavily on convention. Iris' security and message authentication is provided by filesystem permissions and message hashing. 686 | 687 | ### Message Files 688 | 689 | Each user has their own message file. This is a JSON file containing all the messages that the user has authored. It is named `.iris.messages` and is located in the user's home directory. 690 | 691 | `/home/jimmy_foo/.iris.messages` 692 | 693 | In order to operate correctly and safely, this file _must_ be: 694 | * World-readable 695 | * Owner-writable 696 | * Non-executable 697 | * Owned by the user account that it will be storing messages for 698 | 699 | ```bash 700 | %> ls -la ~/.iris.messages 701 | -rw-r--r-- 1 jimmy_foo jimmy_foo /home/jimmy_foo/.iris.messages 702 | ``` 703 | 704 | ### Messages 705 | 706 | Messages fall into one of two categories: topics and replies. Topics are top-level messages. Replies are messages that are attached to a topic. 707 | 708 | The message structure is as follows: 709 | 710 | ``` 711 | { 712 | "hash": str, 713 | "edit_hash": str, 714 | "is_deleted": bool, 715 | "data": { 716 | "author": str, 717 | "parent": str, 718 | "timestamp": str, 719 | "message": str 720 | } 721 | } 722 | ``` 723 | 724 | Each field is as follows: 725 | 726 | * author: The username of the user who created the message, with the server hostname attached with an @ symbol (ie. `jerry_berry@ctrl-c.club`). 727 | * message: The text of the message. The first line is the title of the message. 728 | * hash: Each message is SHA1 hashed for verification and uniqueness. The author, parent hash, timestamp, and message values go into the hash. (see [Message Hash](#message-hash) for details) 729 | * parent: If the message is a reply, this holds the hash of the topic it's associated with. 730 | * timestamp: The GMT timestamp of when the message was created. 731 | * edit_hash: When a message is edited, a new message is created-- this field holds the hash of the modified message. The client follows the chain of edit hashes to end up at the final, edited message to display. This lets us keep an "undo" history (not yet implemented) and is a marker so the client can display a marker that the message has been edited. 732 | * is_deleted: This is a boolean field that marks whether a message has been deleted. The message is retained so that the structure of topics and replies can be maintained. 733 | * errors (not saved to the file): This is where any issues are held for display. Examples of errors are unparseable usernames or invalid hashes. 734 | 735 | #### Message Hash 736 | 737 | Each message is SHA1 hashed for verification and uniqueness. 738 | 739 | The hash is created by putting `author`, `parent`, `timestamp`, and `message` go into a JSON-formatted string. This string should include no extra whitespace. 740 | 741 | For example: 742 | 743 | The following message: 744 | 745 | author: `jerry_berry@ctrl-c.club` 746 | parent: null 747 | timestamp: `2021-11-25T06:35:34Z` 748 | message: `Howdy!` 749 | 750 | Would be turned into the following JSON string, in this order: 751 | 752 | ``` 753 | {"author":"jerry_berry@ctrl-c.club","parent":null,"timestamp":"2021-11-25T06:35:34Z","message":"Howdy!"} 754 | ``` 755 | 756 | This string would then be hashed using SHA1: 757 | 758 | ``` 759 | \xBD\xFD[D\xA0\xF0\xBFw`\x14\xF8)\xCA\xC9n\xFA-\x82\xB9\xBC 760 | ``` 761 | 762 | This hash is then base64 encoded: 763 | 764 | ``` 765 | vf1bRKDwv3dgFPgpyslu+i2Cubw=\n 766 | ``` 767 | 768 | This is the "key" that is used to uniquely identify this version of the message. 769 | 770 | ##### Bad Hashes 771 | #### Edit Chain 772 | #### Deleted Messages 773 | 774 | ### Topic List 775 | 776 | ### Replies 777 | 778 | ## License 779 | GPLv2 780 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # Epics 2 | ### MVP: Complete! 3 | ### Reading/Status: Complete! 4 | ### Editing/Deleting: Complete! 5 | ### Documentation: In Progress 6 | 7 | # Work Items 8 | 9 | ### Documentation 10 | * Flesh out technical sections 11 | 12 | ### Bugs 13 | * Terrible slowdown when refreshing topics 14 | * Performance is fine when no new topics show up 15 | * Is `Time.now.utc.iso8601` working as expected? 16 | * Fix bug when people are posting from different time zones 17 | * Fix message ordering when editing/deleting multiple messages 18 | * Gracefully handle attempt to "r 1 message" 19 | 20 | ### Features 21 | * Add permalinks/indexes 22 | * Add pagination/less for long message lists 23 | * https://github.com/Calamitous/iris/issues/1 24 | * Add local timezone rendering 25 | * CLI option to show response count to threads the user authored 26 | * Search/regex function to find all messages 27 | 28 | ### Tech debt 29 | * Flesh out tests 30 | * Add integration tests 31 | * Create Struct to firm up message payload 32 | * Let Message initialization accept params as a hash 33 | * Add check for message file format version 34 | * Build entire topic line, _then_ truncate 35 | * Continue to make loader more durable against corrupted data files 36 | * Condense generated color codes (color resets are especially noisy) 37 | * Check message file size before loading, to prevent giant files from bombing the system. 38 | 39 | ### Backlog 40 | * Add reader/user count to stats 41 | * Add "already read" message counts to topic line 42 | * Add "already read" message counts to statistics 43 | * Add "Mark unread" option 44 | * Add read-only mode if user doesn't want/can't have message file 45 | * Add user muting (~/.iris.muted) 46 | * Add stats to interactive interface 47 | * Readline.completion_proc for tab completion 48 | * Highlight names for readability 49 | * Add message when no topics are found 50 | * Add option to skip color 51 | 52 | ### Icebox 53 | * Add message troubleshooting tool, for deep data dive 54 | * Add optional title for topics 55 | * Health check CLI flag? 56 | * Add -q/--quiet flag, to create iris message file without user intervention? 57 | * Add "private" messages 58 | * JSON API mode 59 | * Create local copies of replied-to messages to limit tampering? 60 | * Add ability to fully manage/read messages from CLI? 61 | * ncurses client 62 | * customizable prompt 63 | * MOTD/Title? 64 | * Add to default startup script to display read count 65 | * Common message file location for the security-conscious 66 | * JSON -> SSI -> Javascript webreader 67 | 68 | -------------------------------------------------------------------------------- /completions/bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | # This is a bash completion script for iris 4 | # It should be copied to /usr/share/bash-completion/completions/iris 5 | 6 | _iris_module() { 7 | local cur prev OPTS 8 | COMPREPLY=() 9 | cur="${COMP_WORDS[COMP_CWORD]}" 10 | prev="${COMP_WORDS[COMP_CWORD-1]}" 11 | 12 | case $prev in 13 | -f | --test-file) 14 | return 0 # File 15 | ;; 16 | esac 17 | 18 | case $cur in 19 | --*) 20 | OPTS="--debug 21 | --dump 22 | --help 23 | --interactive 24 | --stats 25 | --test-file 26 | --version" 27 | ;; 28 | *) 29 | OPTS="-d 30 | -f 31 | -h 32 | -i 33 | -s 34 | -v" 35 | ;; 36 | esac 37 | 38 | COMPREPLY=( $(compgen -W "${OPTS[*]}" -- $cur) ) 39 | } 40 | 41 | complete -F _iris_module -o bashdefault -o default iris 42 | -------------------------------------------------------------------------------- /completions/zsh: -------------------------------------------------------------------------------- 1 | #compdef _iris iris 2 | 3 | # This is a zsh completion script for iris 4 | # It should be copied to /usr/share/zsh/functions/Completion/Unix/_iris 5 | 6 | function _iris { 7 | local context state state_descr line 8 | typeset -A opt_args 9 | 10 | _arguments -C \ 11 | "--debug[Print warnings and debug informtation during use]" \ 12 | "-d[Dump entire message corpus out]" \ 13 | "--dump[Dump entire message corpus out]" \ 14 | "-h[Show help information]" \ 15 | "--help[Show help information]" \ 16 | "-i[Enter interactive mode (default)]" \ 17 | "--interactive[Enter interactive mode (default)]" \ 18 | "--mark-all-read[Mark every message in Iris as \"read\".]" 19 | "-s[Display Iris version and message stats]" \ 20 | "--stats[Display Iris version and message stats]" \ 21 | "-f[Use the specified test file for messages]:f:->f" \ 22 | "--test-file[Use the specified test file for messages]:f:->f" \ 23 | "-v[Display the current version of Iris]" \ 24 | "--version[Display the current version of Iris]" \ 25 | 26 | if [ "$state" = "f" ]; then 27 | _files 28 | fi 29 | } 30 | -------------------------------------------------------------------------------- /docs/images/blue_fox_and_yellow_dog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Calamitous/iris/c8aa13e13edefefcba8106cab706942a3f567434/docs/images/blue_fox_and_yellow_dog.png -------------------------------------------------------------------------------- /docs/images/blue_fox_jumping.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Calamitous/iris/c8aa13e13edefefcba8106cab706942a3f567434/docs/images/blue_fox_jumping.png -------------------------------------------------------------------------------- /docs/images/blue_fox_really_jumping.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Calamitous/iris/c8aa13e13edefefcba8106cab706942a3f567434/docs/images/blue_fox_really_jumping.png -------------------------------------------------------------------------------- /docs/images/color_and_text_feature_sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Calamitous/iris/c8aa13e13edefefcba8106cab706942a3f567434/docs/images/color_and_text_feature_sample.png -------------------------------------------------------------------------------- /iris.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'base64' 3 | require 'digest' 4 | require 'etc' 5 | require 'json' 6 | require 'readline' 7 | require 'set' 8 | require 'tempfile' 9 | require 'time' 10 | # require 'pry' # Only needed for debugging 11 | 12 | class NilClass 13 | def presence 14 | self 15 | end 16 | end 17 | 18 | class String 19 | def presence 20 | return nil if self.length == 0 21 | self 22 | end 23 | end 24 | 25 | class Config 26 | VERSION = '1.1.3' 27 | MESSAGE_FILE = "#{ENV['HOME']}/.iris.messages" 28 | HISTORY_FILE = "#{ENV['HOME']}/.iris.history" 29 | IRIS_SCRIPT = __FILE__ 30 | 31 | ENV_EDITOR = ENV['EDITOR'].presence || `which nano`.chomp.presence 32 | 33 | @@debug_mode = false 34 | 35 | def self.hostname 36 | return @hostname if @hostname 37 | 38 | hostname = `hostname`.chomp 39 | hostname = 'localhost' if hostname.empty? 40 | 41 | components = hostname.split('.') 42 | return @hostname = hostname if components.length == 1 43 | 44 | @hostname = components[-2..-1].compact.join('.') 45 | end 46 | 47 | def self.author 48 | user = ENV['USER'] || ENV['LOGNAME'] || ENV['USERNAME'] 49 | @author ||= "#{user}@#{self.hostname}" 50 | end 51 | 52 | def self.find_files 53 | (`ls /home/**/.iris.messages`).split("\n") 54 | end 55 | 56 | def self.messagefile_filename 57 | $test_corpus_file || Config::MESSAGE_FILE 58 | end 59 | 60 | def self.readfile_filename 61 | "#{messagefile_filename}.read" 62 | end 63 | 64 | def self.enable_debug_mode 65 | @@debug_mode = true 66 | end 67 | 68 | def self.debug? 69 | @@debug_mode 70 | end 71 | end 72 | 73 | class String 74 | COLOR_MAP = { 75 | 'n' => '0', 76 | 'i' => '1', 77 | 'u' => '4', 78 | 'v' => '7', 79 | 'r' => '31', 80 | 'g' => '32', 81 | 'y' => '33', 82 | 'b' => '34', 83 | 'm' => '35', 84 | 'c' => '36', 85 | 'w' => '37', 86 | } 87 | 88 | COLOR_RESET = "\033[0m" 89 | 90 | def color_token 91 | if self !~ /\w/ 92 | return { '\{' => '|KOPEN|', '\}' => '|KCLOSE|', '}' => COLOR_RESET}[self] 93 | end 94 | 95 | tag = self.scan(/\w/).map{ |t| COLOR_MAP[t] }.sort.join(';') 96 | "\033[#{tag}m" 97 | end 98 | 99 | def colorize 100 | r = /\\{|{[rgybmcwniuv]+\s|\\}|}/ 101 | split = self.split(r, 2) 102 | 103 | return self.color_bounded if r.match(self).nil? 104 | newstr = split.first + $&.color_token + split.last 105 | 106 | if r.match(newstr).nil? 107 | return (newstr + COLOR_RESET).gsub(/\|KOPEN\|/, '{').gsub(/\|KCLOSE\|/, '}').color_bounded 108 | end 109 | 110 | newstr.colorize.color_bounded 111 | end 112 | 113 | def decolorize 114 | self. 115 | gsub(/\\{/, '|KOPEN|'). 116 | gsub(/\\}/, '|KCLOSE|'). 117 | gsub(/{[rgybmcwniuv]+\s|}/, ''). 118 | gsub(/\|KOPEN\|/, '{'). 119 | gsub(/\|KCLOSE\|/, '}') 120 | end 121 | 122 | def wrapped(width = Display::WIDTH) 123 | self.gsub(/.{1,#{width}}(?:\s|\Z|\-)/) { 124 | ($& + 5.chr).gsub(/\n\005/,"\n").gsub(/\005/,"\n") 125 | } 126 | end 127 | 128 | def pluralize(count) 129 | count == 1 ? self : self + 's' 130 | end 131 | 132 | def color_bounded 133 | COLOR_RESET + self.gsub(/\n/, "\n#{COLOR_RESET}") + COLOR_RESET 134 | end 135 | end 136 | 137 | class Corpus 138 | def self.load 139 | if $test_corpus_file 140 | @@corpus = IrisFile.load_messages 141 | else 142 | @@corpus = Config.find_files.map { |filepath| IrisFile.load_messages(filepath) }.flatten.sort_by(&:timestamp) 143 | end 144 | 145 | @@my_corpus = IrisFile.load_messages.sort_by(&:timestamp) 146 | @@my_read_hashes = IrisFile.load_reads 147 | 148 | @@unread_messages = nil 149 | 150 | @@all_hash_to_index = @@corpus.reduce({}) { |agg, msg| agg[msg.hash] = @@corpus.index(msg); agg } 151 | @@edited_hashes = @@corpus.map(&:edit_hash).compact 152 | @@topics = @@corpus.select(&:is_topic?) 153 | 154 | @@all_parent_hash_to_index = @@corpus.reduce({}) do |agg, msg| 155 | agg[msg.parent] ||= [] 156 | agg[msg.parent] << @@corpus.index(msg) 157 | agg 158 | end 159 | end 160 | 161 | def self.to_json 162 | @@corpus.to_json 163 | end 164 | 165 | def self.edited_hashes 166 | @@edited_hashes 167 | end 168 | 169 | def self.topics 170 | @@topics 171 | end 172 | 173 | def self.authors 174 | @@corpus.map(&:author).uniq.sort 175 | end 176 | 177 | def self.mine 178 | @@my_corpus 179 | end 180 | 181 | def self.is_mine?(message) 182 | @@my_corpus.map(&:hash).include? message.hash 183 | end 184 | 185 | def self.index_of(message) 186 | @@corpus.map(&:hash).index message.hash 187 | end 188 | 189 | def self.topic_index_of(message) 190 | @@topics.map(&:hash).index message.hash 191 | end 192 | 193 | def self.find_message_by_hash(hash) 194 | return nil unless hash 195 | index = @@all_hash_to_index[hash] 196 | return nil unless index 197 | @@corpus[index] 198 | end 199 | 200 | def self.find_all_by_parent_hash(hash) 201 | return [] unless hash 202 | indexes = @@all_parent_hash_to_index[hash] 203 | return [] unless indexes 204 | indexes.map{ |idx| @@corpus[idx] }.compact.select(&:show_me?) 205 | end 206 | 207 | def self.find_topic_by_id(topic_lookup) 208 | return nil unless topic_lookup 209 | index = topic_lookup.to_i - 1 210 | @@topics[index] if index >= 0 && index < @@topics.length 211 | end 212 | 213 | def self.find_message_by_id(message_lookup) 214 | return nil unless message_lookup && message_lookup =~ /\AM\d+\Z/ 215 | index = message_lookup.gsub(/M/, '').to_i - 1 216 | @@corpus[index] if index >= 0 && index < @@corpus.length 217 | end 218 | 219 | def self.find_topic_by_hash(topic_lookup) 220 | return nil unless topic_lookup 221 | find_message_by_hash(topic_lookup) 222 | end 223 | 224 | def self.unread_messages 225 | @@unread_messages ||= @@corpus 226 | .select { |message| message.show_me? } 227 | .reject{ |m| @@my_read_hashes.include? m.hash } 228 | .reject{ |m| @@my_corpus.map(&:hash).include? m.hash } 229 | end 230 | 231 | def self.unread_topics 232 | @@topics.select do |m| 233 | # Is the topic unread, or are any of its displayable replies unread? 234 | m.unread? || 235 | find_all_by_parent_hash(m.hash).reduce(false) { |agg, r| agg || r.unread? } 236 | end 237 | end 238 | 239 | def self.unread_topics_set 240 | unread_topics.map(&:hash).to_set 241 | end 242 | 243 | def self.size 244 | @@corpus.size 245 | end 246 | 247 | def self.mark_as_read(hashes) 248 | new_reads = (@@my_read_hashes + hashes).uniq.sort 249 | IrisFile.write_read_file(new_reads.to_json) 250 | Corpus.load 251 | end 252 | end 253 | 254 | class IrisFile 255 | def self.load_messages(filepath = nil) 256 | if filepath.nil? 257 | filepath = Config.messagefile_filename 258 | end 259 | 260 | return [] unless File.exists?(filepath) 261 | 262 | begin 263 | payload = JSON.parse(File.read(filepath)) 264 | rescue JSON::ParserError => e 265 | if filepath == Config.messagefile_filename 266 | Display.flowerbox( 267 | 'Your message file appears to be corrupt.', 268 | "Could not parse valid JSON from #{filepath}", 269 | 'Please fix or delete this message file to use Iris.') 270 | exit(1) 271 | else 272 | Display.say " * Unable to parse #{filepath}, skipping..." 273 | return [] 274 | end 275 | rescue Errno::EACCES => e 276 | Display.warn " * Unable to read data from #{filepath}, permission denied. Skipping..." 277 | return [] 278 | end 279 | 280 | unless payload.is_a?(Array) 281 | if filepath == Config.messagefile_filename 282 | Display.flowerbox( 283 | 'Your message file appears to be corrupt.', 284 | "Could not interpret data from #{filepath}", 285 | '(It\'s not a JSON array of messages, as far as I can tell)', 286 | 'Please fix or delete this message file to use Iris.') 287 | exit(1) 288 | else 289 | Display.say " * Unable to interpret data from #{filepath}, skipping..." 290 | return [] 291 | end 292 | end 293 | 294 | uid = File.stat(filepath).uid 295 | 296 | begin 297 | username = Etc.getpwuid(uid).name 298 | rescue ArgumentError 299 | Display.warn("'#{filepath}' does not appear to have a valid UID in /etc/passwd, skipping...") 300 | return [] 301 | end 302 | 303 | payload.map do |message_json| 304 | new_message = Message.load(message_json) 305 | new_message.validate_user(username) 306 | new_message 307 | end 308 | end 309 | 310 | def self.load_reads 311 | return [] unless File.exists? Config.readfile_filename 312 | 313 | begin 314 | read_array = JSON.parse(File.read(Config.readfile_filename)) 315 | rescue JSON::ParserError => e 316 | Display.flowerbox( 317 | 'Your read file appears to be corrupt.', 318 | "Could not parse valid JSON from #{Config.readfile_filename}", 319 | 'Please fix or delete this read file to use Iris.') 320 | exit(1) 321 | end 322 | 323 | unless read_array.is_a?(Array) 324 | Display.flowerbox( 325 | 'Your read file appears to be corrupt.', 326 | "Could not interpret data from #{Config.readfile_filename}", 327 | '(It\'s not a JSON array of message hashes, as far as I can tell)', 328 | 'Please fix or delete this read file to use Iris.') 329 | exit(1) 330 | end 331 | 332 | read_array 333 | end 334 | 335 | def self.create_message_file 336 | raise 'Should not try to create message file in test mode!' if $test_corpus_file 337 | raise 'Message file exists; refusing to overwrite!' if File.exists?(Config::MESSAGE_FILE) 338 | File.umask(0122) 339 | File.open(Config::MESSAGE_FILE, 'w') { |f| f.write('[]') } 340 | end 341 | 342 | def self.create_read_file 343 | return if File.exists?(Config.readfile_filename) 344 | 345 | File.umask(0122) 346 | File.open(Config.readfile_filename, 'w') { |f| f.write('[]') } 347 | end 348 | 349 | def self.write_corpus(corpus) 350 | File.write(Config.messagefile_filename, corpus) 351 | end 352 | 353 | def self.write_read_file(new_read_hashes) 354 | if $test_corpus_file 355 | File.write("#{$test_corpus_file}.read", new_read_hashes) 356 | else 357 | File.write(Config.readfile_filename, new_read_hashes) 358 | end 359 | end 360 | end 361 | 362 | class Message 363 | attr_reader :timestamp, :edit_hash, :author, :parent, :message, :errors, :is_deleted 364 | 365 | def initialize(message, parent = nil, author = Config.author, edit_hash = nil, timestamp = Time.now.utc.iso8601, is_deleted = nil) 366 | @message = message 367 | @parent = parent 368 | @author = author 369 | @edit_hash = edit_hash 370 | @timestamp = timestamp 371 | @hash = hash 372 | @is_deleted = is_deleted 373 | @errors = [] 374 | end 375 | 376 | def self.load(payload) 377 | data = payload if payload.is_a?(Hash) 378 | data = JSON.parse(payload) if payload.is_a?(String) 379 | 380 | loaded_message = self.new(data['data']['message'], data['data']['parent'], data['data']['author'], data['edit_hash'], data['data']['timestamp'], data['is_deleted']) 381 | loaded_message.validate_hash(data['hash']) 382 | loaded_message 383 | end 384 | 385 | def self.edit(new_text, old_message) 386 | Message.new(new_text, old_message.parent, old_message.author, old_message.hash, old_message.timestamp).save! 387 | end 388 | 389 | def is_topic? 390 | parent.nil? && show_me? 391 | end 392 | 393 | def delete 394 | @is_deleted = !@is_deleted 395 | replace! 396 | end 397 | 398 | def edited? 399 | !(edit_hash.nil? || edit_hash.empty?) 400 | end 401 | 402 | # Only show messages that don't have a following, edited message 403 | def show_me? 404 | !Corpus.edited_hashes.include?(hash) 405 | end 406 | 407 | def validate_user(username) 408 | @errors << 'Unvalidatable; could not parse username' if username.nil? 409 | @errors << 'Unvalidatable; username is empty' if username.empty? 410 | 411 | user_regex = Regexp.new("(.*)@.*$") 412 | author_match = user_regex.match(author) 413 | 414 | unless author_match && author_match[1] == username 415 | @errors << "Bad username: got #{author}'s message from #{username}'s message file." 416 | end 417 | end 418 | 419 | def validate_hash(test_hash) 420 | if self.hash != test_hash 421 | @errors << "Broken hash: expected '#{hash.chomp}', got '#{test_hash.chomp}'" 422 | end 423 | end 424 | 425 | def valid? 426 | @errors.empty? 427 | end 428 | 429 | def replace! 430 | new_corpus = Corpus.mine.reject { |message| message.hash == self.hash } << self 431 | IrisFile.write_corpus(JSON.pretty_generate(new_corpus)) 432 | Corpus.load 433 | end 434 | 435 | def save! 436 | new_corpus = Corpus.mine << self 437 | IrisFile.write_corpus(JSON.pretty_generate(new_corpus)) 438 | Corpus.load 439 | end 440 | 441 | def hash(payload = nil) 442 | if payload.nil? 443 | return @hash if @hash 444 | payload = unconfirmed_payload.to_json 445 | end 446 | Base64.encode64(Digest::SHA1.digest(payload)) 447 | end 448 | 449 | def truncated_display_message(length) 450 | if is_deleted 451 | stub = '{r TOPIC DELETED BY AUTHOR}' 452 | else 453 | stub = message.split("\n").first 454 | end 455 | return stub.colorize if stub.decolorize.length <= length 456 | # Colorize the stub, then decolorize to strip out any partial tags 457 | stub.colorize.slice(0, length - 5 - Display.topic_index_width).decolorize + '...' 458 | end 459 | 460 | def truncated_message(length) 461 | stub = message.split("\n").first 462 | return stub.colorize if stub.decolorize.length <= length 463 | # Colorize the stub, then decolorize to strip out any partial tags 464 | stub.colorize.slice(0, length - 5 - Display.topic_index_width).decolorize + '...' 465 | end 466 | 467 | def latest_topic_timestamp 468 | (replies.map(&:timestamp).max || timestamp || 'UNKNOWN').gsub(/T/, ' ').gsub(/Z/, '') 469 | end 470 | 471 | def unread? 472 | Corpus.unread_messages.include? self 473 | end 474 | 475 | def topic_status 476 | return '{r X}' unless valid? 477 | unread_count = replies.count(&:unread?) 478 | unread_count += 1 if self.unread? 479 | return ' ' if unread_count == 0 480 | return '*' if unread_count > 9 481 | unread_count.to_s 482 | end 483 | 484 | def to_topic_line(index) 485 | head = [Display.print_index(index), topic_status, latest_topic_timestamp, Display.print_author(author)].join(' | ') 486 | message_stub = truncated_display_message(Display::WIDTH - head.decolorize.length - 1) 487 | '| ' + [head, message_stub].join(' | ') 488 | end 489 | 490 | def to_display 491 | error_marker = valid? ? nil : '{r ### THIS MESSAGE HAS THE FOLLOWING ERRORS ###}' 492 | error_follower = valid? ? nil : '{r ### THIS MESSAGE MAY BE CORRUPT OR MAY HAVE BEEN TAMPERED WITH ###}' 493 | 494 | message_header = "#{leader_text} On #{timestamp}, #{author} #{verb_text}..." 495 | 496 | header_bar = (indent_text + message_header + ('-' * (Display::WIDTH))) 497 | header_offset = header_bar.length - header_bar.decolorize.length 498 | header_bar = header_bar[0..Display::WIDTH + header_offset - 1] 499 | 500 | bar = indent_text + ('-' * (Display::WIDTH - indent_text.decolorize.length)) 501 | 502 | if @is_deleted 503 | message_text = nil 504 | else 505 | message_text = message.wrapped(Display::WIDTH - (indent_text.decolorize.length + 1)).split("\n").map{|m| indent_text + m }.join("\n") 506 | end 507 | 508 | [ 509 | '', 510 | error_marker, 511 | errors, 512 | error_follower, 513 | header_bar, 514 | message_text, 515 | bar 516 | ].flatten.compact.join("\n") 517 | end 518 | 519 | def to_topic_display 520 | [to_display] + replies.map(&:to_display) 521 | end 522 | 523 | # TODO: Is this only used for hashing? Maybe rename. 524 | def to_json(*args) 525 | { 526 | hash: hash, 527 | edit_hash: edit_hash, 528 | is_deleted: is_deleted, 529 | data: unconfirmed_payload 530 | }.to_json 531 | end 532 | 533 | def edit_predecessor 534 | return nil unless edit_hash 535 | Corpus.find_message_by_hash(edit_hash) 536 | end 537 | 538 | # Find all messages replying to the current topic, including replies to topics 539 | # which have been edited. 540 | def replies 541 | all_replies = Corpus.find_all_by_parent_hash(hash) 542 | all_replies += ((edit_predecessor && edit_predecessor.replies) || []) 543 | all_replies.compact.sort_by{ |reply| Corpus.index_of(reply) } 544 | end 545 | 546 | def id 547 | 'M' + (Corpus.index_of(self) + 1).to_s 548 | end 549 | 550 | def topic_id 551 | return nil unless self.is_topic? 552 | Corpus.topic_index_of(self) + 1 553 | end 554 | 555 | private 556 | 557 | def status_flag 558 | return '{r (deleted)}' if @is_deleted 559 | '{y (edited)}' if edited? 560 | end 561 | 562 | def leader_text 563 | is_topic? ? "{g ***} [#{topic_id}] #{status_flag}" : ["{g ===}", "[#{id}]", status_flag].compact.join(' ') 564 | end 565 | 566 | def verb_text 567 | is_topic? ? 'posted' : 'replied' 568 | end 569 | 570 | def indent_text 571 | is_topic? ? '' : ' | ' 572 | end 573 | 574 | def unconfirmed_payload 575 | { 576 | author: author, 577 | parent: parent, 578 | timestamp: timestamp, 579 | message: message, 580 | } 581 | end 582 | end 583 | 584 | class Display 585 | MIN_WIDTH = 80 586 | MIN_HEIGHT = 8 587 | 588 | WIDTH = [ENV['COLUMNS'].to_i, `tput cols`.chomp.to_i, MIN_WIDTH].compact.max 589 | HEIGHT = [ENV['ROWS'].to_i, `tput lines`.chomp.to_i, MIN_HEIGHT].compact.max 590 | 591 | TITLE_WIDTH = WIDTH - 26 592 | 593 | def self.permissions_error(filename, file_description, permission_string, mode_string, consequence = nil) 594 | message = [ 595 | "Your #{file_description} file has incorrect permissions! Should be \"#{permission_string}\".", 596 | "You can change this from the command line with:", 597 | " chmod #{mode_string} #{filename}", 598 | consequence 599 | ].compact 600 | self.flowerbox(message) 601 | end 602 | 603 | def self.flowerbox(*lines, box_character: '*', box_thickness: 1) 604 | box_thickness.times do say box_character * WIDTH end 605 | lines.each { |line| say line } 606 | box_thickness.times do say box_character * WIDTH end 607 | end 608 | 609 | def self.say(stuff = '') 610 | stuff = stuff.join("\n") if stuff.is_a? Array 611 | puts stuff.colorize 612 | end 613 | 614 | def self.warn(stuff = '') 615 | say("{y WARNING: }#{stuff}") if Config.debug? 616 | end 617 | 618 | def self.topic_index_width 619 | [Corpus.topics.size.to_s.length, 2].max 620 | end 621 | 622 | def self.topic_author_width 623 | Corpus.authors.map(&:length).max || 1 624 | end 625 | 626 | def self.print_index(index) 627 | # Left-align 628 | '{w ' + ((' ' * topic_index_width) + index.to_s)[(-topic_index_width)..-1] + '}' 629 | end 630 | 631 | def self.print_author(author) 632 | # Right-align 633 | (author.to_s + (' ' * topic_author_width))[0..(topic_author_width - 1)] 634 | end 635 | 636 | def self.topic_header 637 | author_head = ('AUTHOR' + (' ' * WIDTH))[0..topic_author_width-1] 638 | '| ' + ['ID', 'U', 'TIMESTAMP ', author_head, 'TITLE'].join(' | ') 639 | end 640 | end 641 | 642 | class Interface 643 | ONE_SHOTS = %w{ compose delete edit freshen help info mark_all_read mark_read next quit reset_display topics unread } 644 | CMD_MAP = { 645 | '?' => 'help', 646 | 'c' => 'compose', 647 | 'clear' => 'reset_display', 648 | 'compose' => 'compose', 649 | 'd' => 'delete', 650 | 'delete' => 'delete', 651 | 'e' => 'edit', 652 | 'edit' => 'edit', 653 | 'f' => 'freshen', 654 | 'freshen' => 'freshen', 655 | 'h' => 'help', 656 | 'help' => 'help', 657 | 'i' => 'info', 658 | 'info ' => 'info', 659 | 'm' => 'mark_read', 660 | 'mark' => 'mark_read', 661 | 'mark_all_read' => 'mark_all_read', 662 | 'n' => 'next', 663 | 'next' => 'next', 664 | 'q' => 'quit', 665 | 'quit' => 'quit', 666 | 'r' => 'reply', 667 | 'reply' => 'reply', 668 | 'reset' => 'reset_display', 669 | 't' => 'topics', 670 | 'topics' => 'topics', 671 | 'u' => 'unread', 672 | 'undelete' => 'delete', 673 | 'unread' => 'unread', 674 | } 675 | 676 | def reset_display 677 | Display.say `tput reset`.chomp 678 | end 679 | 680 | def self.info 681 | topic_count = Corpus.topics.size 682 | unread_topic_count = Corpus.unread_topics.size 683 | message_count = Corpus.size 684 | unread_message_count = Corpus.unread_messages.size 685 | author_count = Corpus.authors.size 686 | 687 | Display.flowerbox( 688 | "Iris #{Config::VERSION}", 689 | "#{topic_count} #{'topic'.pluralize(topic_count)}, #{unread_topic_count} unread.", 690 | "#{message_count} #{'message'.pluralize(message_count)}, #{unread_message_count} unread.", 691 | "#{author_count} #{'author'.pluralize(author_count)}.", 692 | box_thickness: 0) 693 | end 694 | 695 | def info 696 | Display.say 697 | Interface.info 698 | Display.say 699 | end 700 | 701 | def self.mark_all_read 702 | Corpus.mark_as_read(Corpus.unread_messages.map(&:hash)) 703 | end 704 | 705 | def mark_all_read 706 | Display.say "Marking all messages as read..." 707 | Interface.mark_all_read 708 | Display.say "Done!" 709 | end 710 | 711 | def compose 712 | Display.say 'Writing a new topic.' 713 | 714 | message_text = external_editor() 715 | 716 | if message_text.length <= 1 717 | Display.say '{riv Empty message, discarding...}' 718 | else 719 | Message.new(message_text).save! 720 | Display.say 'Topic saved!' 721 | end 722 | end 723 | 724 | def next 725 | Display.say 726 | 727 | if Corpus.unread_topics.size == 0 728 | Display.say "{gvi You're all caught up! No new topics to read.}" 729 | return 730 | end 731 | 732 | message = Corpus.unread_topics.first 733 | 734 | Display.say message.to_topic_display 735 | Display.say 736 | 737 | Corpus.mark_as_read([message.hash] + message.replies.map(&:hash)) 738 | end 739 | 740 | def reply(topic_id) 741 | unless topic_id 742 | Display.say "I can't reply to nothing! Include a topic ID to reply to." 743 | return 744 | end 745 | 746 | if parent = (Corpus.find_topic_by_id(topic_id) || Corpus.find_topic_by_hash(topic_id)) 747 | reply_topic = parent.hash 748 | else 749 | Display.say "Could not reply; unable to find a topic with ID '#{topic_id}'" 750 | return 751 | end 752 | 753 | title = Corpus.find_topic_by_hash(parent.hash).truncated_message(Display::TITLE_WIDTH) 754 | Display.say 755 | Display.say "Writing a reply to topic '#{title}'" 756 | 757 | message_text = external_editor() 758 | 759 | if message_text.length <= 1 760 | Display.say '{riv Empty message, discarding...}' 761 | else 762 | Message.new(message_text, reply_topic).save! 763 | Display.say 'Reply saved!' 764 | end 765 | end 766 | 767 | def edit(message_id = nil) 768 | unless message_id 769 | Display.say "I can't edit nothing! Include a message ID to edit." 770 | return 771 | end 772 | 773 | message = 774 | Corpus.find_message_by_hash(message_id) || 775 | Corpus.find_message_by_id(message_id) || 776 | Corpus.find_topic_by_id(message_id) 777 | 778 | unless message 779 | Display.say "Could not edit; unable to find a message with ID '#{message_id}'" 780 | return 781 | end 782 | 783 | unless Corpus.is_mine?(message) 784 | Display.say "Message with ID '#{message_id}' belongs to someone else." 785 | Display.say "You can only edit your own messages!" 786 | return 787 | end 788 | 789 | title = message.truncated_message(Display::TITLE_WIDTH) 790 | Display.say 791 | Display.say "Editing message '#{title}'" 792 | 793 | message_text = external_editor(message.message) 794 | 795 | if message_text.length <= 1 796 | Display.say 'Empty message, not updating...' 797 | elsif message_text == message.message 798 | Display.say 'No change made, not updating...' 799 | else 800 | Message.edit(message_text, message) 801 | Display.say 'Message edited!' 802 | end 803 | end 804 | 805 | def mark_read(message_id = nil) 806 | unless message_id 807 | Display.say "I'm not a nihilist; I can't do something with nothing! Include a message ID to mark as read." 808 | return 809 | end 810 | 811 | message = 812 | Corpus.find_message_by_hash(message_id) || 813 | Corpus.find_message_by_id(message_id) || 814 | Corpus.find_topic_by_id(message_id) 815 | 816 | unless message 817 | Display.say "Could not mark as read; unable to find a message with ID '#{message_id}'" 818 | return 819 | end 820 | 821 | Corpus.mark_as_read([message.hash] + message.replies.map(&:hash)) 822 | end 823 | 824 | def delete(message_id = nil) 825 | unless message_id 826 | Display.say "I'm not a nihilist; I can't do something with nothing! Include a message ID to delete or undelete." 827 | return 828 | end 829 | 830 | message = 831 | Corpus.find_message_by_hash(message_id) || 832 | Corpus.find_message_by_id(message_id) || 833 | Corpus.find_topic_by_id(message_id) 834 | 835 | unless message 836 | Display.say "Could not delete or undelete; unable to find a message with ID '#{message_id}'" 837 | return 838 | end 839 | 840 | unless Corpus.is_mine?(message) 841 | Display.say "Message with ID '#{message_id}' belongs to someone else." 842 | Display.say "You can only delete or undelete your own messages!" 843 | return 844 | end 845 | 846 | message.delete 847 | 848 | title = message.truncated_message(Display::TITLE_WIDTH) 849 | Display.say 850 | if message.is_deleted 851 | Display.say "{r Deleted message '#{title}' }" 852 | else 853 | Display.say "{y Undeleted message '#{title}' }" 854 | end 855 | end 856 | 857 | def external_editor(preload_text = nil) 858 | tf = Tempfile.new('iris') 859 | 860 | if preload_text 861 | tf.write(preload_text) 862 | tf.flush 863 | end 864 | 865 | raise "No `$EDITOR` environment variable set!" unless Config::ENV_EDITOR 866 | 867 | system("#{Config::ENV_EDITOR} #{tf.path}") 868 | tf.rewind 869 | message_text = tf.read 870 | tf.unlink 871 | 872 | message_text 873 | end 874 | 875 | def handle(line) 876 | tokens = line.split(/\s/) 877 | cmd = tokens.first 878 | cmd = CMD_MAP[cmd] || cmd 879 | return self.send(cmd.to_sym) if ONE_SHOTS.include?(cmd) && tokens.length == 1 880 | return show_topic(cmd) if cmd =~ /^\d+$/ 881 | # If we've gotten this far, we must have args. Let's handle 'em. 882 | arg = tokens.last 883 | return reply(arg) if cmd == 'reply' 884 | return edit(arg) if cmd == 'edit' 885 | return delete(arg) if cmd == 'delete' 886 | return mark_read(arg) if cmd == 'mark_read' 887 | Display.say 'Unrecognized command. Type "help" for a list of available commands.' 888 | end 889 | 890 | def show_topic(num) 891 | index = num.to_i - 1 892 | # TODO: Paginate here 893 | if index >= 0 && index < Corpus.topics.length 894 | msg = Corpus.topics[index] 895 | 896 | Display.say msg.to_topic_display 897 | Display.say 898 | 899 | Corpus.mark_as_read([msg.hash] + msg.replies.map(&:hash)) 900 | else 901 | Display.say 'Could not find a topic with that ID' 902 | end 903 | end 904 | 905 | def quit 906 | exit(0) 907 | end 908 | 909 | def self.start(args) 910 | self.new(args) 911 | end 912 | 913 | def prompt 914 | "#{Config.author}~> " 915 | end 916 | 917 | def initialize(args) 918 | @history_loaded = false 919 | 920 | Display.say "Welcome to Iris v#{Config::VERSION}. Type 'help' for a list of commands; Ctrl-D or 'quit' to leave." 921 | unread 922 | 923 | while line = readline(prompt) do 924 | handle(line) 925 | end 926 | end 927 | 928 | def unread 929 | Display.say 930 | 931 | if Corpus.unread_topics.size == 0 932 | Display.say "{gvi You're all caught up! No new topics to read.}" 933 | return 934 | end 935 | 936 | Display.say Display.topic_header 937 | # TODO: Paginate here 938 | unread_hashes = Corpus.unread_topics_set 939 | Corpus.topics.each_with_index do |topic, index| 940 | if unread_hashes.include?(topic.hash) 941 | Display.say topic.to_topic_line(index + 1) 942 | end 943 | end 944 | Display.say 945 | end 946 | 947 | def topics 948 | Display.say 949 | Display.say Display.topic_header 950 | # TODO: Paginate here 951 | Corpus.topics.each_with_index do |topic, index| 952 | Display.say topic.to_topic_line(index + 1) 953 | end 954 | Display.say 955 | end 956 | 957 | def help 958 | Display.flowerbox( 959 | "Iris v#{Config::VERSION} readline interface", 960 | '', 961 | 'Commands', 962 | '========', 963 | 'READING', 964 | 'topics, t - List all topics', 965 | 'unread, u - List all topics with unread messages', 966 | '# (topic id) - Read specified topic', 967 | 'next, n - Read the next unread topic', 968 | 'mark_read #, m # - Mark the associated topic as read', 969 | 'mark_all_read - Mark all messages as read', 970 | 'help, h, ? - Display this text', 971 | '', 972 | 'WRITING', 973 | 'compose, c - Add a new topic', 974 | 'reply #, r # - Reply to a specific topic', 975 | 'edit #, e # - Edit a topic or message', 976 | 'delete #, d #, undelete # - Delete {u or undelete} a topic or message', 977 | '', 978 | 'SCREEN AND FILE UTILITIES', 979 | 'freshen, f - Reload to get any new messages', 980 | 'reset, clear - Fix screen in case of text corruption', 981 | 'info, i - Display Iris version and message stats', 982 | 'quit, q - Quit Iris', 983 | '', 984 | 'Full documentation available here:', 985 | 'https://github.com/Calamitous/iris/blob/master/README.md', 986 | box_character: '') 987 | end 988 | 989 | def freshen 990 | Corpus.load 991 | Display.say 'Reloaded!' 992 | unread 993 | end 994 | 995 | def readline(prompt) 996 | if !@history_loaded && File.exist?(Config::HISTORY_FILE) 997 | @history_loaded = true 998 | if File.readable?(Config::HISTORY_FILE) 999 | File.readlines(Config::HISTORY_FILE).each { |l| Readline::HISTORY.push(l.chomp) } 1000 | end 1001 | end 1002 | 1003 | if line = Readline.readline(prompt, true) 1004 | if File.writable?(Config::HISTORY_FILE) 1005 | File.open(Config::HISTORY_FILE) { |f| f.write(line+"\n") } 1006 | end 1007 | return line 1008 | else 1009 | return nil 1010 | end 1011 | end 1012 | end 1013 | 1014 | class CLI 1015 | def self.print_help 1016 | Display.flowerbox( 1017 | "Iris v#{Config::VERSION} command-line", 1018 | '', 1019 | 'Usage', 1020 | '========', 1021 | "#{Config::IRIS_SCRIPT} [options]", 1022 | '', 1023 | 'Options', 1024 | '========', 1025 | '--help, -h - Display this text.', 1026 | '--version, -v - Display the current version of Iris.', 1027 | '--stats, -s - Display Iris version and message stats.', 1028 | '--interactive, -i - Enter interactive mode (default)', 1029 | '--mark-all-read - Mark all messages as read.', 1030 | '--dump, -d - Dump entire message corpus out.', 1031 | '--test-file , -f - Use the specified test file for messages.', 1032 | '--debug - Print warnings and debug informtation during use.', 1033 | '', 1034 | 'If no options are provided, Iris will enter interactive mode.', 1035 | box_character: '') 1036 | end 1037 | 1038 | def self.start(args) 1039 | if (args & %w{-v --version}).any? 1040 | Display.say "Iris #{Config::VERSION}" 1041 | exit(0) 1042 | end 1043 | 1044 | if (args & %w{-h --help}).any? 1045 | print_help 1046 | exit(0) 1047 | end 1048 | 1049 | if (args & %w{-s --stats}).any? 1050 | Interface.info 1051 | exit(0) 1052 | end 1053 | 1054 | if (args & %w{-d --dump}).any? 1055 | puts Corpus.to_json 1056 | exit(0) 1057 | end 1058 | 1059 | if (args & %w{--mark-all-read}).any? 1060 | Interface.mark_all_read 1061 | exit(0) 1062 | end 1063 | 1064 | Display.say "Unrecognized option(s) #{args.join(', ')}" 1065 | Display.say "Try -h for help" 1066 | exit(1) 1067 | end 1068 | end 1069 | 1070 | class Startupper 1071 | INTERACTIVE_OPTIONS = %w[-i --interactive] 1072 | NONINTERACTIVE_OPTIONS = %w[-d --dump -h --help -v --version -s --stats --mark-all-read] 1073 | NONFILE_OPTIONS = %w[-h --help -v --version] 1074 | 1075 | def initialize(args) 1076 | Startupper.perform_file_checks unless NONFILE_OPTIONS.include?(args) 1077 | 1078 | load_corpus(args) 1079 | 1080 | is_interactive = (args & NONINTERACTIVE_OPTIONS).none? || (args & INTERACTIVE_OPTIONS).any? 1081 | 1082 | Config.enable_debug_mode if (args & %w{--debug}).any? 1083 | 1084 | if is_interactive 1085 | Interface.start(args) 1086 | else 1087 | CLI.start(args) 1088 | end 1089 | end 1090 | 1091 | def self.perform_file_checks 1092 | unless File.exists?(Config::MESSAGE_FILE) 1093 | Display.say "You don't have a message file at #{Config::MESSAGE_FILE}." 1094 | response = Readline.readline 'Would you like me to create it for you? (y/n) ', true 1095 | 1096 | if /[Yy]/ =~ response 1097 | IrisFile.create_message_file 1098 | else 1099 | Display.say 'Cannot run Iris without a message file!' 1100 | exit(1) 1101 | end 1102 | end 1103 | 1104 | IrisFile.create_read_file 1105 | 1106 | if File.stat(Config::MESSAGE_FILE).mode != 33188 1107 | Display.permissions_error(Config::MESSAGE_FILE, 'message', '-rw-r--r--', '644', "Leaving your file with incorrect permissions could allow unauthorized edits!") 1108 | end 1109 | 1110 | if File.stat(Config.readfile_filename).mode != 33188 1111 | Display.permissions_error(Config.readfile_filename, 'read', '-rw-r--r--', '644') 1112 | end 1113 | 1114 | if File.stat(Config::IRIS_SCRIPT).mode != 33261 1115 | Display.permissions_error(Config::IRIS_SCRIPT, 'Iris', '-rwxr-xr-x', '755', 'If this file has the wrong permissions the program may be tampered with!') 1116 | end 1117 | end 1118 | 1119 | def load_corpus(args) 1120 | if (args & %w{-f --test-file}).any? 1121 | filename_idx = (args.index('-f') || args.index('--test-file')) + 1 1122 | filename = args[filename_idx] 1123 | 1124 | unless filename 1125 | Display.say "Option `--test-file` (`-f`) expects a filename" 1126 | exit(1) 1127 | end 1128 | 1129 | unless File.exist?(filename) 1130 | Display.say "Could not load test file: #{filename}" 1131 | exit(1) 1132 | end 1133 | 1134 | Display.say "Using Iris with test file: #{filename}" 1135 | $test_corpus_file = filename 1136 | end 1137 | 1138 | Corpus.load 1139 | end 1140 | end 1141 | 1142 | Startupper.new(ARGV) if __FILE__==$0 1143 | 1144 | -------------------------------------------------------------------------------- /test_watch: -------------------------------------------------------------------------------- 1 | echo "Watching..." 2 | find . -name "*.rb" | entr sh -c 'clear; ruby iris_test.rb' 3 | 4 | # while inotifywait -r -e modify ./spec; do 5 | # SKIP_PRECOMPILE=TRUE SKIP_LINT=TRUE ./test.sh $1 6 | # done 7 | -------------------------------------------------------------------------------- /tests/iris.messages.json: -------------------------------------------------------------------------------- 1 | [ 2 | {"hash":"gpY2WW/jGcH+BODgySCwDANJlIM=\n","edit_hash":null,"is_deleted":null,"data":{"author":"calamitous@ctrl-c.club","parent":null,"timestamp":"2020-01-07T21:04:21Z","message":"Test"}}, 3 | {"hash":"qubS6AvNXgCJj/4ClFocuJ16SBk=\n","edit_hash":null,"is_deleted":null,"data":{"author":"calamitous@ctrl-c.club","parent":"gpY2WW/jGcH+BODgySCwDANJlIM=\n","timestamp":"2020-01-07T21:32:30Z","message":"Wat"}}, 4 | {"hash":"evxWlSJYeJjYmlVJ5uo++PWwph4=\n","edit_hash":null,"is_deleted":null,"data":{"author":"calamitous@ctrl-c.club","parent":null,"timestamp":"2020-02-08T20:45:11Z","message":"{m This title has a hanging color tag\n\nThat is all."}}, 5 | {"hash":"oRaGp5rHoJYqyYRWZpIN7uvjmKc=\n","edit_hash":null,"is_deleted":null,"data":{"author":"calamitous@ctrl-c.club","parent":"evxWlSJYeJjYmlVJ5uo++PWwph4=\n","timestamp":"2020-02-08T20:46:49Z","message":"Well, that could mess things up."}} 6 | ] -------------------------------------------------------------------------------- /tests/iris.messages.json.read: -------------------------------------------------------------------------------- 1 | ["evxWlSJYeJjYmlVJ5uo++PWwph4=\n","gpY2WW/jGcH+BODgySCwDANJlIM=\n","oRaGp5rHoJYqyYRWZpIN7uvjmKc=\n","qubS6AvNXgCJj/4ClFocuJ16SBk=\n"] -------------------------------------------------------------------------------- /tests/iris_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'mocha/minitest' 3 | 4 | # This allows the test to pretend that user "jerryberry" is logged in. 5 | ENV['USER'] = 'jerryberry' 6 | 7 | ENV['EDITOR'] = 'foo/bar' 8 | 9 | # Set this before loading the code so that the Config constants load correctly. 10 | $test_corpus_file = "./tests/iris.messages.json" 11 | 12 | require './iris.rb' 13 | 14 | describe Config do 15 | it 'has the Iris semantic version number' do 16 | _(Config::VERSION).must_match /^\d\.\d\.\d+$/ 17 | end 18 | 19 | it 'has the message file location' do 20 | _(Config::MESSAGE_FILE).must_match /\/\.iris\.messages$/ 21 | end 22 | 23 | it 'has the readline history file location' do 24 | _(Config::HISTORY_FILE).must_match /\/\.iris\.history$/ 25 | end 26 | 27 | describe '.hostname' do 28 | before do 29 | Config.instance_variable_set(:@hostname, nil) 30 | end 31 | 32 | it 'has a hostname' do 33 | _(Config.hostname).wont_be_nil 34 | end 35 | 36 | it 'correctly interprets an empty string' do 37 | Config.expects(:`).with('hostname').returns('') 38 | _(Config.hostname).must_equal 'localhost' 39 | end 40 | 41 | it 'correctly interprets localhost' do 42 | Config.instance_variable_set(:@hostname, nil) 43 | Config.expects(:`).with('hostname').returns('localhost') 44 | _(Config.hostname).must_equal 'localhost' 45 | end 46 | 47 | it 'correctly interprets a single word' do 48 | Config.instance_variable_set(:@hostname, nil) 49 | Config.expects(:`).with('hostname').returns('example') 50 | _(Config.hostname).must_equal 'example' 51 | end 52 | 53 | it 'correctly interprets a subdomain' do 54 | Config.instance_variable_set(:@hostname, nil) 55 | Config.expects(:`).with('hostname').returns('example.com') 56 | _(Config.hostname).must_equal 'example.com' 57 | end 58 | 59 | it 'correctly interprets a subsubdomain' do 60 | Config.instance_variable_set(:@hostname, nil) 61 | Config.expects(:`).with('hostname').returns('foo.example.com') 62 | _(Config.hostname).must_equal 'example.com' 63 | end 64 | 65 | it 'correctly interprets an arbitrary number of subdomains' do 66 | Config.instance_variable_set(:@hostname, nil) 67 | Config.expects(:`).with('hostname').returns('foo.bar.baz.quux.example.com') 68 | _(Config.hostname).must_equal 'example.com' 69 | end 70 | end 71 | 72 | it 'has the author' do 73 | user = 'jerryberry' 74 | _(Config.author).must_equal "#{user}@#{Config.hostname}" 75 | end 76 | 77 | it 'has the $EDITOR environment variable' do 78 | _(Config::ENV_EDITOR).must_equal 'foo/bar' 79 | end 80 | 81 | describe '.find_files' do 82 | it 'looks up all the Iris message files on the system' do 83 | # I am so sorry about this `expects` clause 84 | Config.expects(:`).with('ls /home/**/.iris.messages').returns('') 85 | Config.find_files 86 | end 87 | 88 | it 'returns a list of Iris message files' do 89 | Config.stubs(:`).returns("foo\nbar\n") 90 | _(Config.find_files).must_equal ['foo', 'bar'] 91 | end 92 | 93 | it 'returns an empty array if no Iris message files are found' do 94 | Config.stubs(:`).returns('') 95 | _(Config.find_files).must_equal [] 96 | end 97 | end 98 | end 99 | 100 | describe Corpus do 101 | before do 102 | Corpus.load 103 | end 104 | 105 | describe '.load' do 106 | it 'loads all the message files' 107 | it 'sets the corpus class variable' 108 | it 'sets the topics class variable' 109 | it 'creates a hash index' 110 | it 'creates parent-hash-to-child-indexes index' 111 | end 112 | 113 | describe '.topics' do 114 | it 'returns all the messages which are topics' 115 | it 'does not return reply messages' 116 | end 117 | 118 | describe '.mine' do 119 | it 'returns all messages composed by the current user' 120 | it 'does not return any messages not composed by the current user' 121 | end 122 | 123 | describe '.find_message_by_hash' do 124 | it 'returns nil if a nil is passed in' do 125 | assert_nil Corpus.find_message_by_hash(nil) 126 | end 127 | 128 | it 'returns nil if the hash is not found in the corpus' do 129 | assert_nil Corpus.find_message_by_hash('NoofMcGoof') 130 | end 131 | 132 | it 'returns the message associated with the hash if it is found' do 133 | message = Corpus.find_message_by_hash("gpY2WW/jGcH+BODgySCwDANJlIM=\n") 134 | _(message.message).must_equal "Test" 135 | end 136 | end 137 | 138 | describe '.find_all_by_parent_hash' do 139 | it 'returns an empty array if a nil is passed in' do 140 | _(Corpus.find_all_by_parent_hash(nil)).must_equal [] 141 | end 142 | 143 | it 'returns an empty array if the hash is not a parent of any other messages' do 144 | _(Corpus.find_all_by_parent_hash(nil)).must_equal [] 145 | end 146 | 147 | it 'returns an empty array if the hash is not found in the corpus' do 148 | _(Corpus.find_all_by_parent_hash('GoofMcDoof')).must_equal [] 149 | end 150 | 151 | it 'returns the messages associated with the parent hash' 152 | end 153 | 154 | describe '.find_topic_by_id' do 155 | it 'returns nil if a nil is passed in' do 156 | assert_nil Corpus.find_topic_by_id(nil) 157 | end 158 | 159 | describe 'when an index string is passed in' do 160 | it 'returns nil if the topic is not found' do 161 | assert_nil Corpus.find_topic_by_id('InvalidTopicId') 162 | end 163 | 164 | it 'returns the associated topic' do 165 | _(Corpus.find_topic_by_id(1).message).must_equal 'Test' 166 | end 167 | end 168 | end 169 | 170 | describe '.find_topic_by_hash' do 171 | it 'returns nil if a nil is passed in' do 172 | assert_nil Corpus.find_topic_by_hash(nil) 173 | end 174 | 175 | describe 'when a hash string is passed in' do 176 | it 'returns nil if the topic is not found' do 177 | assert_nil Corpus.find_topic_by_hash('BadHash') 178 | end 179 | 180 | it 'returns the associated topic' do 181 | _(Corpus.find_topic_by_hash("gpY2WW/jGcH+BODgySCwDANJlIM=\n").message).must_equal 'Test' 182 | end 183 | end 184 | end 185 | end 186 | 187 | describe IrisFile do 188 | describe '.load_messages' do; end 189 | describe '.create_message_file' do; end 190 | end 191 | 192 | describe Message do 193 | it 'exposes all its data attributes for reading' 194 | 195 | it 'is #valid? if it has no errors' 196 | it 'is #topic? if it has no parent' 197 | 198 | describe 'creation' do; end 199 | describe 'validation' do; end 200 | 201 | describe '#save!' do 202 | it 'adds itself to the user\'s corpus' 203 | it 'writes out the user\'s message file' 204 | it 'reloads all message files' 205 | end 206 | 207 | describe '#hash' do; end 208 | describe '#truncated_message' do; end 209 | describe '#to_topic_line' do; end 210 | describe '#to_display' do; end 211 | describe '#to_topic_display' do; end 212 | describe '#to_json' do; end 213 | end 214 | 215 | describe Display do 216 | it 'has a setting for a minimum width of 80' do 217 | _(Display::MIN_WIDTH).must_equal 80 218 | end 219 | 220 | it 'has a setting for a minimum height of 8' do 221 | _(Display::MIN_HEIGHT).must_equal 8 222 | end 223 | 224 | it 'has settings for the calculated screen geometry' do 225 | _(Display::WIDTH).wont_equal nil 226 | _(Display::HEIGHT).wont_equal nil 227 | end 228 | 229 | describe '#topic_index_width' do 230 | it 'returns the a minimun length of 2' do 231 | Corpus.stubs(:topics).returns(%w{a}) 232 | _(Display.topic_index_width).must_equal 2 233 | end 234 | 235 | it 'returns the length in characters of the longest topic index' do 236 | Corpus.stubs(:topics).returns((0..1000).to_a) 237 | _(Display.topic_index_width).must_equal 4 238 | end 239 | 240 | it 'returns 2 if there are no topics' do 241 | Corpus.stubs(:topics).returns([]) 242 | _(Display.topic_index_width).must_equal 2 243 | end 244 | end 245 | 246 | describe '#topic_author_width' do 247 | it 'returns the length in characters of the longest author\'s name' do 248 | Corpus.stubs(:authors).returns(['jerryberry@ctrl-c.club']) 249 | _(Display.topic_author_width).must_equal 22 250 | end 251 | 252 | it 'returns 1 if there are no topics' do 253 | Corpus.stubs(:authors).returns([]) 254 | _(Display.topic_author_width).must_equal 1 255 | end 256 | end 257 | 258 | describe '.print_index' do; end 259 | describe '.print_author' do; end 260 | end 261 | 262 | describe Interface do 263 | it 'has a map of all single-word commands' 264 | it 'has a map of all shortcuts and commands' 265 | 266 | describe '#start' do; end 267 | describe 'creation' do; end 268 | 269 | describe '#reset_display' do; end 270 | describe '#reply' do; end 271 | describe '#show_topic' do; end 272 | describe '#quit' do; end 273 | describe '.start' do; end 274 | describe '#compose' do; end 275 | describe '#topics' do; end 276 | describe '#help' do; end 277 | describe '#freshen' do; end 278 | describe '#readline (maybe?)' do; end 279 | end 280 | 281 | describe CLI do 282 | describe '#start' do; end 283 | describe 'creation' do; end 284 | describe '--version or -v' do; end 285 | describe '--stats or -s' do; end 286 | describe '--help or -h' do; end 287 | describe 'junk parameters' do; end 288 | end 289 | 290 | describe Startupper do 291 | describe 'creation' do 292 | let(:message_file_path) { 'jerryberry/.iris.messages' } 293 | let(:read_file_path) { 'jerryberry/.iris.read' } 294 | let(:data_file_stat) { a = mock; a.stubs(:mode).returns(33188); a } 295 | let(:script_file_stat) { a = mock; a.stubs(:mode).returns(33261); a } 296 | let(:bad_file_stat) { a = mock; a.stubs(:mode).returns(2); a } 297 | 298 | before do 299 | Config.stubs(:find_files).returns([]) 300 | IrisFile.stubs(:load_messages).returns([]) 301 | IrisFile.stubs(:load_reads).returns([]) 302 | 303 | Config.send(:remove_const, 'MESSAGE_FILE') if Config.const_defined? 'MESSAGE_FILE' 304 | Config.send(:remove_const, 'READ_FILE') if Config.const_defined? 'READ_FILE' 305 | Config.send(:remove_const, 'IRIS_SCRIPT') if Config.const_defined? 'IRIS_SCRIPT' 306 | Config::MESSAGE_FILE = message_file_path 307 | Config::READ_FILE = read_file_path 308 | Config.stubs(:messagefile_filename).returns(message_file_path) 309 | Config.stubs(:readfile_filename).returns(read_file_path) 310 | Config::IRIS_SCRIPT = 'doots' 311 | 312 | File.stubs(:exists?).returns(true) 313 | 314 | File.stubs(:stat).with(Config::IRIS_SCRIPT).returns(script_file_stat) 315 | File.stubs(:stat).with(message_file_path).returns(data_file_stat) 316 | File.stubs(:stat).with(read_file_path).returns(data_file_stat) 317 | 318 | Interface.stubs(:start) 319 | end 320 | 321 | it 'starts the Interface if no command-line arguments are provided' do 322 | Interface.expects(:start).with([]) 323 | Startupper.new([]) 324 | end 325 | 326 | it 'starts the Interface if "-i" is provided at the command-line' do 327 | Interface.expects(:start).with(['-i']) 328 | Startupper.new(['-i']) 329 | end 330 | 331 | it 'starts the Interface if "--interactive" is provided at the command-line' do 332 | Interface.expects(:start).with(['--interactive']) 333 | Startupper.new(['--interactive']) 334 | end 335 | 336 | it 'starts the CLI if any non-interactive parameters are provided at the command-line' do 337 | CLI.expects(:start).with(['-h']) 338 | Startupper.new(['-h']) 339 | end 340 | 341 | it 'offers to create a message file if the user doesn\'t have one' do 342 | File.stubs(:exists?).with(message_file_path).returns(false) 343 | Display.stubs(:say) 344 | Readline.expects(:readline).with('Would you like me to create it for you? (y/n) ', true).returns('y') 345 | IrisFile.expects(:create_message_file) 346 | 347 | Startupper.new([]) 348 | end 349 | 350 | it 'creates a read file if the user doesn\'t have one' do 351 | File.stubs(:exists?).with(read_file_path).returns(false) 352 | IrisFile.expects(:create_read_file) 353 | 354 | Startupper.new([]) 355 | end 356 | 357 | it 'warns the user if the message file permissions are wrong' do 358 | File.expects(:stat).with(message_file_path).returns(bad_file_stat) 359 | Display.stubs(:say) 360 | message_lines = [ 361 | "Your message file has incorrect permissions! Should be \"-rw-r--r--\".", 362 | "You can change this from the command line with:", 363 | " chmod 644 jerryberry/.iris.messages", 364 | "Leaving your file with incorrect permissions could allow unauthorized edits!" 365 | ] 366 | Display.expects(:say).with(message_lines) 367 | 368 | Startupper.new([]) 369 | end 370 | 371 | it 'warns the user if the read file permissions are wrong' do 372 | File.stubs(:stat).with(read_file_path).returns(bad_file_stat) 373 | Display.stubs(:say) 374 | message_lines = [ 375 | "Your read file has incorrect permissions! Should be \"-rw-r--r--\".", 376 | "You can change this from the command line with:", 377 | " chmod 644 jerryberry/.iris.read" 378 | ] 379 | Display.expects(:say).with(message_lines) 380 | 381 | Startupper.new([]) 382 | end 383 | 384 | it 'warns the user if the script file permissions are wrong' do 385 | File.expects(:stat).with(Config::IRIS_SCRIPT).returns(bad_file_stat) 386 | Display.stubs(:say) 387 | message_lines = [ 388 | "Your Iris file has incorrect permissions! Should be \"-rwxr-xr-x\".", 389 | "You can change this from the command line with:", 390 | " chmod 755 doots", "If this file has the wrong permissions the program may be tampered with!" 391 | ] 392 | Display.expects(:say).with(message_lines) 393 | 394 | Startupper.new([]) 395 | end 396 | end 397 | end 398 | 399 | describe 'String#colorize' do 400 | let(:color_strings) { 401 | " 402 | RED {r normal}\t{ri intense}\t{ru underline}\t{riu intense underline} 403 | {rv reverse}\t{riv intense}\t{ruv underline}\t{riuv intense underline} 404 | GREEN {g normal}\t{gi intense}\t{ug underline}\t{uig intense underline} 405 | {gv reverse}\t{giv intense}\t{ugv underline}\t{uigv intense underline} 406 | YELLOW {y normal}\t{yi intense}\t{yu underline}\t{yiu intense underline} 407 | {yv reverse}\t{yiv intense}\t{yuv underline}\t{yiuv intense underline} 408 | BLUE {b normal}\t{bi intense}\t{bu underline}\t{biu intense underline} 409 | {bv reverse}\t{biv intense}\t{buv underline}\t{biuv intense underline} 410 | MAGENTA {m normal}\t{mi intense}\t{mu underline}\t{miu intense underline} 411 | {mv reverse}\t{miv intense}\t{muv underline}\t{miuv intense underline} 412 | CYAN {c normal}\t{ci intense}\t{cu underline}\t{ciu intense underline} 413 | {cv reverse}\t{civ intense}\t{cuv underline}\t{ciuv intense underline} 414 | WHITE {w normal}\t{wi intense}\t{wu underline}\t{wiu intense underline} 415 | {wv reverse}\t{wiv intense}\t{wuv underline}\t{wiuv intense underline} 416 | ".split("\n")[1..-2] 417 | } 418 | 419 | it 'produces the expected output' do 420 | lead = "\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m " 421 | lines = [ 422 | "RED \e[31mnormal\e[0m\t\e[1;31mintense\e[0m\t\e[31;4munderline\e[0m\t\e[1;31;4mintense underline\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m", 423 | " \e[31;7mreverse\e[0m\t\e[1;31;7mintense\e[0m\t\e[31;4;7munderline\e[0m\t\e[1;31;4;7mintense underline\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m", 424 | "GREEN \e[32mnormal\e[0m\t\e[1;32mintense\e[0m\t\e[32;4munderline\e[0m\t\e[1;32;4mintense underline\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m", 425 | " \e[32;7mreverse\e[0m\t\e[1;32;7mintense\e[0m\t\e[32;4;7munderline\e[0m\t\e[1;32;4;7mintense underline\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m", 426 | "YELLOW \e[33mnormal\e[0m\t\e[1;33mintense\e[0m\t\e[33;4munderline\e[0m\t\e[1;33;4mintense underline\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m", 427 | " \e[33;7mreverse\e[0m\t\e[1;33;7mintense\e[0m\t\e[33;4;7munderline\e[0m\t\e[1;33;4;7mintense underline\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m", 428 | "BLUE \e[34mnormal\e[0m\t\e[1;34mintense\e[0m\t\e[34;4munderline\e[0m\t\e[1;34;4mintense underline\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m", 429 | " \e[34;7mreverse\e[0m\t\e[1;34;7mintense\e[0m\t\e[34;4;7munderline\e[0m\t\e[1;34;4;7mintense underline\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m", 430 | "MAGENTA \e[35mnormal\e[0m\t\e[1;35mintense\e[0m\t\e[35;4munderline\e[0m\t\e[1;35;4mintense underline\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m", 431 | " \e[35;7mreverse\e[0m\t\e[1;35;7mintense\e[0m\t\e[35;4;7munderline\e[0m\t\e[1;35;4;7mintense underline\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m", 432 | "CYAN \e[36mnormal\e[0m\t\e[1;36mintense\e[0m\t\e[36;4munderline\e[0m\t\e[1;36;4mintense underline\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m", 433 | " \e[36;7mreverse\e[0m\t\e[1;36;7mintense\e[0m\t\e[36;4;7munderline\e[0m\t\e[1;36;4;7mintense underline\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m", 434 | "WHITE \e[37mnormal\e[0m\t\e[1;37mintense\e[0m\t\e[37;4munderline\e[0m\t\e[1;37;4mintense underline\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m", 435 | " \e[37;7mreverse\e[0m\t\e[1;37;7mintense\e[0m\t\e[37;4;7munderline\e[0m\t\e[1;37;4;7mintense underline\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m\e[0m", 436 | ] 437 | 438 | _(color_strings[0].colorize).must_equal lead + lines[0] 439 | _(color_strings[1].colorize).must_equal lead + lines[1] 440 | _(color_strings[2].colorize).must_equal lead + lines[2] 441 | _(color_strings[3].colorize).must_equal lead + lines[3] 442 | _(color_strings[4].colorize).must_equal lead + lines[4] 443 | _(color_strings[5].colorize).must_equal lead + lines[5] 444 | _(color_strings[6].colorize).must_equal lead + lines[6] 445 | _(color_strings[7].colorize).must_equal lead + lines[7] 446 | _(color_strings[8].colorize).must_equal lead + lines[8] 447 | _(color_strings[9].colorize).must_equal lead + lines[9] 448 | _(color_strings[10].colorize).must_equal lead + lines[10] 449 | _(color_strings[11].colorize).must_equal lead + lines[11] 450 | _(color_strings[12].colorize).must_equal lead + lines[12] 451 | _(color_strings[13].colorize).must_equal lead + lines[13] 452 | end 453 | 454 | it 'returns an empty string wrapped with resets when provided an empty string' do 455 | _(''.colorize).must_equal "\e[0m\e[0m" 456 | end 457 | 458 | it 'allows curly brackets to be escaped' do 459 | _('I want \{no color\}'.colorize).must_equal "\e[0m\e[0mI want {no color}\e[0m\e[0m\e[0m" 460 | end 461 | end 462 | 463 | describe 'String#decolorize' do 464 | it 'returns the string with the coloring tags stripped' do 465 | _("{b colorful}".decolorize).must_equal "colorful" 466 | end 467 | 468 | it 'allows curly brackets to be escaped' do 469 | _('I want \{no color\}'.decolorize).must_equal "I want {no color}" 470 | end 471 | end 472 | --------------------------------------------------------------------------------