rodo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+
2
+ module Commands
3
+
4
+ def initialize
5
+ @command_prototyp_list = [
6
+ {
7
+ description: "p(ostpone) todo by number of n days (p <n>)",
8
+ regex: /^po?s?t?p?o?n?e?\s*(\d*)\s*$/,
9
+ prototype: "p ",
10
+ do_cmd: lambda { |cmd, lines, day|
11
+ if cmd =~ /^p\s*(\d*)\s*$/
12
+ postpone(lines, day, $1.to_i)
13
+ end
14
+ }
15
+ },
16
+ "t(oday): move all unfinished tasks to today's entry",
17
+ "k(ill): remove the current line",
18
+ "m(ove): enter movement mode",
19
+ "a(ppend): insert a new todo after the current line",
20
+ "i(nsert): insert a new todo before the current line",
21
+ "w(aiting): move the current todo 7 days into the future",
22
+ {
23
+ description: "q(uit): exit and save",
24
+ prototype: "q"
25
+ }
26
+ ]
27
+ end
28
+
29
+ end
@@ -0,0 +1,287 @@
1
+ require 'curses'
2
+
3
+ module Curses
4
+
5
+ KEY_CTRL_RIGHT = Curses::REQ_SCR_FCHAR
6
+ KEY_CTRL_LEFT = Curses::REQ_DEL_CHAR
7
+
8
+ @@debug_win = nil
9
+
10
+ def self.debug_win= debug_win
11
+ @@debug_win = debug_win
12
+ end
13
+
14
+ def self.debug_win
15
+ @@debug_win
16
+ end
17
+
18
+ def self.debug message
19
+ if @@debug_win
20
+ @@debug_win.puts message
21
+ @@debug_win.refresh
22
+ end
23
+ end
24
+
25
+ # Switch on bracketed paste mode
26
+ # and reset it at end of application
27
+ def self.bracketed_paste
28
+ print("\x1b[?2004h")
29
+ at_exit {
30
+ print("\x1b[?2004l")
31
+ }
32
+ end
33
+
34
+ module WindowExtensions
35
+
36
+ # Create a subwindow inside this window which is padded by the given number n (lines and characters)
37
+ def inset(n=1)
38
+ subwin(maxy - 2 * n, maxx - 2 * n, begy + n, begx + n)
39
+ end
40
+
41
+ # Override box() to call box(0, 0)
42
+ def box(*args)
43
+ if args.size == 0
44
+ super(0, 0)
45
+ else
46
+ super(*args)
47
+ end
48
+ end
49
+
50
+ # Add the given string to this window and put the cursor on the next line
51
+ def puts(string)
52
+ if maxx - curx == string.length
53
+ addstr(string)
54
+ else
55
+ addstr(string + "\n")
56
+ end
57
+ end
58
+
59
+ # Return the current coordinates of the cursor [y,x]
60
+ def getyx
61
+ return [cury(), curx()]
62
+ end
63
+
64
+ # Add the given string to the top left borner of a box window
65
+ def caption(string)
66
+ p = getyx
67
+ setpos(0,1)
68
+ addstr(string)
69
+ setpos(*p)
70
+ end
71
+
72
+ def addstr_with_cursor(line, cursor_x)
73
+ line = line + " "
74
+
75
+ Curses.debug "Before Cursor: '#{line[...cursor_x]}'"
76
+ Curses.debug "Cursor_x: '#{cursor_x}'"
77
+
78
+ cursor_x = 0 if cursor_x < 0
79
+ cursor_x = line.size - 1 if cursor_x >= line.size
80
+
81
+ addstr line[...cursor_x] if cursor_x > 0
82
+ attron(Curses::A_REVERSE)
83
+ addstr line[cursor_x]
84
+ attroff(Curses::A_REVERSE)
85
+ addstr(line[(cursor_x+1)..]) if cursor_x < line.size
86
+ addstr("\n")
87
+ end
88
+
89
+ # Will read until the end of a bracketed paste marker "\x1b[201~"
90
+ # Requires that the "\x1b[200~" start marker has already been processed.
91
+ # The returned text does NOT include the end marker "200~"
92
+ def get_paste_text
93
+ pasted = ""
94
+ loop do
95
+ d = get_char2
96
+ case d
97
+ in csi: "201~" # Bracketed paste ended
98
+ break
99
+ else
100
+ pasted += d
101
+ end
102
+ end
103
+ return pasted
104
+ end
105
+
106
+ # Reads a Control Sequence Introducer (CSI) from `get_char`
107
+ #
108
+ # Requires that ANSI Control Sequence "\x1b[" has already been consumed.
109
+ #
110
+ # Assumes that there are no errors in the CSI.
111
+ #
112
+ # For CSI, or "Control Sequence Introducer" commands,
113
+ # the ESC [ is followed by
114
+ # 1.) any number (including none) of "parameter bytes" in the range
115
+ # 0x30–0x3F (ASCII 0–9:;<=>?), then by
116
+ # 2.) any number of "intermediate bytes" in the range
117
+ # 0x20–0x2F (ASCII space and !"#$%&'()*+,-./), then finally by
118
+ # 3.) a single "final byte" in the range
119
+ # 0x40–0x7E (ASCII @A–Z[\]^_`a–z{|}~).
120
+ #
121
+ # From: https://handwiki.org/wiki/ANSI_escape_code
122
+ def get_csi
123
+
124
+ result = "".dup
125
+ loop do
126
+ c = get_char
127
+ result += c
128
+ if c.ord >= 0x40 && c.ord <= 0x7E
129
+ return result
130
+ end
131
+ end
132
+
133
+ end
134
+
135
+ # Just like get_char, but will read \x1b[<csi>
136
+ # return it as a hash { csi: ... },
137
+ # everything else is just returned as-is
138
+ def get_char2
139
+ c = get_char
140
+ case c
141
+ when "\e" # ESC
142
+ d = get_char
143
+ case d
144
+ when '['
145
+ return { csi: get_csi }
146
+ else
147
+ Curses.unget_char(d)
148
+ return "\e"
149
+ # Curses.abort("Unhandled command sequence")
150
+ # raise "¯\_(ツ)_/¯"
151
+ end
152
+ else
153
+ return c
154
+ end
155
+ end
156
+
157
+ # Just like get_char2, but will read csi: "200~" as bracketed paste and
158
+ # return it as a hash { paste: <text> },
159
+ # everything else is just returned as from get_char2
160
+ def get_char3
161
+ c = get_char2
162
+ case c
163
+ in csi: "200~" # Bracketed paste started
164
+ return { paste: get_paste_text }
165
+ else
166
+ return c
167
+ end
168
+ end
169
+
170
+ # Will take care of reading a string from the user, given the start_string
171
+ def gets(start_string)
172
+
173
+ line = start_string
174
+ cursor_x = line.length
175
+ start_pos = getyx
176
+
177
+ loop do
178
+
179
+ clear
180
+ setpos(*start_pos)
181
+ addstr_with_cursor(line, cursor_x)
182
+
183
+ yield line
184
+
185
+ char = get_char
186
+ case char
187
+
188
+ when CTRLC, CTRLC.chr, ESC, ESC.chr
189
+ return :close
190
+
191
+ when "\u0001" # CTRL+A
192
+ cursor_x = 0
193
+
194
+ when "\u0005" # CTRL+E
195
+ cursor_x = line.length
196
+
197
+ when Curses::KEY_LEFT
198
+ cursor_x -= 1 if cursor_x > 0
199
+
200
+ when Curses::KEY_RIGHT
201
+ cursor_x += 1 if cursor_x < line.length - 1
202
+
203
+ when Curses::KEY_DC, "\u0004" # DEL, CTRL+D
204
+ if cursor_x < line.size
205
+ line.slice!(cursor_x)
206
+ end
207
+
208
+ when Curses::KEY_BACKSPACE
209
+ if cursor_x > 0
210
+ line.slice!(cursor_x - 1)
211
+ cursor_x -= 1
212
+ end
213
+
214
+ when ENTER, ENTER.chr
215
+ return line
216
+
217
+ when /[[:print:]]/
218
+
219
+ line.insert(cursor_x, char)
220
+ cursor_x += 1
221
+
222
+ else
223
+ Curses.debug "Char not handled: " + Curses::char_info(char)
224
+ end
225
+
226
+ end
227
+
228
+ end
229
+ end
230
+
231
+ class Window
232
+ prepend WindowExtensions
233
+ end
234
+
235
+ def self.cputs(string)
236
+ w = stdscr.inset
237
+ w.puts string
238
+ w.getch
239
+ w.close
240
+ end
241
+
242
+ # Enable bracketed paste mode and reset it upon exit
243
+ def bracketed_paste
244
+ print("\x1b[?2004h")
245
+ at_exit {
246
+ print("\x1b[?2004l")
247
+ }
248
+ end
249
+
250
+ def self.char_info(char)
251
+
252
+ case char
253
+ when Integer
254
+ key = Curses::ch2key(char)
255
+ return "Char: #{char} - Class Integer " + (key != nil ? "Constant: #{key}" : "To chr(): #{char.chr}")
256
+
257
+ # when String
258
+
259
+ else
260
+ return "Char: #{char.inspect} - Class: #{char.class}"
261
+ end
262
+ end
263
+
264
+ def self.ch2key(ch)
265
+
266
+ if !defined?(@@map)
267
+ @@map = {}
268
+ Curses.constants(false).each { |s|
269
+ c = Curses.const_get(s)
270
+ @@map[c] ||= []
271
+ @@map[c] << s
272
+ }
273
+ end
274
+ @@map[ch]
275
+ end
276
+
277
+ def self.abort(msg)
278
+
279
+ Curses.close_screen
280
+ puts "Traceback (most recent call last):"
281
+ puts caller.each_with_index.map { |s, i| "#{(i+1).to_s.rjust(9)}: from #{s}" }.reverse
282
+ puts msg
283
+ Kernel.exit(-1)
284
+
285
+ end
286
+
287
+ end
@@ -0,0 +1,438 @@
1
+
2
+ require 'date'
3
+
4
+ class Command
5
+
6
+ def redo()
7
+
8
+ end
9
+
10
+ def undo()
11
+
12
+ end
13
+
14
+ end
15
+
16
+
17
+ class Processor
18
+
19
+
20
+ def command(c, line, verb)
21
+
22
+ end
23
+
24
+
25
+ end
26
+
27
+ #
28
+ # Journal is the datastore class for the todo list managed by rodolib.
29
+ #
30
+ # Responsible for:
31
+ # - Parsing a md file into a rodolib Journal
32
+ # - Serialization into a md file
33
+ # - Splitting into days
34
+ #
35
+ class Journal
36
+
37
+ attr_accessor :days
38
+ def self.from_s(s)
39
+
40
+ j = Journal.new
41
+ j.days = []
42
+
43
+ next_day = []
44
+ s.each_line { |line|
45
+ if line =~ /^\s*\#\s*(\d\d\d\d-\d\d-\d\d)/ && next_day.size > 0
46
+ j.days << TodoDay.new(next_day)
47
+ next_day = []
48
+ end
49
+ next_day << line.rstrip if next_day.size > 0 || line.strip.length > 0 # Skip leading empty lines
50
+ }
51
+ if (next_day.size > 0 && next_day.any? { |line| line.strip.length > 0 })
52
+ j.days << TodoDay.new(next_day)
53
+ end
54
+
55
+ return j
56
+ end
57
+
58
+ def to_s
59
+ days.map { |day| day.to_s }.join("\n\n") + (days.empty? ? "" : "\n")
60
+ end
61
+
62
+ # Returns the TodoDay for the given date creating it if it doesn't exist
63
+ def ensure_day(target_date)
64
+ index = days.find_index { |x| x.date <= target_date } || -1
65
+
66
+ if index < 0 || days[index].date != target_date
67
+ days.insert(index, TodoDay.new(["# #{target_date.strftime("%Y-%m-%d")}"]))
68
+ end
69
+ return days[index]
70
+ end
71
+
72
+ # Returns the day, number of days in the future from the given day, but at never before today
73
+ def postpone_day(day, number_of_days_to_postpone=1)
74
+
75
+ number_of_days_to_postpone = 1 if number_of_days_to_postpone < 1
76
+
77
+ target_date = (day.date || Date.today).next_day(number_of_days_to_postpone)
78
+
79
+ # target_date shouldn't be in the past
80
+ target_date = Date.today if target_date.to_date < Date.today
81
+
82
+ return ensure_day(target_date)
83
+ end
84
+
85
+ # Postpone the given line on the given day by the given number of days, default=1
86
+ #
87
+ # Returns false, if there is no todo which can be postponed on the given line
88
+ # Returns the target date to which the line was moved if successful.
89
+ def postpone_line(day, line_index, number_of_days_to_postpone=1)
90
+
91
+ line = day.lines[line_index]
92
+
93
+ # Postpone only works for todos
94
+ return false if !(line =~ /^\s*(-\s+)?\[(.)\]/)
95
+
96
+ # Postpone only works for unfinished todos
97
+ return false if $2 != " "
98
+
99
+ # First create the target day
100
+ target_day = postpone_day(day, number_of_days_to_postpone)
101
+
102
+ # Determine all affected lines
103
+ unfinished_lines = [nil] * day.lines.size
104
+
105
+ # Copy all unfinished tasks and...
106
+ unfinished_lines[line_index] = line.dup
107
+
108
+ # ...their parent entries (recursively)
109
+ parent_index = line_index
110
+ while (parent_index = day.parent_index(parent_index)) != nil
111
+ unfinished_lines[parent_index] ||= day.lines[parent_index].dup
112
+ end
113
+
114
+ # Copy up to 1 whitespace line preceeding
115
+ unfinished_lines.each_with_index { |e, i|
116
+ if e != nil && i != 0 && day.indent_depth(i - 1) == nil
117
+ unfinished_lines[i-1] = ""
118
+ end
119
+ }
120
+
121
+ # ...and the children as well!
122
+ # TODO
123
+
124
+ # Mark line itself as postponed
125
+ line.sub!(/\[\s\]/, "[>]")
126
+
127
+ # Get rid of primary header
128
+ if unfinished_lines[0] =~ /^\s*\#\s*(\d\d\d\d-\d\d-\d\d)/
129
+ unfinished_lines.shift
130
+ end
131
+
132
+ # Only append non-empty lines
133
+ unfinished_lines.select! { |l| l != nil }
134
+
135
+ target_day.merge_lines(unfinished_lines)
136
+
137
+ return target_day
138
+ end
139
+
140
+ # Postpones all unfinished todos to today's date
141
+ # Returns the index of the target date to which things were postponed
142
+ def close(day)
143
+
144
+ unfinished_lines = [nil] * day.lines.size
145
+
146
+ day.lines.each_with_index { |line, index|
147
+ if line =~ /^\s*(-\s+)?\[(.)\]/
148
+
149
+ if $2 == " "
150
+ # Copy all unfinished tasks and...
151
+ unfinished_lines[index] = line.dup
152
+
153
+ # ...their parent entries (recursively)
154
+ parent_index = index
155
+ while (parent_index = day.parent_index(parent_index)) != nil
156
+ unfinished_lines[parent_index] ||= day.lines[parent_index].dup
157
+ end
158
+ line.sub!(/\[\s\]/, "[>]")
159
+ end
160
+
161
+ # Copy top level structure:
162
+ elsif !(line =~ /^\s*[-*]\s+/ || line =~ /^\s*#\s/)
163
+ unfinished_lines[index] = line.dup
164
+ end
165
+ }
166
+
167
+ if unfinished_lines[0] =~ /^\s*\#\s*(\d\d\d\d-\d\d-\d\d)/
168
+ unfinished_lines.shift
169
+ end
170
+
171
+ target_day = ensure_day(Date.today)
172
+ if target_day == day
173
+ # When closing on the same day: append hour and minutes
174
+ newDate = "# #{Time.now.strftime("%Y-%m-%d %a %H:%M")}"
175
+ target_day = TodoDay.new([newDate])
176
+ insertion_index = days.index { |d| target_day.date >= d.date } || 0
177
+ days.insert(insertion_index, target_day)
178
+ end
179
+
180
+ # Only append non-empty lines
181
+ unfinished_lines.select! { |l| l != nil }
182
+
183
+ target_day.merge_lines(unfinished_lines)
184
+
185
+ return days.find_index(target_day)
186
+ end
187
+
188
+ def most_recent_index
189
+ today = Date.today
190
+ return days.find_index { |x| x.date <= today } || 0
191
+ end
192
+
193
+ end
194
+
195
+ #
196
+ # Encapsulates the position of the cursor in the following dimensions:
197
+ #
198
+ # - which *x* position the cursor is on, on a particular *line*
199
+ # - which *line* the cursor is on, on a particular *day*
200
+ # - which *day* is currently shown on the main screen
201
+ # - which *x* position would be on ('shadow x'), if the line would be longer
202
+ # - which *line* the cursor would be on ('shadow line'), if the current day would have more lines
203
+ #
204
+ class Cursor
205
+
206
+ attr_accessor :journal
207
+ attr_accessor :day
208
+
209
+ def initialize(journal)
210
+ @journal = journal
211
+ @shadow_line = 0
212
+ @shadow_x = 0
213
+ self.day = @journal.most_recent_index
214
+ # self.line = 0
215
+ # self.x = 0
216
+ end
217
+
218
+ def day=(day)
219
+ @day = [[0, day].max, @journal.days.size - 1].min
220
+ @line = [@journal.days[@day].lines.size - 1, @shadow_line].min
221
+ @x = [@journal.days[@day].lines[@line].size, @shadow_x].min
222
+ end
223
+
224
+ def line=(line)
225
+ line = [[0, line].max, @journal.days[@day].lines.size - 1].min
226
+ @line = @shadow_line = line
227
+ @x = [@journal.days[@day].lines[@line].size, @shadow_x].min
228
+ end
229
+
230
+ def x=(x)
231
+ # note x is clamped 1 character beyond the length of the line
232
+ x = [[0, x].max, @journal.days[@day].lines[@line].size].min
233
+ @x = @shadow_x = x
234
+ end
235
+
236
+ def line
237
+ @line
238
+ end
239
+
240
+ def x
241
+ @x
242
+ end
243
+
244
+ end
245
+
246
+ # Encapsulate a single date of todo information
247
+ class TodoDay
248
+
249
+ attr_accessor :date
250
+ attr_accessor :lines
251
+
252
+ def initialize(lines)
253
+ self.lines = lines
254
+
255
+ if lines.size > 0 && lines[0] =~ /^\s*\#\s*(\d\d\d\d-\d\d-\d\d)/
256
+ self.date = Date.parse($1)
257
+ raise "Date couldn't be parsed on line #{lines[0]}" if self.date == nil
258
+ end
259
+ end
260
+
261
+ def date_name
262
+ return date.strftime("%Y-%m-%d") if date
263
+ return "undefined"
264
+ end
265
+
266
+ def to_s
267
+ lines.join("\n").rstrip
268
+ end
269
+
270
+ def line_prototype(line_index)
271
+ line = lines[line_index]
272
+ if /^(?<lead>\s+[*-])(?<option>\s\[.\]\s?)?(?<rest>.*?)$/ =~ line
273
+ if option =~ /^\s\[.\]/
274
+ option = " [ ]"
275
+ end
276
+ else
277
+ lead = " -"
278
+ option = " [ ]"
279
+ end
280
+
281
+ option = "" if !option
282
+ return lead + option.rstrip + " "
283
+ end
284
+
285
+ # Returns the number of leading spaces of the given line
286
+ def indent_depth(line_index)
287
+ return nil if !lines[line_index] || lines[line_index].strip.length == 0
288
+
289
+ lines[line_index][/\A\s*/].length
290
+ end
291
+
292
+ # Returns the line index of the parent line if any or nil
293
+ # The parent line is the line with a reduced indentation or the section header in case there no reduced indented line
294
+ def parent_index(line_index)
295
+ j = line_index - 1
296
+ my_indent = indent_depth(line_index)
297
+ return nil if !my_indent
298
+ while j > 0 # Day header does not count as parent
299
+ other_indent = indent_depth(j)
300
+ if other_indent && other_indent < my_indent
301
+ return j
302
+ end
303
+ j -= 1
304
+ end
305
+ return nil
306
+ end
307
+
308
+ # Turns the linear list of lines of this TodoDay into a nested structure of the form
309
+ # [{text: "text", children: [...]}, ...]
310
+ # where ... is the same hash structure {text: "text", children: [...]}
311
+ def structure
312
+
313
+ indents = [nil] * lines.size
314
+ (lines.size - 1).downto(0).each { |i|
315
+ indents[i] = indent_depth(i) || (i+1 < indents.size ? indents[i+1] : 0)
316
+ }
317
+
318
+ stack = [{depth: -1, children: []}]
319
+ lines.each_with_index { |s, i|
320
+ indent = indents[i]
321
+ new_child = {depth: indent, text: s, index: i, children: []}
322
+ while indent <= stack.last[:depth]
323
+ stack.pop
324
+ end
325
+ stack.last[:children] << new_child
326
+ stack << new_child
327
+ }
328
+
329
+ return stack.first[:children]
330
+ end
331
+
332
+ def close
333
+
334
+ unfinished_lines = []
335
+ lines.each { |line|
336
+ if line =~ /^\s*(-\s+)?\[(.)\]/
337
+
338
+ if $2 == " "
339
+ unfinished_lines << line.dup
340
+ line.sub!(/\[\s\]/, "[>]")
341
+ end
342
+
343
+ # Copy structure:
344
+ elsif !(line =~ /^\s*[-*]\s+/ || line =~ /^\s*#\s/)
345
+ unfinished_lines << line.dup
346
+ end
347
+ }
348
+
349
+ if unfinished_lines[0] =~ /^\s*\#\s*(\d\d\d\d-\d\d-\d\d)/
350
+ unfinished_lines.shift
351
+ end
352
+
353
+ # When closing on the same day: append hour and minutes
354
+ newDate = "# #{Time.now.strftime("%Y-%m-%d %a")}"
355
+ if lines.size > 0 && lines[0].start_with?(newDate)
356
+ newDate = "# #{Time.now.strftime("%Y-%m-%d %a %H:%M")}"
357
+ end
358
+
359
+ return TodoDay.new([newDate, *unfinished_lines])
360
+
361
+ end
362
+
363
+
364
+ # Merge alls entries from source into target
365
+ def self.merge_structures(target, source)
366
+ # Attempt 1.3:
367
+ to_prepend = []
368
+ source.each { |new_block|
369
+ existing_block_index = target.find_index { |existing_block|
370
+ new_block != nil && existing_block != nil && new_block[:text] != "" && new_block[:text] == existing_block[:text]
371
+ }
372
+
373
+ if existing_block_index
374
+ existing_block = target[existing_block_index]
375
+
376
+ # Insert everything in the to_prepend queue at the given position...
377
+
378
+ # ... but merge whitespace now
379
+ whitespace_check_index = existing_block_index - 1
380
+ while whitespace_check_index >=0 && to_prepend.size > 0 && to_prepend[0][:text] == "" && target[whitespace_check_index][:text] = ""
381
+ to_prepend.shift
382
+ whitespace_check_index -= 1
383
+ end
384
+
385
+ target.insert(existing_block_index, *to_prepend)
386
+
387
+ # Start queue from scratch
388
+ to_prepend = []
389
+ merge_structures(existing_block[:children], new_block[:children])
390
+ else
391
+ to_prepend << new_block.dup
392
+ end
393
+ }
394
+ # Everything that couldn't be matched, goes to the end
395
+ # TODO Whitespace merging
396
+ target.concat(to_prepend)
397
+
398
+ TodoDay::structure_reindex(target)
399
+
400
+ return target
401
+ end
402
+
403
+ def self.structure_to_a(structure)
404
+ result = []
405
+ structure.each { |block|
406
+ result << block[:text]
407
+ result.concat(structure_to_a(block[:children]))
408
+ }
409
+ return result
410
+ end
411
+
412
+ # Will traverse the given structure and update all indices to be in increasing order
413
+ def self.structure_reindex(structure, index = 0)
414
+ structure.each { |block|
415
+ block[:index] = index
416
+ index = structure_reindex(block[:children], index + 1)
417
+ }
418
+ return index
419
+ end
420
+
421
+ def merge_lines(lines_to_append)
422
+
423
+ return if lines_to_append.empty?
424
+
425
+ end_lines = []
426
+ end_lines << lines.pop while lines.last.strip.size == 0
427
+
428
+ my_structure = structure
429
+ ap_structure = TodoDay.new(lines_to_append).structure
430
+
431
+ TodoDay::merge_structures(my_structure, ap_structure)
432
+
433
+ @lines = TodoDay::structure_to_a(my_structure)
434
+
435
+ lines.concat(end_lines)
436
+ end
437
+
438
+ end