itinerary 0.1
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 +15 -0
- data/.gitignore +17 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +29 -0
- data/Rakefile +1 -0
- data/TODO.md +32 -0
- data/bin/itinerary +35 -0
- data/itinerary.gemspec +33 -0
- data/lib/itinerary.rb +180 -0
- data/lib/itinerary/briar_scraper.rb +91 -0
- data/lib/itinerary/cache.rb +30 -0
- data/lib/itinerary/parse_html.rb +12 -0
- data/lib/itinerary/record.rb +336 -0
- data/lib/itinerary/tool.rb +23 -0
- data/lib/itinerary/tools/convert.rb +27 -0
- data/lib/itinerary/tools/create.rb +38 -0
- data/lib/itinerary/tools/find-dups.rb +34 -0
- data/lib/itinerary/tools/import.rb +20 -0
- data/lib/itinerary/tools/list.rb +36 -0
- data/lib/itinerary/tools/scrape-briar.rb +26 -0
- data/lib/itinerary/version.rb +5 -0
- data/lib/itinerary/view.rb +26 -0
- data/lib/itinerary/views/html.rb +18 -0
- data/lib/itinerary/views/kml.rb +49 -0
- data/lib/itinerary/views/tab.rb +20 -0
- data/lib/itinerary/views/text.rb +12 -0
- metadata +223 -0
@@ -0,0 +1,30 @@
|
|
1
|
+
class Cache
|
2
|
+
|
3
|
+
def initialize(dir)
|
4
|
+
@dir = dir
|
5
|
+
@dir.mkpath unless @dir.exist?
|
6
|
+
end
|
7
|
+
|
8
|
+
def path_for_key(key)
|
9
|
+
@dir + URI.encode(key).gsub(%r{/}, '_')
|
10
|
+
end
|
11
|
+
|
12
|
+
def read(key)
|
13
|
+
path = path_for_key(key)
|
14
|
+
if path.exist?
|
15
|
+
Marshal.load(path.read)
|
16
|
+
else
|
17
|
+
warn "[MISS #{key}]"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def write(key, data)
|
22
|
+
path = path_for_key(key)
|
23
|
+
path.open('w') { |io| io.write(Marshal.dump(data)) }
|
24
|
+
end
|
25
|
+
|
26
|
+
def fetch(key)
|
27
|
+
read(key) || yield.tap { |data| write(key, data) }
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
@@ -0,0 +1,336 @@
|
|
1
|
+
class Itinerary
|
2
|
+
|
3
|
+
class Record < HashStruct
|
4
|
+
|
5
|
+
class Field
|
6
|
+
|
7
|
+
attr_accessor :key
|
8
|
+
attr_accessor :type
|
9
|
+
attr_accessor :name
|
10
|
+
|
11
|
+
def initialize(key, options={})
|
12
|
+
@key = key
|
13
|
+
@type = options[:type]
|
14
|
+
@name = options[:name]
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
18
|
+
|
19
|
+
# include Geocoder::Model::Record
|
20
|
+
|
21
|
+
@@fields = {}
|
22
|
+
|
23
|
+
def self.define_field(key, options={})
|
24
|
+
@@fields[key] = Field.new(key, options)
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.field(key)
|
28
|
+
@@fields[key]
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.fields
|
32
|
+
@@fields
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.field_keys
|
36
|
+
@@fields.keys
|
37
|
+
end
|
38
|
+
|
39
|
+
attr_accessor :path
|
40
|
+
|
41
|
+
define_field :person, type: String, name: 'Person'
|
42
|
+
define_field :organization, type: String, name: 'Organization'
|
43
|
+
define_field :address, type: String, name: 'Address'
|
44
|
+
define_field :geocoding, type: Object, name: 'Geocoding'
|
45
|
+
define_field :email, type: String, name: 'Email'
|
46
|
+
define_field :phone, type: String, name: 'Phone'
|
47
|
+
define_field :uri, type: URI, name: 'URL'
|
48
|
+
define_field :description, type: String, name: 'Description'
|
49
|
+
define_field :ref, type: String, name: 'Reference'
|
50
|
+
define_field :group, type: String, name: 'Group'
|
51
|
+
define_field :visited, type: Date, name: 'Visited'
|
52
|
+
define_field :contacted, type: Date, name: 'Contacted'
|
53
|
+
define_field :declined, type: Date, name: 'Declined'
|
54
|
+
define_field :notes, type: String, name: 'Notes'
|
55
|
+
|
56
|
+
MaxFieldNameLength = @@fields.map { |k, f| f.name.length }.max
|
57
|
+
|
58
|
+
def self.load(path, options={})
|
59
|
+
io = path.open
|
60
|
+
rec = new(:path => path)
|
61
|
+
last_key = nil
|
62
|
+
while !io.eof? && (line = io.readline) do
|
63
|
+
case line.chomp
|
64
|
+
when ''
|
65
|
+
break
|
66
|
+
when /^\s*(\w+):\s*(.*)\s*$/
|
67
|
+
field_name, value = $1, $2.strip
|
68
|
+
next if value.empty?
|
69
|
+
field = @@fields[field_name.to_sym] || @@fields.values.find { |f| f.name == field_name } \
|
70
|
+
or raise "#{path}: Unknown field: #{field_name.inspect}"
|
71
|
+
if field.type == URI
|
72
|
+
#FIXME: sometimes this field is a space-separated list
|
73
|
+
# value = URI.parse(value)
|
74
|
+
elsif field.type == Object
|
75
|
+
value = eval(value)
|
76
|
+
end
|
77
|
+
rec[field.key] = value
|
78
|
+
last_key = field.key
|
79
|
+
when /^\s+(.+)\s*$/
|
80
|
+
raise "#{path}: Can't continue line without initial key or name" unless last_key
|
81
|
+
if rec[last_key]
|
82
|
+
rec[last_key] += ' ' + $1
|
83
|
+
else
|
84
|
+
rec[last_key] = $1
|
85
|
+
end
|
86
|
+
else
|
87
|
+
warn "#{path}: Bad line: #{line.inspect}"
|
88
|
+
next
|
89
|
+
end
|
90
|
+
end
|
91
|
+
notes = io.read
|
92
|
+
unless notes.empty?
|
93
|
+
if rec.notes
|
94
|
+
rec.notes += "\n\n" + notes
|
95
|
+
else
|
96
|
+
rec.notes = notes
|
97
|
+
end
|
98
|
+
end
|
99
|
+
io.close
|
100
|
+
rec
|
101
|
+
end
|
102
|
+
|
103
|
+
###
|
104
|
+
|
105
|
+
def name
|
106
|
+
organization || person
|
107
|
+
end
|
108
|
+
|
109
|
+
def city
|
110
|
+
geocoding[:city] if geocoded?
|
111
|
+
end
|
112
|
+
|
113
|
+
def state
|
114
|
+
geocoding[:state] if geocoded?
|
115
|
+
end
|
116
|
+
|
117
|
+
def country
|
118
|
+
geocoding[:country] if geocoded?
|
119
|
+
end
|
120
|
+
|
121
|
+
def latitude
|
122
|
+
geocoding[:latitude] if geocoded?
|
123
|
+
end
|
124
|
+
|
125
|
+
def longitude
|
126
|
+
geocoding[:longitude] if geocoded?
|
127
|
+
end
|
128
|
+
|
129
|
+
def coordinates
|
130
|
+
[latitude, longitude] if geocoded?
|
131
|
+
end
|
132
|
+
|
133
|
+
def geocoded?
|
134
|
+
!geocoding.nil?
|
135
|
+
end
|
136
|
+
|
137
|
+
def geocode
|
138
|
+
results = Geocoder.search(address)
|
139
|
+
if (result = results.first)
|
140
|
+
self.geocoding = {
|
141
|
+
:city => result.city,
|
142
|
+
:state => result.state_code,
|
143
|
+
:country => result.country_code,
|
144
|
+
:latitude => result.coordinates[0],
|
145
|
+
:longitude => result.coordinates[1],
|
146
|
+
}
|
147
|
+
true
|
148
|
+
else
|
149
|
+
false
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
def near(coords, radius)
|
154
|
+
if geocoded? && (distance = Haversine.distance(*coords, latitude, longitude).to_miles) <= radius
|
155
|
+
distance
|
156
|
+
else
|
157
|
+
nil
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
def visited?
|
162
|
+
visited && visited < DateTime.now
|
163
|
+
end
|
164
|
+
|
165
|
+
def to_visit?
|
166
|
+
visited && visited >= DateTime.now
|
167
|
+
end
|
168
|
+
|
169
|
+
def string_to_key(str)
|
170
|
+
key = str.dup
|
171
|
+
key.downcase!
|
172
|
+
key.gsub!(/[^\w]+/, '-')
|
173
|
+
key.sub!(/^-+/, '')
|
174
|
+
key.sub!(/-+$/, '')
|
175
|
+
key
|
176
|
+
end
|
177
|
+
|
178
|
+
def make_path(root)
|
179
|
+
path = root.dup
|
180
|
+
raise "Not geocoded" unless geocoded?
|
181
|
+
path += string_to_key(country) if country
|
182
|
+
path += string_to_key(state) if state
|
183
|
+
raise "Can't make key from empty name" unless name
|
184
|
+
key = string_to_key(name)
|
185
|
+
i = 1
|
186
|
+
p = nil
|
187
|
+
loop do
|
188
|
+
p = key.dup
|
189
|
+
p += i.to_s if i > 1
|
190
|
+
break unless (path + p).exist?
|
191
|
+
i += 1
|
192
|
+
end
|
193
|
+
path += p
|
194
|
+
path
|
195
|
+
end
|
196
|
+
|
197
|
+
def clean!
|
198
|
+
@@fields.values.select { |f| f.type == String }.each do |field|
|
199
|
+
if (value = self[field.key])
|
200
|
+
value = value.gsub(/[[:space:]]+/, ' ').strip
|
201
|
+
end
|
202
|
+
if value.nil? || value.empty?
|
203
|
+
delete(field.key)
|
204
|
+
else
|
205
|
+
self[field.key] = value
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
def convert
|
211
|
+
false
|
212
|
+
end
|
213
|
+
|
214
|
+
def to_text(options={})
|
215
|
+
t = StringIO.new
|
216
|
+
field_keys = options[:field_keys] || @@fields.keys
|
217
|
+
field_keys.map { |k| @@fields[k] }.each do |field|
|
218
|
+
next if field.key == :notes
|
219
|
+
value = self[field.key] or next
|
220
|
+
if value =~ /\n/
|
221
|
+
value = "\n" + value.gsub(/^/, "\t")
|
222
|
+
end
|
223
|
+
t.puts "%-#{MaxFieldNameLength + 1}.#{MaxFieldNameLength + 1}s %s" % [field.name + ':', value]
|
224
|
+
end
|
225
|
+
t.puts
|
226
|
+
t.puts notes if notes && field_keys.include?(:notes)
|
227
|
+
t.rewind
|
228
|
+
t.read
|
229
|
+
end
|
230
|
+
|
231
|
+
def to_tab(options={})
|
232
|
+
field_keys = options[:field_keys] || self.field_keys
|
233
|
+
field_keys.map { |k| @@fields[k] }.map do |field|
|
234
|
+
value = self[field.key] || ''
|
235
|
+
if value =~ /\n/
|
236
|
+
value = value.gsub(/\n+/, ' | ')
|
237
|
+
end
|
238
|
+
value
|
239
|
+
end.join("\t")
|
240
|
+
end
|
241
|
+
|
242
|
+
def to_html(options={})
|
243
|
+
use_dl = options[:use_dl]
|
244
|
+
fields_html = fields_to_html(options[:field_keys] || self.field_keys)
|
245
|
+
html = Builder::XmlMarkup.new
|
246
|
+
html.h2(name)
|
247
|
+
if use_dl
|
248
|
+
html.dl do
|
249
|
+
fields_html.each do |display_name, h|
|
250
|
+
html.dt(display_name)
|
251
|
+
html.dd << h
|
252
|
+
end
|
253
|
+
end
|
254
|
+
else
|
255
|
+
fields_html.each do |display_name, h|
|
256
|
+
html.p do
|
257
|
+
html.b("#{display_name}: ")
|
258
|
+
html << h
|
259
|
+
end
|
260
|
+
end
|
261
|
+
end
|
262
|
+
html.target!
|
263
|
+
end
|
264
|
+
|
265
|
+
def fields_to_html(field_keys)
|
266
|
+
fields_html = {}
|
267
|
+
field_keys.each do |key|
|
268
|
+
field = @@fields[key] or raise "Unknown field: #{key.inspect}"
|
269
|
+
display_name = field.name
|
270
|
+
if (value = self[field.key])
|
271
|
+
case field.key
|
272
|
+
when :geocoding
|
273
|
+
display_name = 'Location'
|
274
|
+
value = [city, state].compact.join(', ')
|
275
|
+
when :contacted, :declined, :visited
|
276
|
+
value = value.strftime('%-d %b %Y')
|
277
|
+
when :description
|
278
|
+
html = Builder::XmlMarkup.new
|
279
|
+
html.i(value)
|
280
|
+
value = html
|
281
|
+
when :uri
|
282
|
+
display_name = 'Website'
|
283
|
+
#FIXME: anything beyond first URI is ignored -- make into list of links
|
284
|
+
value = URI.parse(value.split(/\s+/).first) if value.kind_of?(String)
|
285
|
+
end
|
286
|
+
html = Builder::XmlMarkup.new
|
287
|
+
case value
|
288
|
+
when URI
|
289
|
+
html.a(value.to_s, :href => value.to_s)
|
290
|
+
when Builder::XmlMarkup
|
291
|
+
html << value
|
292
|
+
else
|
293
|
+
html.text!(value)
|
294
|
+
end
|
295
|
+
fields_html[display_name] = html.target!
|
296
|
+
end
|
297
|
+
end
|
298
|
+
fields_html
|
299
|
+
end
|
300
|
+
|
301
|
+
def save!
|
302
|
+
raise "Record has no path" unless @path
|
303
|
+
@path.dirname.mkpath unless @path.dirname.exist?
|
304
|
+
@path.open('w') { |io| io.write(to_text) }
|
305
|
+
end
|
306
|
+
|
307
|
+
def edit(options={})
|
308
|
+
system(
|
309
|
+
'subl',
|
310
|
+
options[:wait] ? '--wait' : (),
|
311
|
+
@path.to_s)
|
312
|
+
end
|
313
|
+
|
314
|
+
def open_in_editor_link
|
315
|
+
URI.parse("subl://open/?url=file://#{URI.escape(full_path)}")
|
316
|
+
end
|
317
|
+
|
318
|
+
def print_diff(other)
|
319
|
+
(self.keys + other.keys).sort.uniq.each do |key|
|
320
|
+
if self[key] != other[key]
|
321
|
+
puts "\t" + "#{key}:"
|
322
|
+
if self[key] && !other[key]
|
323
|
+
puts "\t\t" + "- #{self[key].inspect}"
|
324
|
+
elsif !self[key] && other[key]
|
325
|
+
puts "\t\t" + "+ #{other[key].inspect}"
|
326
|
+
elsif self[key] != other[key]
|
327
|
+
puts "\t\t" + "< #{self[key].inspect}"
|
328
|
+
puts "\t\t" + "> #{other[key].inspect}"
|
329
|
+
end
|
330
|
+
end
|
331
|
+
end
|
332
|
+
end
|
333
|
+
|
334
|
+
end
|
335
|
+
|
336
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
class Itinerary
|
2
|
+
class Tool
|
3
|
+
|
4
|
+
def self.inherited(subclass)
|
5
|
+
@@tools ||= []
|
6
|
+
@@tools << subclass
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.tools
|
10
|
+
@@tools
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.find_tool(cmd)
|
14
|
+
tool_class = @@tools.find { |t| t.name == cmd }
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize(itinerary, args)
|
18
|
+
@itinerary = itinerary
|
19
|
+
parse(args) if respond_to?(:parse)
|
20
|
+
end
|
21
|
+
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
class Itinerary
|
2
|
+
class ConvertTool < Tool
|
3
|
+
|
4
|
+
def self.name
|
5
|
+
'convert'
|
6
|
+
end
|
7
|
+
|
8
|
+
def parse(args)
|
9
|
+
if args.first == '-n'
|
10
|
+
args.shift
|
11
|
+
@dry_run = true
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def run
|
16
|
+
@itinerary.each do |rec1|
|
17
|
+
rec2 = rec1.dup
|
18
|
+
if rec2.convert && rec2 != rec1
|
19
|
+
puts "#{rec2.path} changed in cleaning"
|
20
|
+
rec2.print_diff(rec1)
|
21
|
+
rec2.save! unless @dry_run
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
class Itinerary
|
2
|
+
class CreateTool < Tool
|
3
|
+
|
4
|
+
def self.name
|
5
|
+
'create'
|
6
|
+
end
|
7
|
+
|
8
|
+
def run
|
9
|
+
tmp = Pathname.new('/tmp/new-entry')
|
10
|
+
unless tmp.exist?
|
11
|
+
rec = Record.new(
|
12
|
+
:path => tmp,
|
13
|
+
:person => 'FIXME',
|
14
|
+
:organization => 'FIXME',
|
15
|
+
:address => 'FIXME',
|
16
|
+
:email => 'FIXME',
|
17
|
+
:phone => 'FIXME',
|
18
|
+
:uri => 'FIXME',
|
19
|
+
:description => 'FIXME',
|
20
|
+
:ref => 'FIXME',
|
21
|
+
)
|
22
|
+
rec.save!
|
23
|
+
rec.edit(:wait => true)
|
24
|
+
end
|
25
|
+
rec = Record.load(tmp)
|
26
|
+
rec.geocode or begin
|
27
|
+
warn "Failed to geocode #{rec.address.inspect} (entry left in #{tmp})"
|
28
|
+
exit(1)
|
29
|
+
end
|
30
|
+
rec.path = rec.make_path(@itinerary.entries_path)
|
31
|
+
rec.save!
|
32
|
+
warn "Saved to #{rec.path}"
|
33
|
+
tmp.unlink
|
34
|
+
@itinerary.import_entry(rec.path)
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
38
|
+
end
|