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.
- checksums.yaml +7 -0
- data/.gitignore +57 -0
- data/Gemfile +8 -0
- data/LICENSE +674 -0
- data/README.md +97 -0
- data/Rakefile +4 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/exe/rodo +10 -0
- data/lib/rodo.rb +825 -0
- data/lib/rodo/commands.rb +29 -0
- data/lib/rodo/curses_util.rb +287 -0
- data/lib/rodo/rodolib.rb +438 -0
- data/lib/rodo/version.rb +5 -0
- data/plan.md +267 -0
- data/rodo.gemspec +38 -0
- metadata +106 -0
@@ -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
|
data/lib/rodo/rodolib.rb
ADDED
@@ -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
|