Features and fixes for 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
This commit is contained in:
Eric B. Budd 2020-11-26 19:57:50 -05:00
parent ff6aedd008
commit bcf74fb8b4
4 changed files with 135 additions and 34 deletions

View File

@ -35,7 +35,7 @@ Iris has a readline interface that can be used to navigate the message corpus.
### Readline Interface Example ### Readline Interface Example
``` ```
%> iris %> 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 | ID | U | TIMESTAMP | AUTHOR | TITLE
| 1 | | 2018-01-24T05:49:53Z | jimmy_foo@ctrl-c.club | Welcome! | 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 jennie_minnie@ctrl-c.club~> info
Iris 1.0.10 Iris 1.0.11
13 topics, 0 unread. 13 topics, 0 unread.
50 messages, 0 unread. 50 messages, 0 unread.
10 authors. 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. 13 topics, 0 unread.
50 messages, 0 unread. 50 messages, 0 unread.
10 authors. 10 authors.
@ -546,6 +546,7 @@ The one place we're breaking the rules on requiring gems is in the tests. Mocha
## Technical Bits ## Technical Bits
* [Dependencies](#dependencies)
* [Conventions](#conventions) * [Conventions](#conventions)
* [Message Files](#message-files) * [Message Files](#message-files)
* [Messages](#messages) * [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) * [Topic List](#topic-list)
* [Replies](#replies) * [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 ### Conventions
Iris leans heavily on convention. Iris' security and message authentication is provided by filesystem permissions and message hashing. Iris leans heavily on convention. Iris' security and message authentication is provided by filesystem permissions and message hashing.

13
TODO.md
View File

@ -18,13 +18,14 @@
* Gracefully handle attempt to "r 1 message" * Gracefully handle attempt to "r 1 message"
### Features ### Features
* Add pagiation/less for long message lists * Add pagination/less for long message lists
* https://github.com/Calamitous/iris/issues/1 * https://github.com/Calamitous/iris/issues/1
* Allow shelling out to editor for message editing * Allow shelling out to editor for message editing
* https://github.com/Calamitous/iris/issues/2 * https://github.com/Calamitous/iris/issues/2
* Add local timezone rendering * Add local timezone rendering
* Add "Mark all read" option * Add "Mark all read" option
* Add option to mark all messages in a thread as read * Add option to mark all messages in a thread as read
* CLI option to show response count to threads the user authored
### Tech debt ### Tech debt
* Flesh out tests * Flesh out tests
@ -68,6 +69,16 @@
# Changelog # 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 ## 1.0.10
* ~Fix bug causing system to crash when a user removes read permissions from their directory/iris.messages file~ * ~Fix bug causing system to crash when a user removes read permissions from their directory/iris.messages file~

129
iris.rb
View File

@ -8,7 +8,7 @@ require 'time'
# require 'pry' # Only needed for debugging # require 'pry' # Only needed for debugging
class Config class Config
VERSION = '1.0.10' VERSION = '1.0.11'
MESSAGE_FILE = "#{ENV['HOME']}/.iris.messages" MESSAGE_FILE = "#{ENV['HOME']}/.iris.messages"
HISTORY_FILE = "#{ENV['HOME']}/.iris.history" HISTORY_FILE = "#{ENV['HOME']}/.iris.history"
IRIS_SCRIPT = __FILE__ IRIS_SCRIPT = __FILE__
@ -135,9 +135,14 @@ class Corpus
end end
@@my_corpus = IrisFile.load_messages.sort_by(&:timestamp) @@my_corpus = IrisFile.load_messages.sort_by(&:timestamp)
@@topics = @@corpus.select{ |m| m.parent == nil && m.show_me? }
@@my_reads = IrisFile.load_reads @@my_reads = IrisFile.load_reads
@@unread_messages = nil
@@all_hash_to_index = @@corpus.reduce({}) { |agg, msg| agg[msg.hash] = @@corpus.index(msg); agg } @@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| @@all_parent_hash_to_index = @@corpus.reduce({}) do |agg, msg|
agg[msg.parent] ||= [] agg[msg.parent] ||= []
agg[msg.parent] << @@corpus.index(msg) agg[msg.parent] << @@corpus.index(msg)
@ -153,8 +158,8 @@ class Corpus
@@corpus @@corpus
end end
def self.displayable def self.edited_hashes
@@corpus.select { |message| message.show_me? } @@edited_hashes
end end
def self.topics def self.topics
@ -188,9 +193,9 @@ class Corpus
all[index] all[index]
end end
def self.find_message_by_edit_hash(hash) def self.has_edit_hash(hash)
return nil unless hash return nil unless hash
Corpus.all.detect { |message| message.edit_hash == hash } Corpus.all.map(&:edit_hash).include?(hash)
end end
def self.find_all_by_parent_hash(hash) def self.find_all_by_parent_hash(hash)
@ -222,11 +227,21 @@ class Corpus
end end
def self.unread_messages 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 end
def self.unread_topics 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 end
def self.size def self.size
@ -256,7 +271,7 @@ class IrisFile
return [] return []
end end
rescue Errno::EACCES => e 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 [] return []
end end
@ -371,6 +386,10 @@ class Message
Message.new(new_text, old_message.parent, old_message.author, old_message.hash, old_message.timestamp).save! Message.new(new_text, old_message.parent, old_message.author, old_message.hash, old_message.timestamp).save!
end end
def is_topic?
parent.nil? && show_me?
end
def delete def delete
@is_deleted = !@is_deleted @is_deleted = !@is_deleted
replace! replace!
@ -380,8 +399,9 @@ class Message
!(edit_hash.nil? || edit_hash.empty?) !(edit_hash.nil? || edit_hash.empty?)
end end
# Only show messages that don't have a following, edited message
def show_me? def show_me?
!Corpus.find_message_by_edit_hash(hash) !Corpus.edited_hashes.include?(hash)
end end
def validate_user(username) def validate_user(username)
@ -444,7 +464,7 @@ class Message
def truncated_message(length) def truncated_message(length)
stub = message.split("\n").first stub = message.split("\n").first
return stub.colorize if stub.decolorize.length <= length 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 + '...' stub.colorize.slice(0, length - 5 - Display.topic_index_width).decolorize + '...'
end end
@ -479,7 +499,7 @@ class Message
header_bar = (indent_text + message_header + ('-' * (Display::WIDTH))) header_bar = (indent_text + message_header + ('-' * (Display::WIDTH)))
header_offset = header_bar.length - header_bar.decolorize.length 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)) bar = indent_text + ('-' * (Display::WIDTH - indent_text.decolorize.length))
@ -518,6 +538,8 @@ class Message
Corpus.find_message_by_hash(edit_hash) Corpus.find_message_by_hash(edit_hash)
end end
# Find all messages replying to the current topic, including replies to topics
# which have been edited.
def replies def replies
(Corpus.find_all_by_parent_hash(hash) + ((edit_predecessor && edit_predecessor.replies) || [])).compact (Corpus.find_all_by_parent_hash(hash) + ((edit_predecessor && edit_predecessor.replies) || [])).compact
end end
@ -562,7 +584,14 @@ end
class Display class Display
MIN_WIDTH = 80 MIN_WIDTH = 80
MIN_HEIGHT = 8
WIDTH = [ENV['COLUMNS'].to_i, `tput cols`.chomp.to_i, MIN_WIDTH].compact.max 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) def self.permissions_error(filename, file_description, permission_string, mode_string, consequence = nil)
message = [ message = [
@ -614,10 +643,14 @@ class Display
end end
class Interface 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 = { CMD_MAP = {
't' => 'topics', 't' => 'topics',
'topics' => 'topics', 'topics' => 'topics',
'u' => 'unread',
'unread' => 'unread',
'm' => 'mark_read',
'mark' => 'mark_read',
'c' => 'compose', 'c' => 'compose',
'compose' => 'compose', 'compose' => 'compose',
'h' => 'help', 'h' => 'help',
@ -651,6 +684,7 @@ class Interface
return reply(arg) if cmd == 'reply' return reply(arg) if cmd == 'reply'
return edit(arg) if cmd == 'edit' return edit(arg) if cmd == 'edit'
return delete(arg) if cmd == 'delete' 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.' Display.say 'Unrecognized command. Type "help" for a list of available commands.'
end end
@ -700,7 +734,7 @@ class Interface
@mode = :replying @mode = :replying
@text_buffer = '' @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
Display.say "Writing a reply to topic '#{title}'" Display.say "Writing a reply to topic '#{title}'"
Display.say 'Type a period on a line by itself to end message.' Display.say 'Type a period on a line by itself to end message.'
@ -731,12 +765,33 @@ class Interface
@mode = :editing @mode = :editing
@old_message = message @old_message = message
@text_buffer = '' @text_buffer = ''
title = message.truncated_message(Display::WIDTH - 26) title = message.truncated_message(Display::TITLE_WIDTH)
Display.say Display.say
Display.say "Editing message '#{title}'" Display.say "Editing message '#{title}'"
Display.say 'Type a period on a line by itself to end message.' Display.say 'Type a period on a line by itself to end message.'
end 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) def delete(message_id = nil)
unless message_id 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." 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 message.delete
title = message.truncated_message(Display::WIDTH - 26) title = message.truncated_message(Display::TITLE_WIDTH)
Display.say Display.say
if message.is_deleted if message.is_deleted
Display.say "{r Deleted message '#{title}' }" Display.say "{r Deleted message '#{title}' }"
@ -838,6 +893,7 @@ class Interface
def show_topic(num) def show_topic(num)
index = num.to_i - 1 index = num.to_i - 1
# TODO: Paginate here
if index >= 0 && index < Corpus.topics.length if index >= 0 && index < Corpus.topics.length
msg = Corpus.topics[index] msg = Corpus.topics[index]
@reply_topic = msg.hash @reply_topic = msg.hash
@ -873,16 +929,35 @@ class Interface
@mode = :browsing @mode = :browsing
Display.say "Welcome to Iris v#{Config::VERSION}. Type 'help' for a list of commands; Ctrl-D or 'quit' to leave." 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 while line = readline(prompt) do
handle(line) handle(line)
end end
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 def topics
Display.say Display.say
Display.say Display.topic_header Display.say Display.topic_header
# TODO: Paginate here
Corpus.topics.each_with_index do |topic, index| Corpus.topics.each_with_index do |topic, index|
Display.say topic.to_topic_line(index + 1) Display.say topic.to_topic_line(index + 1)
end end
@ -896,20 +971,22 @@ class Interface
'Commands', 'Commands',
'========', '========',
'READING', 'READING',
'topics, t - List all topics', 'topics, t - List all topics',
'# (topic id) - Read specified topic', 'unread, u - List all topics with unread messages',
'help, h, ? - Display this text', '# (topic id) - Read specified topic',
'mark_read #, m # - Mark the associated topic as read',
'help, h, ? - Display this text',
'', '',
'WRITING', 'WRITING',
'compose, c - Add a new topic', 'compose, c - Add a new topic',
'reply #, r # - Reply to a specific topic', 'reply #, r # - Reply to a specific topic',
'edit #, e # - Edit a topic or message', 'edit #, e # - Edit a topic or message',
'delete #, d #, undelete # - Delete {u or undelete} a topic or message', 'delete #, d #, undelete # - Delete {u or undelete} a topic or message',
'', '',
'SCREEN AND FILE UTILITIES', 'SCREEN AND FILE UTILITIES',
'freshen, f - Reload to get any new messages', 'freshen, f - Reload to get any new messages',
'reset, clear - Fix screen in case of text corruption', 'reset, clear - Fix screen in case of text corruption',
'info, i - Display Iris version and message stats', 'info, i - Display Iris version and message stats',
'', '',
'Full documentation available here:', 'Full documentation available here:',
'https://github.com/Calamitous/iris/blob/master/README.md', 'https://github.com/Calamitous/iris/blob/master/README.md',

View File

@ -1,6 +1,7 @@
require 'minitest/autorun' require 'minitest/autorun'
require 'mocha/mini_test' require 'mocha/mini_test'
$test_corpus_file = "./tests/iris.messages.json"
# Setting this before loading the main code file so that the Config contants # 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" # load correctly. This will allows the test to pretend that user "jerryberry"
# is logged in. # is logged in.
@ -10,7 +11,7 @@ require './iris.rb'
describe Config do describe Config do
it 'has the Iris semantic version number' do it 'has the Iris semantic version number' do
Config::VERSION.must_match /^\d\.\d\.\d$/ Config::VERSION.must_match /^\d\.\d\.\d+$/
end end
it 'has the message file location' do it 'has the message file location' do
@ -81,11 +82,14 @@ describe Corpus do
end end
it 'returns nil if the hash is not found in the corpus' do 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 Corpus.find_message_by_hash('NoofMcGoof').must_equal nil
end 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 end
describe '.find_all_by_parent_hash' do describe '.find_all_by_parent_hash' do