sgfa 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.
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