sgfa 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/sgfa/entry.rb ADDED
@@ -0,0 +1,697 @@
1
+ #
2
+ # Simple Group of Filing Applications
3
+ # Entry item
4
+ #
5
+ # Copyright (C) 2015 by Graham A. Field.
6
+ #
7
+ # See LICENSE.txt for licensing information.
8
+ #
9
+ # This program is distributed WITHOUT ANY WARRANTY; without even the
10
+ # implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11
+
12
+ require 'digest/sha2'
13
+ require 'json'
14
+
15
+ require_relative 'error'
16
+
17
+ module Sgfa
18
+
19
+
20
+ #####################################################################
21
+ # The Entry is the basic item which is being filed in a {Jacket}.
22
+ #
23
+ # The Entry has attributes:
24
+ # * hash - SHA256 hash of the canonical encoding
25
+ # * canonical - the canonical encoded string
26
+ # * jacket - the {Jacket} hash ID the entry belongs to
27
+ # * entry - the entry number
28
+ # * revision - the revision number
29
+ # * history - the history number where the entry was recorded
30
+ # * title - one line description
31
+ # * time - date/time used to sort within a tag
32
+ # * time_str - encoded version of the time in UTC
33
+ # * body - multiple line text of the entry
34
+ # * tags - list of all associated tags (may be empty)
35
+ # * attachments - list of attached files with names (may be empty)
36
+ # * max_attach - the maximum number of attachments ever belonging to
37
+ # the entry
38
+ #
39
+ class Entry
40
+
41
+
42
+ #########################################################
43
+ # @!group Limits checks
44
+
45
+ # Max chars in title
46
+ LimTitleMax = 128
47
+
48
+ # Invalid chars in title
49
+ LimTitleInv = /[[:cntrl:]]/
50
+
51
+ #####################################
52
+ # Limit check, title
53
+ def self.limits_title(str)
54
+ Error.limits(str, 1, LimTitleMax, LimTitleInv, 'Entry title')
55
+ end # def self.limits_title()
56
+
57
+
58
+ # Max chars in body
59
+ LimBodyMax = 1024 * 8
60
+
61
+ # Invalid chars in body
62
+ LimBodyInv = /[^[:print:][:space:]]/
63
+
64
+ #####################################
65
+ # Limit check, body
66
+ def self.limits_body(str)
67
+ Error.limits(str, 1, LimBodyMax, LimBodyInv, 'Entry body')
68
+ end # def self.limits_body()
69
+
70
+
71
+ # Max chars in a tag
72
+ LimTagMax = 128
73
+
74
+ # Invalid chars in a tag
75
+ LimTagInv = /[[:cntrl:]\/\\\*\?]|^_/
76
+
77
+ #####################################
78
+ # Limit check, tag
79
+ def self.limits_tag(str)
80
+ Error.limits(str, 1, LimTagMax, LimTagInv, 'Tag')
81
+ end # def self.limits_tag()
82
+
83
+
84
+ # Maximum attachment name
85
+ LimAttachMax = 255
86
+
87
+ # Invalid attachment name characters
88
+ LimAttachInv = /[[:cntrl:]\/\\\*\?]/
89
+
90
+ #####################################
91
+ # Limit check, attachment name
92
+ def self.limits_attach(str)
93
+ Error.limits(str, 1, LimAttachMax, LimAttachInv, 'Attachment name')
94
+ end # def self.limits_attach()
95
+
96
+
97
+ #########################################################
98
+ # @!group Read attributes
99
+
100
+
101
+ #####################################
102
+ # Get entry item hash
103
+ #
104
+ # @return [String] The hash of the entry
105
+ # @raise (see #canonical)
106
+ def hash
107
+ if !@hash
108
+ @hash = Digest::SHA256.new.update(canonical).hexdigest
109
+ end
110
+ return @hash.dup
111
+ end # def hash
112
+
113
+
114
+ #####################################
115
+ # Generate canonical encoded string
116
+ #
117
+ # @return [String] Canonical output
118
+ # @raise [Error::Sanity] if the entry is not complete enought to
119
+ # generate canonical output.
120
+ def canonical
121
+ if !@canon
122
+ raise Error::Sanity, 'Entry not complete' if !@history
123
+
124
+ txt = "jckt %s\n" % @jacket
125
+ txt << "entr %d\n" % @entry
126
+ txt << "revn %d\n" % @revision
127
+ txt << "hist %d\n" % @history
128
+ txt << "amax %d\n" % @attach_max
129
+ txt << "time %s\n" % @time_str
130
+ txt << "titl %s\n" % @title
131
+ @tags.sort.each{ |tag| txt << "tags %s\n" % tag }
132
+ @attach.to_a.sort{|aa, bb| aa[0] <=> bb[0] }.each do |anum, ary|
133
+ txt << "atch %d %d %s\n" % [anum, ary[0], ary[1]]
134
+ end
135
+ txt << "\n"
136
+ txt << @body
137
+ @canon = txt
138
+ end
139
+ return @canon.dup
140
+ end # def canonical()
141
+
142
+
143
+ #####################################
144
+ # Generate JSON encoded string
145
+ #
146
+ # @return [String] JSON output
147
+ # @raise [Error::Sanity] if the entry is not complete enought to
148
+ # generate canonical output.
149
+ def json
150
+ if !@json
151
+ enc = {
152
+ 'hash' => hash,
153
+ 'jacket' => @jacket,
154
+ 'entry' => @entry,
155
+ 'revision' => @revision,
156
+ 'history' => @history,
157
+ 'max_attach' => @attach_max,
158
+ 'time' => @time_str,
159
+ 'title' => @title,
160
+ 'tags' => @tags.sort,
161
+ 'attachments' => @attach,
162
+ 'body' => @body,
163
+ }
164
+ @json = JSON.generate(enc)
165
+ end
166
+ return @json.dup
167
+ end # def json
168
+
169
+
170
+ #####################################
171
+ # Get jacket
172
+ # @return [String, Boolean] The jacket hash ID or false if not set
173
+ def jacket
174
+ if @jacket
175
+ return @jacket.dup
176
+ else
177
+ return false
178
+ end
179
+ end # def jacket
180
+
181
+
182
+ #####################################
183
+ # Get entry number
184
+ #
185
+ # @return [Integer, Boolean] The entry number, or false if not set
186
+ def entry
187
+ if @entry
188
+ return @entry
189
+ else
190
+ return false
191
+ end
192
+ end # def entry
193
+
194
+
195
+ #####################################
196
+ # Get revision number
197
+ #
198
+ # @return [Integer, Boolean] The revision number, or false if not set
199
+ def revision
200
+ if @revision
201
+ return @revision
202
+ else
203
+ return false
204
+ end
205
+ end # def revision
206
+
207
+
208
+ #####################################
209
+ # Get history number
210
+ #
211
+ # @return [Integer, Boolean] The history number, or false if not set
212
+ def history
213
+ if @history
214
+ return @history
215
+ else
216
+ return false
217
+ end
218
+ end # def history
219
+
220
+
221
+ #####################################
222
+ # Get title
223
+ #
224
+ # @return [String, Boolean] Title of the entry, or false if not set
225
+ def title
226
+ if @title
227
+ return @title.dup
228
+ else
229
+ return false
230
+ end
231
+ end # def title
232
+
233
+
234
+ # Regex to parse time string
235
+ TimeStrReg = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/
236
+ private_constant :TimeStrReg
237
+
238
+
239
+ #####################################
240
+ # Get time
241
+ #
242
+ # @return [Time, Boolean] Time of the entry, or false if not set
243
+ def time
244
+ if !@time
245
+ return false if !@time_str
246
+ ma = TimeStrReg.match(@time_str)
247
+ raise Error::Limits, 'Invalid time string' if !ma
248
+ ary = ma[1,6].map{|str| str.to_i}
249
+ begin
250
+ @time = Time.utc(*ary)
251
+ rescue
252
+ raise Error::Limits, 'Invalid time string'
253
+ end
254
+ end
255
+ return @time.dup
256
+ end # def time
257
+
258
+
259
+ #####################################
260
+ # Get time string
261
+ #
262
+ # @return [String, Boolean] Encoded time string of the entry, or false
263
+ # if time not set
264
+ def time_str
265
+ if !@time_str
266
+ return false if !@time
267
+ @time_str = @time.strftime('%F %T')
268
+ end
269
+ return @time_str.dup
270
+ end # def time_str
271
+
272
+
273
+ #####################################
274
+ # Get body
275
+ #
276
+ # @return [String, Boolean] Entry body, or false if not set
277
+ def body
278
+ if @body
279
+ return @body.dup
280
+ else
281
+ return false
282
+ end
283
+ end # def body
284
+
285
+
286
+ #####################################
287
+ # Get tags
288
+ #
289
+ # @return [Array] of tag names
290
+ def tags
291
+ @tags = @tags.uniq
292
+ @tags.map{|tag| tag.dup }
293
+ end # def tags
294
+
295
+
296
+ #####################################
297
+ # Get permissions
298
+ def perms
299
+ @tags = @tags.uniq
300
+ @tags.select{|tag| tag.start_with?('perm: ')}.map{|tag| tag[6..-1]}
301
+ end # def perms
302
+
303
+
304
+ #####################################
305
+ # Get attached files
306
+ #
307
+ # @return [Array] of attachment information. Each entry is an array of
308
+ # \[attach_num, history_num, name\]
309
+ def attachments
310
+ res = []
311
+ @attach.each do |anum, ary|
312
+ res.push [anum, ary[0], ary[1].dup]
313
+ end
314
+ return res
315
+ end # def attach_each
316
+
317
+
318
+ #####################################
319
+ # Get max attachment
320
+ #
321
+ # @return [Integer, Boolean] The maximum attachment number, or false
322
+ # if not set
323
+ def attach_max
324
+ if @attach_max
325
+ return @attach_max
326
+ else
327
+ return false
328
+ end
329
+ end # def attach_max
330
+
331
+
332
+
333
+ #########################################################
334
+ # @!group Set attributes
335
+
336
+
337
+ #####################################
338
+ # Set entry using canonical encoding
339
+ #
340
+ # @param str [String] Canonical encoded entry
341
+ # @raise [Error::Corrupt] if encoding does not follow canonical rules
342
+ def canonical=(str)
343
+ @hash = nil
344
+ @canon = str.dup
345
+ lines = str.lines
346
+
347
+ ma = /^jckt ([0-9a-f]{64})$/.match lines.shift
348
+ raise(Error::Corrupt, 'Canonical entry jacket error') if !ma
349
+ @jacket = ma[1]
350
+
351
+ ma = /^entr (\d+)$/.match lines.shift
352
+ raise(Error::Corrupt, 'Canonical entry entry error') if !ma
353
+ @entry = ma[1].to_i
354
+
355
+ ma = /^revn (\d+)$/.match lines.shift
356
+ raise(Error::Corrupt, 'Canonical entry revision error') if !ma
357
+ @revision = ma[1].to_i
358
+
359
+ ma = /^hist (\d+)$/.match lines.shift
360
+ raise(Error::Corrupt, 'Canonical entry history error') if !ma
361
+ @history = ma[1].to_i
362
+
363
+ ma = /^amax (\d+)$/.match lines.shift
364
+ raise(Error::Corrupt, 'Caononical entry attach_max error') if !ma
365
+ @attach_max = ma[1].to_i
366
+
367
+ ma = /^time (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})$/.match lines.shift
368
+ raise(Error::Corrupt, 'Canonical entry time error') if !ma
369
+ @time_str = ma[1]
370
+ @time = nil
371
+
372
+ ma = /^titl (.*)$/.match lines.shift
373
+ raise(Error::Corrupt, 'Canonical entry title error') if !ma
374
+ Entry.limits_title(ma[1])
375
+ @title = ma[1]
376
+
377
+ li = lines.shift
378
+ @tags = []
379
+ while( li && ma = /^tags (.+)$/.match(li) )
380
+ Entry.limits_tag(ma[1])
381
+ @tags.push ma[1]
382
+ li = lines.shift
383
+ end
384
+
385
+ @attach = {}
386
+ while( li && ma = /^atch (\d+) (\d+) (.+)$/.match(li) )
387
+ Entry.limits_attach(ma[3])
388
+ @attach[ma[1].to_i] = [ma[2].to_i, ma[3]]
389
+ li = lines.shift
390
+ end
391
+
392
+ unless li && li.strip.empty?
393
+ raise Error::Corrupt, 'Canonical entry body error'
394
+ end
395
+
396
+ txt = ''
397
+ lines.each{ |li| txt << li }
398
+ Entry.limits_body(txt)
399
+ @body = txt
400
+
401
+ _final
402
+
403
+ rescue
404
+ reset
405
+ raise
406
+ end # def canonical=()
407
+
408
+
409
+ #####################################
410
+ # Set jacket
411
+ #
412
+ # @param jck [String] The jacket hash ID
413
+ # @raise [Error::Limits] if jck is not a valid hash
414
+ # @raise [Error::Sanity] if changing an already set jacket hash ID
415
+ def jacket=(jck)
416
+ ma = /^([0-9a-f]{64})$/.match jck
417
+ raise(Error::Limits, 'Jacket hash not valid') if !ma
418
+ if @jacket
419
+ raise(Error::Sanity, 'Jacket already set') if @jacket != jck
420
+ else
421
+ @jacket = jck.dup
422
+ _change
423
+ end
424
+ end # def jacket=()
425
+
426
+
427
+ #####################################
428
+ # Set entry number
429
+ # @param enum [Integer] The entry number
430
+ # @raise [ArgumentError] if enum is negative
431
+ # @raise [Error::Sanity] if changing an already set entry number
432
+ def entry=(enum)
433
+ raise(ArgumentError, 'Entry number invalid') if enum < 0
434
+ if @entry
435
+ raise(Error::Sanity, 'Changing entry number') if @entry != enum
436
+ else
437
+ @entry = enum
438
+ _change
439
+ end
440
+ end # def entry=()
441
+
442
+
443
+ #####################################
444
+ # Set title
445
+ #
446
+ # @param ttl [String] Title of the entry
447
+ # @raise [Error::Limits] if ttl exceeds allowed values
448
+ def title=(ttl)
449
+ Entry.limits_title(ttl)
450
+ @title = ttl.dup
451
+ _change
452
+ end # def title=()
453
+
454
+
455
+ #####################################
456
+ # Set time
457
+ #
458
+ # @param tme [Time] Time of the entry
459
+ def time=(tme)
460
+ @time = tme.utc
461
+ @time_str = nil
462
+ end # def time=()
463
+
464
+
465
+ #####################################
466
+ # Set encoded time string
467
+ #
468
+ # @param tme [String] Encoded time string
469
+ # @raise [Error::Limits] if tme is not properly written
470
+ def time_str=(tme)
471
+ @time = nil
472
+ @time_str = tme.dup
473
+ time
474
+ end # def time_str=()
475
+
476
+
477
+ #####################################
478
+ # Set body
479
+ #
480
+ # @param bdy [String] Entry body
481
+ # @raise [Error::Limits] if bdy exceeds allowed values
482
+ def body=(bdy)
483
+ Entry.limits_body(bdy)
484
+ @body = bdy.dup
485
+ _change
486
+ end # def body=()
487
+
488
+
489
+ #########################################################
490
+ # @!group General interface
491
+
492
+
493
+ #####################################
494
+ # Create a new entry
495
+ def initialize
496
+ reset
497
+ end # def initialize
498
+
499
+
500
+ #####################################
501
+ # Reset to blank entry
502
+ #
503
+ # @return [Entry] self
504
+ def reset()
505
+ @hash = nil
506
+ @canon = nil
507
+ @jacket = nil
508
+ @entry = nil
509
+ @revision = 1
510
+ @history = nil
511
+ @title = nil
512
+ @time = nil
513
+ @time_str = nil
514
+ @body = nil
515
+ @tags = []
516
+ @attach = {}
517
+ @attach_file = {}
518
+ @attach_max = 0
519
+
520
+ @time_old = nil
521
+ @tags_old = []
522
+
523
+ return self
524
+ end # def reset()
525
+
526
+
527
+ #####################################
528
+ # Update an entry
529
+ #
530
+ # @note If time has not been set, it defaults to the current time
531
+ #
532
+ # The changes returned include:
533
+ # * :time - true if time changed
534
+ # * :tags_add - list of new tags
535
+ # * :tags_del - list of tags deleted
536
+ # * :files - hash of attachment number => \[file, hash\]
537
+ #
538
+ # @param hnum [Integer] History number
539
+ # @raise [Error::Sanity] if no changes have been made to an entry
540
+ # @raise [Error::Sanity] if entry does not have at least jacket,
541
+ # entry, title, and body set
542
+ # @return [Hash] Describing changes
543
+ def update(hnum)
544
+ raise Error::Sanity, 'Update entry with no changes' if @history
545
+ if !@jacket && !@entry && !@title && !@body
546
+ raise Error::Sanity, 'Update incomplete entry'
547
+ end
548
+
549
+ @history = hnum
550
+ change = {}
551
+
552
+ if !@time_str
553
+ @time = Time.new.utc if !@time
554
+ @time_str = @time.strftime('%F %T')
555
+ end
556
+ change[:time] = @time_str != @time_old
557
+
558
+ @tags = @tags.uniq
559
+ change[:tags_add] = @tags - @tags_old
560
+ change[:tags_del] = @tags_old - @tags
561
+
562
+ @attach.each{ |anum, ary| ary[0] = hnum if ary[0] == 0 }
563
+ change[:files] = @attach_file
564
+
565
+ _final()
566
+ return change
567
+ end # def update()
568
+
569
+
570
+ #########################################################
571
+ # @!group Edit tags and attached files
572
+
573
+
574
+ #####################################
575
+ # Rename an attachment
576
+ #
577
+ # @param anum [Integer] Attachment number to rename
578
+ # @param name [String] New attachment name
579
+ # @raise [Error::Sanity] if attachment does not exist
580
+ # @raise [Error::Limits] if name exceeds allowed values
581
+ def rename(anum, name)
582
+ raise(Error::Sanity, 'Non-existent attachment') if !@attach[anum]
583
+ Entry.limits_attach(name)
584
+ @attach[anum][1] = name.dup
585
+ _change()
586
+ end # def rename()
587
+
588
+
589
+ #####################################
590
+ # Delete an attachment
591
+ #
592
+ # @param anum [Integer] anum Attachment number to delete
593
+ # @raise [Error::Sanity] if attachment does not exist
594
+ def delete(anum)
595
+ raise(Error::Sanity, 'Non-existent attachment') if !@attach[anum]
596
+ @attach.delete(anum)
597
+ _change()
598
+ end # def delete()
599
+
600
+
601
+ #####################################
602
+ # Replace an attachment
603
+ #
604
+ # @note If hash is not provided, it will be calculated. This can take
605
+ # a long time for large files.
606
+ #
607
+ # @param anum [Integer] Attachment number to replace
608
+ # @param file [File] Temporary file to attach
609
+ # @param hash [String] The SHA256 hash of the file
610
+ # @raise [Error::Sanity] if attachment does not exist
611
+ def replace(anum, file, hash=nil)
612
+ raise(Error::Sanity, 'Non-existent attachment') if !@attach[anum]
613
+ hsto = hash ? hash.dup : Digest::SHA256.file(file.path).hexdigest
614
+ @attach[anum][0] = 0
615
+ @attach_file[anum] = [file, hsto]
616
+ _change()
617
+ end # def replace()
618
+
619
+
620
+ #####################################
621
+ # Add an attachment
622
+ #
623
+ # @note (see #replace)
624
+ #
625
+ # @param name [String] Attachment name
626
+ # @param file [File] Temporary file to attach
627
+ # @param hash [String] The SHA256 hash of the file
628
+ # @raise [Error::Limits] if name exceeds allowed values
629
+ def attach(name, file, hash=nil)
630
+ Entry.limits_attach(name)
631
+ hsto = hash ? hash.dup : Digest::SHA256.file(file.path).hexdigest
632
+ @attach_max += 1
633
+ @attach[@attach_max] = [0, name.dup]
634
+ @attach_file[@attach_max] = [file, hsto]
635
+ _change()
636
+ end # def attach()
637
+
638
+
639
+ #####################################
640
+ # Set tag
641
+ #
642
+ # @param tnam [String] Tag name to set
643
+ # @raise [Error::Limits] if tnam exceeds allowed values
644
+ def tag(tnam)
645
+ name = _tag_normalize(tnam)
646
+ @tags.push name
647
+ _change()
648
+ end # def tag()
649
+
650
+
651
+ #####################################
652
+ # Clear tag
653
+ #
654
+ # @param tnam [String] tnam Tag name to clear
655
+ # @raise [Error::Limits] if tnam exceeds allowed values
656
+ def untag(tnam)
657
+ name = _tag_normalize(tnam)
658
+ @tags.delete name
659
+ _change()
660
+ end # def untag()
661
+
662
+
663
+ private
664
+
665
+ # Change to entry
666
+ def _change()
667
+ @revision = @revision + 1 if @history
668
+ @history = nil
669
+ @hash = nil
670
+ @canon = nil
671
+ end # def _change()
672
+
673
+
674
+ # Finalize entry
675
+ def _final
676
+ @time_old = @time_str ? @time_str.dup : nil
677
+ @tags_old = @tags.map{|tg| tg.dup }
678
+ @attach_file = {}
679
+ end # def _final()
680
+
681
+
682
+ # Normalize tag name
683
+ def _tag_normalize(tnam)
684
+ idx = tnam.index(':')
685
+ if idx
686
+ pre = tnam[0, idx].strip
687
+ post = tnam[idx+1..-1].strip
688
+ return pre + ': ' + post
689
+ else
690
+ return tnam.strip
691
+ end
692
+ end # def _tag_normalize()
693
+
694
+
695
+ end # class Entry
696
+
697
+ end # module Sgfa