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.
- checksums.yaml +7 -0
- data/LICENSE.txt +674 -0
- data/README.txt +14 -0
- data/bin/sgfa +15 -0
- data/data/sgfa_web.css +240 -0
- data/lib/sgfa/binder.rb +627 -0
- data/lib/sgfa/binder_fs.rb +203 -0
- data/lib/sgfa/cli/binder.rb +160 -0
- data/lib/sgfa/cli/jacket.rb +299 -0
- data/lib/sgfa/cli.rb +36 -0
- data/lib/sgfa/demo/web_binders.rb +111 -0
- data/lib/sgfa/demo/web_css.rb +60 -0
- data/lib/sgfa/entry.rb +697 -0
- data/lib/sgfa/error.rb +95 -0
- data/lib/sgfa/history.rb +445 -0
- data/lib/sgfa/jacket.rb +556 -0
- data/lib/sgfa/jacket_fs.rb +136 -0
- data/lib/sgfa/lock_fs.rb +141 -0
- data/lib/sgfa/state_fs.rb +342 -0
- data/lib/sgfa/store_fs.rb +214 -0
- data/lib/sgfa/web/base.rb +225 -0
- data/lib/sgfa/web/binder.rb +1190 -0
- data/lib/sgfa.rb +37 -0
- metadata +68 -0
data/lib/sgfa/error.rb
ADDED
@@ -0,0 +1,95 @@
|
|
1
|
+
#
|
2
|
+
# Simple Group of Filing Applications
|
3
|
+
# Errors
|
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
|
+
# This ; prevents YARD from using the header as docs for Sgfa module
|
13
|
+
;
|
14
|
+
|
15
|
+
module Sgfa
|
16
|
+
|
17
|
+
|
18
|
+
##########################################################################
|
19
|
+
# Errors generated by the Sgfa system
|
20
|
+
module Error
|
21
|
+
|
22
|
+
|
23
|
+
##########################################
|
24
|
+
# Most limit checks
|
25
|
+
#
|
26
|
+
# @param str [String] String to check
|
27
|
+
# @param min [Integer] Minimum length
|
28
|
+
# @param max [Integer] max Maximum length
|
29
|
+
# @param inv [Regexp, String] Invalid characters
|
30
|
+
# @param desc [String] desc Description of string
|
31
|
+
# @raise [Error::Limits] if limits are violated
|
32
|
+
def self.limits(str, min, max, inv, desc)
|
33
|
+
|
34
|
+
# string
|
35
|
+
if !str || !str.is_a?(String)
|
36
|
+
raise Error::Limits, desc + ' is not a string.'
|
37
|
+
end
|
38
|
+
|
39
|
+
# size
|
40
|
+
size = str.size
|
41
|
+
if str.size > max
|
42
|
+
raise Error::Limits, '%s length %d is too large, limit %d' %
|
43
|
+
[desc, size, max]
|
44
|
+
end
|
45
|
+
if str.size < min
|
46
|
+
raise Error::Limits, '%s length %d is too small, limit %d' %
|
47
|
+
[desc, size, min]
|
48
|
+
end
|
49
|
+
|
50
|
+
# invalid characters
|
51
|
+
idx = str.index(inv)
|
52
|
+
if idx
|
53
|
+
raise Error::Limits,
|
54
|
+
'%s contains invalid character \'%s\'' % [desc, str[idx]]
|
55
|
+
end
|
56
|
+
end # def self.limits()
|
57
|
+
|
58
|
+
|
59
|
+
###########################################################
|
60
|
+
# Sanity check failed
|
61
|
+
class Sanity < StandardError; end
|
62
|
+
|
63
|
+
|
64
|
+
###########################################################
|
65
|
+
# Outside of limits
|
66
|
+
class Limits < StandardError; end
|
67
|
+
|
68
|
+
|
69
|
+
###########################################################
|
70
|
+
# Corrupt file
|
71
|
+
class Corrupt < StandardError; end
|
72
|
+
|
73
|
+
|
74
|
+
###########################################################
|
75
|
+
# Outside of limits
|
76
|
+
class Limits < StandardError; end
|
77
|
+
|
78
|
+
|
79
|
+
###########################################################
|
80
|
+
# Conflict of some type
|
81
|
+
class Conflict < StandardError; end
|
82
|
+
|
83
|
+
|
84
|
+
###########################################################
|
85
|
+
# Does not exist
|
86
|
+
class NonExistent < StandardError; end
|
87
|
+
|
88
|
+
|
89
|
+
###########################################################
|
90
|
+
# Permission check failed
|
91
|
+
class Permission < StandardError; end
|
92
|
+
|
93
|
+
end # module Error
|
94
|
+
|
95
|
+
end # module Sgfa
|
data/lib/sgfa/history.rb
ADDED
@@ -0,0 +1,445 @@
|
|
1
|
+
#
|
2
|
+
# Simple Group of Filing Applications
|
3
|
+
# History 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
|
+
|
14
|
+
require_relative 'error'
|
15
|
+
|
16
|
+
module Sgfa
|
17
|
+
|
18
|
+
|
19
|
+
#####################################################################
|
20
|
+
# The history item provides a cryptographic chain of changes made to a
|
21
|
+
# {Jacket}.
|
22
|
+
#
|
23
|
+
# The History has attributes:
|
24
|
+
# * hash - SHA256 hash of the canonical encoding
|
25
|
+
# * canonical - the canonical encoded string
|
26
|
+
# * jacket - The {Jacket} hash ID the history belongs to
|
27
|
+
# * previous - Previous history number
|
28
|
+
# * history - History number
|
29
|
+
# * entry_max - Number of entries present in the Jacket
|
30
|
+
# * time - Date and time of the change
|
31
|
+
# * user - User name who made the change
|
32
|
+
# * entries - List of entries changed \[entry_num, revision_num, hash\]
|
33
|
+
# * attachments - List of attachments made \[entry_num, attach_num, hash\]
|
34
|
+
#
|
35
|
+
class History
|
36
|
+
|
37
|
+
#########################################################
|
38
|
+
# @!group Limits checks
|
39
|
+
|
40
|
+
# Max chars in user name
|
41
|
+
LimUserMax = 64
|
42
|
+
|
43
|
+
# Invalid character in user name
|
44
|
+
LimUserInv = /[[:cntrl:]]/
|
45
|
+
|
46
|
+
#####################################
|
47
|
+
# Limit check, user name
|
48
|
+
def self.limits_user(str)
|
49
|
+
Error.limits(str, 1, LimUserMax, LimUserInv, 'User name')
|
50
|
+
end # def self.limits_user()
|
51
|
+
|
52
|
+
|
53
|
+
#########################################################
|
54
|
+
# @!group Read attributes
|
55
|
+
|
56
|
+
|
57
|
+
#####################################
|
58
|
+
# Get history item hash
|
59
|
+
#
|
60
|
+
# @return [String] The hash of the history
|
61
|
+
# @raise (see #canonical)
|
62
|
+
def hash
|
63
|
+
if !@hash
|
64
|
+
@hash = Digest::SHA256.new.update(canonical).hexdigest
|
65
|
+
end
|
66
|
+
return @hash.dup
|
67
|
+
end # def hash
|
68
|
+
|
69
|
+
|
70
|
+
#####################################
|
71
|
+
# Generate canonical encoded string
|
72
|
+
#
|
73
|
+
# @return [String] Canonical output
|
74
|
+
# @raise [Error::Sanity] if the history is not complete
|
75
|
+
def canonical
|
76
|
+
if !@canon
|
77
|
+
raise Error::Sanity, 'History not complete' if !@history
|
78
|
+
|
79
|
+
txt = "jckt %s\n" % @jacket
|
80
|
+
txt << "hist %d\n" % @history
|
81
|
+
txt << "emax %d\n" % @entry_max
|
82
|
+
txt << "time %s\n" % time_str
|
83
|
+
txt << "prev %s\n" % @previous
|
84
|
+
txt << "user %s\n" % @user
|
85
|
+
@entries.each{|ary| txt << "entr %d %d %s\n" % ary }
|
86
|
+
@attach.each{|ary| txt << "atch %d %d %s\n" % ary }
|
87
|
+
@canon = txt
|
88
|
+
end
|
89
|
+
return @canon.dup
|
90
|
+
end # def canonical
|
91
|
+
|
92
|
+
|
93
|
+
#####################################
|
94
|
+
# Get jacket
|
95
|
+
#
|
96
|
+
# @return [String, Boolean] The jacket hash ID or false if not set
|
97
|
+
def jacket
|
98
|
+
if @jacket
|
99
|
+
return @jacket.dup
|
100
|
+
else
|
101
|
+
return false
|
102
|
+
end
|
103
|
+
end # def jacket
|
104
|
+
|
105
|
+
|
106
|
+
#####################################
|
107
|
+
# Get previous history hash
|
108
|
+
#
|
109
|
+
# @return [String, Boolean] The hash of the previous history item,
|
110
|
+
# or false if not set
|
111
|
+
def previous
|
112
|
+
if @previous
|
113
|
+
return @previous.dup
|
114
|
+
else
|
115
|
+
return false
|
116
|
+
end
|
117
|
+
end # def previous
|
118
|
+
|
119
|
+
|
120
|
+
#####################################
|
121
|
+
# Get history number
|
122
|
+
#
|
123
|
+
# @return [Integer, Boolean] History number, or false if not set
|
124
|
+
def history
|
125
|
+
return @history
|
126
|
+
end # def history
|
127
|
+
|
128
|
+
|
129
|
+
#####################################
|
130
|
+
# Get maximum entry
|
131
|
+
#
|
132
|
+
# @return [Integer, Boolean] Maximum entry in a Jacket, or false if not set
|
133
|
+
def entry_max
|
134
|
+
return @entry_max
|
135
|
+
end # def entry_max
|
136
|
+
|
137
|
+
|
138
|
+
# Regex to parse time string
|
139
|
+
TimeStrReg = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/
|
140
|
+
private_constant :TimeStrReg
|
141
|
+
|
142
|
+
|
143
|
+
#####################################
|
144
|
+
# Get time
|
145
|
+
#
|
146
|
+
# @return [Time, Boolean] The time, or false if not set
|
147
|
+
def time
|
148
|
+
if !@time
|
149
|
+
return false if !@time_str
|
150
|
+
ma = TimeStrReg.match(@time_str)
|
151
|
+
ary = ma[1,6].map{|str| str.to_i}
|
152
|
+
@time = Time.utc(*ary)
|
153
|
+
end
|
154
|
+
return @time.dup
|
155
|
+
end # def time
|
156
|
+
|
157
|
+
|
158
|
+
#####################################
|
159
|
+
# Get time string
|
160
|
+
#
|
161
|
+
# @return [String, Boolean] Encoded time string of the history, or false
|
162
|
+
# if time not set
|
163
|
+
def time_str
|
164
|
+
if !@time_str
|
165
|
+
return false if !@time
|
166
|
+
@time_str = @time.strftime('%F %T')
|
167
|
+
end
|
168
|
+
return @time_str.dup
|
169
|
+
end # def time_str
|
170
|
+
|
171
|
+
|
172
|
+
#####################################
|
173
|
+
# Get user
|
174
|
+
#
|
175
|
+
# @return [String, Boolean] The user string, or false if not set
|
176
|
+
def user
|
177
|
+
if @user
|
178
|
+
return @user.dup
|
179
|
+
else
|
180
|
+
return false
|
181
|
+
end
|
182
|
+
end # def user
|
183
|
+
|
184
|
+
|
185
|
+
#####################################
|
186
|
+
# Get entries
|
187
|
+
#
|
188
|
+
# @return [Array] of Entry information \[entry_num, revision_num, hash\]
|
189
|
+
def entries
|
190
|
+
return @entries.map{|enum, rnum, hash| [enum, rnum, hash.dup] }
|
191
|
+
end # def entries
|
192
|
+
|
193
|
+
|
194
|
+
#####################################
|
195
|
+
# Get attachments
|
196
|
+
#
|
197
|
+
# @return [Array] of Attachment information \[entry_num, attach_num, hash\]
|
198
|
+
def attachments
|
199
|
+
return @attach.map{|enum, anum, hash| [enum, anum, hash.dup] }
|
200
|
+
end # def attachments
|
201
|
+
|
202
|
+
|
203
|
+
|
204
|
+
#########################################################
|
205
|
+
# @!group General interface
|
206
|
+
|
207
|
+
#####################################
|
208
|
+
# Create a new History item
|
209
|
+
#
|
210
|
+
# @param (see #jacket=)
|
211
|
+
# @raise (see #jacket=)
|
212
|
+
def initialize(jck=nil)
|
213
|
+
reset
|
214
|
+
if jck
|
215
|
+
self.jacket = jck
|
216
|
+
end
|
217
|
+
end # def initialize()
|
218
|
+
|
219
|
+
|
220
|
+
#####################################
|
221
|
+
# Reset to blank History item
|
222
|
+
#
|
223
|
+
# @return [History] self
|
224
|
+
def reset
|
225
|
+
@hash = nil
|
226
|
+
@canon = nil
|
227
|
+
@jacket = nil
|
228
|
+
@history = nil
|
229
|
+
@entry_max = 0
|
230
|
+
@time = nil
|
231
|
+
@time_str = nil
|
232
|
+
@previous = nil
|
233
|
+
@user = nil
|
234
|
+
@entries = []
|
235
|
+
@attach = []
|
236
|
+
|
237
|
+
@change_entry = nil
|
238
|
+
@change_tag = nil
|
239
|
+
@change_attach = nil
|
240
|
+
end # def reset
|
241
|
+
|
242
|
+
|
243
|
+
#####################################
|
244
|
+
# Set History using canonical encoding
|
245
|
+
#
|
246
|
+
# @param str [String] Canonical encoded History
|
247
|
+
# @raise [Error::Corrupt] if encoding does not follow canonical rules
|
248
|
+
def canonical=(str)
|
249
|
+
@hash = nil
|
250
|
+
@canon = str.dup
|
251
|
+
lines = str.lines
|
252
|
+
|
253
|
+
ma = /^jckt ([0-9a-f]{64})$/.match lines.shift
|
254
|
+
raise(Error::Corrupt, 'Canonical history jacket error') if !ma
|
255
|
+
@jacket = ma[1]
|
256
|
+
|
257
|
+
ma = /^hist (\d+)$/.match lines.shift
|
258
|
+
raise(Error::Corrupt, 'Canonical history history error') if !ma
|
259
|
+
@history = ma[1].to_i
|
260
|
+
|
261
|
+
ma = /^emax (\d+)$/.match lines.shift
|
262
|
+
raise(Error::Corrupt, 'Canonical history entry_max error') if !ma
|
263
|
+
@entry_max = ma[1].to_i
|
264
|
+
|
265
|
+
ma = /^time (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})$/.match lines.shift
|
266
|
+
raise(Error::Corrupt, 'Canonical history time error') if !ma
|
267
|
+
@time_str = ma[1]
|
268
|
+
|
269
|
+
ma = /^prev ([0-9a-f]{64})$/.match lines.shift
|
270
|
+
raise(Error::Corrupt, 'Canonical history previous error') if !ma
|
271
|
+
@previous = ma[1]
|
272
|
+
|
273
|
+
ma = /^user (.+)$/.match lines.shift
|
274
|
+
raise(Error::Corrupt, 'Canonical history user error') if !ma
|
275
|
+
@user = ma[1]
|
276
|
+
|
277
|
+
li = lines.shift
|
278
|
+
@entries = []
|
279
|
+
while(li && ma = /^entr (\d+) (\d+) (.+)$/.match(li) )
|
280
|
+
@entries.push [ma[1].to_i, ma[2].to_i, ma[3]]
|
281
|
+
li = lines.shift
|
282
|
+
end
|
283
|
+
|
284
|
+
@attach = []
|
285
|
+
while(li && ma = /^atch (\d+) (\d+) ([0-9a-f]{64})$/.match(li) )
|
286
|
+
@attach.push [ma[1].to_i, ma[2].to_i, ma[3]]
|
287
|
+
li = lines.shift
|
288
|
+
end
|
289
|
+
|
290
|
+
if li
|
291
|
+
raise Error::Corrupt, 'Canonical history error'
|
292
|
+
end
|
293
|
+
|
294
|
+
rescue
|
295
|
+
reset
|
296
|
+
raise
|
297
|
+
end # def canonical=()
|
298
|
+
|
299
|
+
|
300
|
+
#####################################
|
301
|
+
# Set jacket
|
302
|
+
#
|
303
|
+
# @param [String] jck The jacket hash ID
|
304
|
+
# @raise [Error::Limits] if jck is not a valid hash
|
305
|
+
# @raise [Error::Sanity] if changing an already set jacket hash ID
|
306
|
+
def jacket=(jck)
|
307
|
+
ma = /^([0-9a-f]{64})$/.match jck
|
308
|
+
raise(Error::Limits, 'Jacket hash not valid') if !ma
|
309
|
+
if @jacket
|
310
|
+
raise(Error::Sanity, 'Jacket already set') if @jacket != jck
|
311
|
+
else
|
312
|
+
@jacket = jck.dup
|
313
|
+
end
|
314
|
+
end # def jacket=()
|
315
|
+
|
316
|
+
|
317
|
+
# Tag added to every entry
|
318
|
+
TagAll = '_all'
|
319
|
+
private_constant :TagAll
|
320
|
+
|
321
|
+
|
322
|
+
#####################################
|
323
|
+
# Generate next history
|
324
|
+
#
|
325
|
+
# @param [String] user User making the change
|
326
|
+
# @param [Array] ents Entries to update
|
327
|
+
# @param [Time] tme Time to use for the history
|
328
|
+
# @return [Array] the next history item, and the changes hash
|
329
|
+
# @raise (see #hash)
|
330
|
+
# @raise (see #process)
|
331
|
+
def next(user, ents, tme=nil)
|
332
|
+
raise Error::Sanity, 'History not complete' if !@history
|
333
|
+
nxt = History.new(@jacket)
|
334
|
+
cng = nxt.process(@history + 1, hash, @entry_max, user, ents, tme)
|
335
|
+
return [nxt, cng]
|
336
|
+
end # def next
|
337
|
+
|
338
|
+
|
339
|
+
#####################################
|
340
|
+
# Process entries
|
341
|
+
#
|
342
|
+
# @note Entries without a jacket set are automatically set to the correct
|
343
|
+
# value
|
344
|
+
# @note If time is not supplied, current time is used
|
345
|
+
#
|
346
|
+
# The changes returned consist of:
|
347
|
+
# * :entry - Array of entries provided in ents
|
348
|
+
# * :tag - Hash of tag => Hash of Entry => time_str or nil
|
349
|
+
# * :attach - Attached files \[entry_num, attach_num, file\]
|
350
|
+
#
|
351
|
+
# @param [Integer] hnum History number
|
352
|
+
# @param [String] prev Hash of previous item
|
353
|
+
# @param [Integer] emax Maximum previous entry number
|
354
|
+
# @param [String] user User making the change
|
355
|
+
# @param [Array] ents Entries to update
|
356
|
+
# @param [Time] tme Time to use for the history
|
357
|
+
# @return [Hash] Record of changes made
|
358
|
+
# @raise [Error::Sanity] if entry does not belong to the same jacket
|
359
|
+
# @raise (see Entry#update)
|
360
|
+
def process(hnum, prev, emax, user, ents, tme=nil)
|
361
|
+
|
362
|
+
cng = {}
|
363
|
+
add = []
|
364
|
+
|
365
|
+
# initial values
|
366
|
+
@hash = nil
|
367
|
+
@canon = nil
|
368
|
+
@previous = prev
|
369
|
+
@history = hnum
|
370
|
+
@entry_max = emax
|
371
|
+
if tme
|
372
|
+
@time = tme.utc
|
373
|
+
else
|
374
|
+
@time = Time.now.utc
|
375
|
+
end
|
376
|
+
@user = user.dup
|
377
|
+
@entries = []
|
378
|
+
@attach = []
|
379
|
+
|
380
|
+
# process the entries
|
381
|
+
ents.each do |entry|
|
382
|
+
# set/check jacket
|
383
|
+
if !entry.jacket
|
384
|
+
entry.jacket = @jacket
|
385
|
+
elsif entry.jacket != @jacket
|
386
|
+
raise Error::Sanity, 'Entry belongs to different jacket'
|
387
|
+
end
|
388
|
+
|
389
|
+
# set entry for new entries
|
390
|
+
if !entry.entry
|
391
|
+
@entry_max += 1
|
392
|
+
entry.entry = @entry_max
|
393
|
+
end
|
394
|
+
|
395
|
+
# update the entry
|
396
|
+
ecng = entry.update(@history)
|
397
|
+
enum = entry.entry
|
398
|
+
ts = entry.time_str
|
399
|
+
|
400
|
+
# time changed, all tags update
|
401
|
+
if ecng[:time]
|
402
|
+
cng[TagAll] = {} if !cng[TagAll]
|
403
|
+
cng[TagAll][enum] = ts
|
404
|
+
entry.tags.each do |tag|
|
405
|
+
cng[tag] = {} if !cng[tag]
|
406
|
+
cng[tag][enum] = ts
|
407
|
+
end
|
408
|
+
|
409
|
+
# just new tags
|
410
|
+
else
|
411
|
+
ecng[:tags_add].each do |tag|
|
412
|
+
cng[tag] = {} if !cng[tag]
|
413
|
+
cng[tag][enum] = ts
|
414
|
+
end
|
415
|
+
end
|
416
|
+
|
417
|
+
# deleted tags
|
418
|
+
ecng[:tags_del].each do |tag|
|
419
|
+
cng[tag] = {} if !cng[tag]
|
420
|
+
cng[tag][enum] = nil
|
421
|
+
end
|
422
|
+
|
423
|
+
# record entry and attachments
|
424
|
+
@entries.push [enum, entry.revision, entry.hash]
|
425
|
+
ecng[:files].each do |anum, ary|
|
426
|
+
file, hash = ary
|
427
|
+
@attach.push [enum, anum, hash]
|
428
|
+
add.push [enum, anum, file]
|
429
|
+
end
|
430
|
+
|
431
|
+
end
|
432
|
+
|
433
|
+
ret = {
|
434
|
+
:entry => ents,
|
435
|
+
:tag => cng,
|
436
|
+
:attach => add,
|
437
|
+
}
|
438
|
+
|
439
|
+
return ret
|
440
|
+
end # def process()
|
441
|
+
|
442
|
+
|
443
|
+
end # class History
|
444
|
+
|
445
|
+
end # module Sgfa
|