diff --git a/README.md b/README.md index 751a9e2..d2634f5 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Iris has a readline interface that can be used to navigate the message corpus. ### Readline Interface Example ``` %> iris -Welcome to Iris v. 1.0.10. Type "help" for a list of commands.; Ctrl-D or 'quit' to leave. +Welcome to Iris v. 1.0.11. Type "help" for a list of commands.; Ctrl-D or 'quit' to leave. | ID | U | TIMESTAMP | AUTHOR | TITLE | 1 | | 2018-01-24T05:49:53Z | jimmy_foo@ctrl-c.club | Welcome! @@ -308,7 +308,7 @@ This outputs the current version of Iris, along with messsage, topic, and author ``` jennie_minnie@ctrl-c.club~> info -Iris 1.0.10 +Iris 1.0.11 13 topics, 0 unread. 50 messages, 0 unread. 10 authors. @@ -341,7 +341,7 @@ iris --version ``` ``` -Iris 1.0.10 +Iris 1.0.11 ``` --- @@ -357,7 +357,7 @@ iris --stats ``` ``` -Iris 1.0.10 +Iris 1.0.11 13 topics, 0 unread. 50 messages, 0 unread. 10 authors. @@ -546,6 +546,7 @@ The one place we're breaking the rules on requiring gems is in the tests. Mocha ## Technical Bits + * [Dependencies](#dependencies) * [Conventions](#conventions) * [Message Files](#message-files) * [Messages](#messages) @@ -556,6 +557,14 @@ The one place we're breaking the rules on requiring gems is in the tests. Mocha * [Topic List](#topic-list) * [Replies](#replies) +### Dependencies + +While trying to stay reasonably lightweight, Iris does dependend on a few tools being installed: + +* `ls` is used to get a list of all the Iris message files on the system. +* `hostname` is used to find the name of the server Iris is running on. +* `tput` is used to get the terminal reset command. + ### Conventions Iris leans heavily on convention. Iris' security and message authentication is provided by filesystem permissions and message hashing. diff --git a/TODO.md b/TODO.md index 79bfb6d..ccf8070 100644 --- a/TODO.md +++ b/TODO.md @@ -18,13 +18,14 @@ * Gracefully handle attempt to "r 1 message" ### Features -* Add pagiation/less for long message lists +* Add pagination/less for long message lists * https://github.com/Calamitous/iris/issues/1 * Allow shelling out to editor for message editing * https://github.com/Calamitous/iris/issues/2 * Add local timezone rendering * Add "Mark all read" option * Add option to mark all messages in a thread as read +* CLI option to show response count to threads the user authored ### Tech debt * Flesh out tests @@ -68,6 +69,16 @@ # Changelog +## 1.0.11 +* Speed up the topic listing significantly +* Add 'unread' (short form 'u') to only list topics with unread messages +* Add 'mark_unread' (short form 'm') to mark topics as read without displaying them +* Tweaks to help text +* Default main listing to unread topics instead of listing all topics +* Updates to the way screen dimensions are calculated +* Preliminaary work to support pagination +* Change permissions message from error to warning so it only shows in debug mode + ## 1.0.10 * ~Fix bug causing system to crash when a user removes read permissions from their directory/iris.messages file~ diff --git a/iris.rb b/iris.rb index 37eb3e5..79f2535 100755 --- a/iris.rb +++ b/iris.rb @@ -8,7 +8,7 @@ require 'time' # require 'pry' # Only needed for debugging class Config - VERSION = '1.0.10' + VERSION = '1.0.11' MESSAGE_FILE = "#{ENV['HOME']}/.iris.messages" HISTORY_FILE = "#{ENV['HOME']}/.iris.history" IRIS_SCRIPT = __FILE__ @@ -135,9 +135,14 @@ class Corpus end @@my_corpus = IrisFile.load_messages.sort_by(&:timestamp) - @@topics = @@corpus.select{ |m| m.parent == nil && m.show_me? } @@my_reads = IrisFile.load_reads + + @@unread_messages = nil + @@all_hash_to_index = @@corpus.reduce({}) { |agg, msg| agg[msg.hash] = @@corpus.index(msg); agg } + @@edited_hashes = @@corpus.map(&:edit_hash).compact + @@topics = @@corpus.select(&:is_topic?) + @@all_parent_hash_to_index = @@corpus.reduce({}) do |agg, msg| agg[msg.parent] ||= [] agg[msg.parent] << @@corpus.index(msg) @@ -153,8 +158,8 @@ class Corpus @@corpus end - def self.displayable - @@corpus.select { |message| message.show_me? } + def self.edited_hashes + @@edited_hashes end def self.topics @@ -188,9 +193,9 @@ class Corpus all[index] end - def self.find_message_by_edit_hash(hash) + def self.has_edit_hash(hash) return nil unless hash - Corpus.all.detect { |message| message.edit_hash == hash } + Corpus.all.map(&:edit_hash).include?(hash) end def self.find_all_by_parent_hash(hash) @@ -222,11 +227,21 @@ class Corpus end def self.unread_messages - displayable.reject{ |m| @@my_reads.include? m.hash } + @@unread_messages ||= @@corpus + .select { |message| message.show_me? } + .reject{ |m| @@my_reads.include? m.hash } + end + + def self.unread_message_hashes + self.unread_messages.map(&:hash) end def self.unread_topics - @@topics.reject{ |m| @@my_reads.include? m.hash } + @@topics.select do |m| + # Is the topic unread, or are any of its displayable replies unread? + m.unread? || + find_all_by_parent_hash(m.hash).reduce(false) { |agg, r| agg || r.unread? } + end end def self.size @@ -256,7 +271,7 @@ class IrisFile return [] end rescue Errno::EACCES => e - Display.say " * Unable to read data from #{filepath}, permission denied. Skipping..." + Display.warn " * Unable to read data from #{filepath}, permission denied. Skipping..." return [] end @@ -371,6 +386,10 @@ class Message Message.new(new_text, old_message.parent, old_message.author, old_message.hash, old_message.timestamp).save! end + def is_topic? + parent.nil? && show_me? + end + def delete @is_deleted = !@is_deleted replace! @@ -380,8 +399,9 @@ class Message !(edit_hash.nil? || edit_hash.empty?) end + # Only show messages that don't have a following, edited message def show_me? - !Corpus.find_message_by_edit_hash(hash) + !Corpus.edited_hashes.include?(hash) end def validate_user(username) @@ -444,7 +464,7 @@ class Message def truncated_message(length) stub = message.split("\n").first return stub.colorize if stub.decolorize.length <= length - # colorize the stub, then decolorize to strip out any partial tags + # Colorize the stub, then decolorize to strip out any partial tags stub.colorize.slice(0, length - 5 - Display.topic_index_width).decolorize + '...' end @@ -479,7 +499,7 @@ class Message header_bar = (indent_text + message_header + ('-' * (Display::WIDTH))) header_offset = header_bar.length - header_bar.decolorize.length - header_bar = header_bar[0..Display::WIDTH+header_offset-1] + header_bar = header_bar[0..Display::WIDTH + header_offset - 1] bar = indent_text + ('-' * (Display::WIDTH - indent_text.decolorize.length)) @@ -518,6 +538,8 @@ class Message Corpus.find_message_by_hash(edit_hash) end + # Find all messages replying to the current topic, including replies to topics + # which have been edited. def replies (Corpus.find_all_by_parent_hash(hash) + ((edit_predecessor && edit_predecessor.replies) || [])).compact end @@ -562,7 +584,14 @@ end class Display MIN_WIDTH = 80 + MIN_HEIGHT = 8 + WIDTH = [ENV['COLUMNS'].to_i, `tput cols`.chomp.to_i, MIN_WIDTH].compact.max + HEIGHT = [ENV['ROWS'].to_i, `tput lines`.chomp.to_i, MIN_HEIGHT].compact.max + + # p Readline.get_screen_size + # WIDTH = Readline.get_screen_size[1] + TITLE_WIDTH = WIDTH - 26 def self.permissions_error(filename, file_description, permission_string, mode_string, consequence = nil) message = [ @@ -614,10 +643,14 @@ class Display end class Interface - ONE_SHOTS = %w{help topics compose quit freshen reset_display reply edit delete info} + ONE_SHOTS = %w{help topics unread compose quit freshen reset_display reply edit delete mark_read info} CMD_MAP = { 't' => 'topics', 'topics' => 'topics', + 'u' => 'unread', + 'unread' => 'unread', + 'm' => 'mark_read', + 'mark' => 'mark_read', 'c' => 'compose', 'compose' => 'compose', 'h' => 'help', @@ -651,6 +684,7 @@ class Interface return reply(arg) if cmd == 'reply' return edit(arg) if cmd == 'edit' return delete(arg) if cmd == 'delete' + return mark_read(arg) if cmd == 'mark_read' Display.say 'Unrecognized command. Type "help" for a list of available commands.' end @@ -700,7 +734,7 @@ class Interface @mode = :replying @text_buffer = '' - title = Corpus.find_topic_by_hash(parent.hash).truncated_message(Display::WIDTH - 26) + title = Corpus.find_topic_by_hash(parent.hash).truncated_message(Display::TITLE_WIDTH) Display.say Display.say "Writing a reply to topic '#{title}'" Display.say 'Type a period on a line by itself to end message.' @@ -731,12 +765,33 @@ class Interface @mode = :editing @old_message = message @text_buffer = '' - title = message.truncated_message(Display::WIDTH - 26) + title = message.truncated_message(Display::TITLE_WIDTH) Display.say Display.say "Editing message '#{title}'" Display.say 'Type a period on a line by itself to end message.' end + def mark_read(message_id = nil) + unless message_id + Display.say "I'm not a nihilist; I can't do something with nothing! Include a message ID to mark as read." + return + end + + message = + Corpus.find_message_by_hash(message_id) || + Corpus.find_message_by_id(message_id) || + Corpus.find_topic_by_id(message_id) + + unless message + Display.say "Could not mark as read; unable to find a message with ID '#{message_id}'" + return + end + + new_reads = (Corpus.read_hashes + [message.hash] + message.replies.map(&:hash)).uniq.sort + IrisFile.write_read_file(new_reads.to_json) + Corpus.load + end + def delete(message_id = nil) unless message_id Display.say "I'm not a nihilist; I can't do something with nothing! Include a message ID to delete or undelete." @@ -761,7 +816,7 @@ class Interface message.delete - title = message.truncated_message(Display::WIDTH - 26) + title = message.truncated_message(Display::TITLE_WIDTH) Display.say if message.is_deleted Display.say "{r Deleted message '#{title}' }" @@ -838,6 +893,7 @@ class Interface def show_topic(num) index = num.to_i - 1 + # TODO: Paginate here if index >= 0 && index < Corpus.topics.length msg = Corpus.topics[index] @reply_topic = msg.hash @@ -873,16 +929,35 @@ class Interface @mode = :browsing Display.say "Welcome to Iris v#{Config::VERSION}. Type 'help' for a list of commands; Ctrl-D or 'quit' to leave." - topics + unread while line = readline(prompt) do handle(line) end end + def unread + Display.say + + if Corpus.unread_topics.size == 0 + Display.say "{gvi You're all caught up! No new topics to read.}" + return + end + + Display.say Display.topic_header + # TODO: Paginate here + Corpus.topics.each_with_index do |topic, index| + if Corpus.unread_topics.include?(topic) + Display.say topic.to_topic_line(index + 1) + end + end + Display.say + end + def topics Display.say Display.say Display.topic_header + # TODO: Paginate here Corpus.topics.each_with_index do |topic, index| Display.say topic.to_topic_line(index + 1) end @@ -896,20 +971,22 @@ class Interface 'Commands', '========', 'READING', - 'topics, t - List all topics', - '# (topic id) - Read specified topic', - 'help, h, ? - Display this text', + 'topics, t - List all topics', + 'unread, u - List all topics with unread messages', + '# (topic id) - Read specified topic', + 'mark_read #, m # - Mark the associated topic as read', + 'help, h, ? - Display this text', '', 'WRITING', - 'compose, c - Add a new topic', - 'reply #, r # - Reply to a specific topic', - 'edit #, e # - Edit a topic or message', + 'compose, c - Add a new topic', + 'reply #, r # - Reply to a specific topic', + 'edit #, e # - Edit a topic or message', 'delete #, d #, undelete # - Delete {u or undelete} a topic or message', '', 'SCREEN AND FILE UTILITIES', - 'freshen, f - Reload to get any new messages', - 'reset, clear - Fix screen in case of text corruption', - 'info, i - Display Iris version and message stats', + 'freshen, f - Reload to get any new messages', + 'reset, clear - Fix screen in case of text corruption', + 'info, i - Display Iris version and message stats', '', 'Full documentation available here:', 'https://github.com/Calamitous/iris/blob/master/README.md', diff --git a/tests/iris_test.rb b/tests/iris_test.rb index 13f9288..3725ac5 100644 --- a/tests/iris_test.rb +++ b/tests/iris_test.rb @@ -1,6 +1,7 @@ require 'minitest/autorun' require 'mocha/mini_test' +$test_corpus_file = "./tests/iris.messages.json" # Setting this before loading the main code file so that the Config contants # load correctly. This will allows the test to pretend that user "jerryberry" # is logged in. @@ -10,7 +11,7 @@ require './iris.rb' describe Config do it 'has the Iris semantic version number' do - Config::VERSION.must_match /^\d\.\d\.\d$/ + Config::VERSION.must_match /^\d\.\d\.\d+$/ end it 'has the message file location' do @@ -81,11 +82,14 @@ describe Corpus do end it 'returns nil if the hash is not found in the corpus' do - skip + # Corpus.load Corpus.find_message_by_hash('NoofMcGoof').must_equal nil end - it 'returns the message associated with the hash if it is found' + it 'returns the message associated with the hash if it is found' do + Corpus.load + Corpus.find_message_by_hash("gpY2WW/jGcH+BODgySCwDANJlIM=").must_equal "Test" + end end describe '.find_all_by_parent_hash' do