ruby-keepassx 0.2.0beta11
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/.coveralls.yml +1 -0
- data/.gitignore +9 -0
- data/.rvmrc +1 -0
- data/.travis.yml +5 -0
- data/Gemfile +5 -0
- data/README.md +28 -0
- data/Rakefile +5 -0
- data/lib/keepassx.rb +56 -0
- data/lib/keepassx/aes_crypt.rb +19 -0
- data/lib/keepassx/database.rb +431 -0
- data/lib/keepassx/entry.rb +155 -0
- data/lib/keepassx/entry_field.rb +27 -0
- data/lib/keepassx/exceptions.rb +4 -0
- data/lib/keepassx/field.rb +205 -0
- data/lib/keepassx/group.rb +138 -0
- data/lib/keepassx/group_field.rb +22 -0
- data/lib/keepassx/header.rb +178 -0
- data/lib/keepassx/item.rb +128 -0
- data/lib/keepassx/utilities.rb +226 -0
- data/ruby-keepassx.gemspec +23 -0
- data/spec/fixtures/test_data_array.yaml +54 -0
- data/spec/fixtures/test_data_hash.yaml +36 -0
- data/spec/fixtures/test_database.kdb +0 -0
- data/spec/keepassx/database_spec.rb +338 -0
- data/spec/keepassx/entry_spec.rb +72 -0
- data/spec/keepassx/group_spec.rb +47 -0
- data/spec/spec_helper.rb +64 -0
- metadata +126 -0
@@ -0,0 +1,22 @@
|
|
1
|
+
module Keepassx
|
2
|
+
class GroupField
|
3
|
+
include Field
|
4
|
+
|
5
|
+
FIELD_TYPES = [
|
6
|
+
[0x0, 'ignored', :null],
|
7
|
+
[0x1, 'groupid', :int],
|
8
|
+
[0x2, 'group_name', :string],
|
9
|
+
[0x3, 'creation_time', :date],
|
10
|
+
[0x4, 'lastmod_time', :date],
|
11
|
+
[0x5, 'lastacc_time', :date],
|
12
|
+
[0x6, 'expire_time', :date],
|
13
|
+
[0x7, 'imageid', :int],
|
14
|
+
[0x8, 'level', :short],
|
15
|
+
[0x9, 'flags', :int],
|
16
|
+
[0xFFFF, 'terminator', :null]
|
17
|
+
]
|
18
|
+
|
19
|
+
attr_reader :name, :data_type, :type_code
|
20
|
+
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,178 @@
|
|
1
|
+
# The keepass file header.
|
2
|
+
#
|
3
|
+
# From the KeePass doc:
|
4
|
+
#
|
5
|
+
# Database header: [DBHDR]
|
6
|
+
#
|
7
|
+
# [ 4 bytes] DWORD dwSignature1 = 0x9AA2D903
|
8
|
+
# [ 4 bytes] DWORD dwSignature2 = 0xB54BFB65
|
9
|
+
# [ 4 bytes] DWORD dwFlags
|
10
|
+
# [ 4 bytes] DWORD dwVersion { Ve.Ve.Mj.Mj:Mn.Mn.Bl.Bl }
|
11
|
+
# [16 bytes] BYTE{16} aMasterSeed
|
12
|
+
# [16 bytes] BYTE{16} aEncryptionIV
|
13
|
+
# [ 4 bytes] DWORD dwGroups Number of groups in database
|
14
|
+
# [ 4 bytes] DWORD dwEntries Number of entries in database
|
15
|
+
# [32 bytes] BYTE{32} aContentsHash SHA-256 hash value of the plain contents
|
16
|
+
# [32 bytes] BYTE{32} aMasterSeed2 Used for the dwKeyEncRounds AES
|
17
|
+
# master key transformations
|
18
|
+
# [ 4 bytes] DWORD dwKeyEncRounds See above; number of transformations
|
19
|
+
#
|
20
|
+
# Notes:
|
21
|
+
#
|
22
|
+
# - dwFlags is a bitmap, which can include:
|
23
|
+
# * PWM_FLAG_SHA2 (1) for SHA-2.
|
24
|
+
# * PWM_FLAG_RIJNDAEL (2) for AES (Rijndael).
|
25
|
+
# * PWM_FLAG_ARCFOUR (4) for ARC4.
|
26
|
+
# * PWM_FLAG_TWOFISH (8) for Twofish.
|
27
|
+
# - aMasterSeed is a salt that gets hashed with the transformed user master key
|
28
|
+
# to form the final database data encryption/decryption key.
|
29
|
+
# * FinalKey = SHA-256(aMasterSeed, TransformedUserMasterKey)
|
30
|
+
# - aEncryptionIV is the initialization vector used by AES/Twofish for
|
31
|
+
# encrypting/decrypting the database data.
|
32
|
+
# - aContentsHash: "plain contents" refers to the database file, minus the
|
33
|
+
# database header, decrypted by FinalKey.
|
34
|
+
# * PlainContents = Decrypt_with_FinalKey(DatabaseFile - DatabaseHeader)
|
35
|
+
module Keepassx
|
36
|
+
class Header
|
37
|
+
|
38
|
+
ENCRYPTION_FLAGS = [
|
39
|
+
[1, 'SHA2'],
|
40
|
+
[2, 'Rijndael'],
|
41
|
+
[2, 'AES'],
|
42
|
+
[4, 'ArcFour'],
|
43
|
+
[8, 'TwoFish']
|
44
|
+
]
|
45
|
+
|
46
|
+
FIELDS = [
|
47
|
+
:signature1,
|
48
|
+
:signature2,
|
49
|
+
:flags,
|
50
|
+
:version,
|
51
|
+
:master_seed,
|
52
|
+
:encryption_iv,
|
53
|
+
:group_number,
|
54
|
+
:entry_number,
|
55
|
+
:contents_hash,
|
56
|
+
:master_seed2,
|
57
|
+
:rounds
|
58
|
+
]
|
59
|
+
|
60
|
+
SIGNATURES = [0x9AA2D903, 0xB54BFB65]
|
61
|
+
|
62
|
+
attr_accessor :group_number, :entry_number, :encryption_iv, :contents_hash
|
63
|
+
attr_reader :signature1, :signature2, :flags, :version, :master_seed,
|
64
|
+
:master_seed2, :rounds
|
65
|
+
|
66
|
+
|
67
|
+
def initialize(header_bytes = nil)
|
68
|
+
if header_bytes.nil?
|
69
|
+
self.signature1, self.signature2 = SIGNATURES
|
70
|
+
self.flags = 3 # SHA2 hashing, AES encryption
|
71
|
+
self.version = 0x30002
|
72
|
+
|
73
|
+
self.master_seed = SecureRandom.random_bytes(16)
|
74
|
+
self.encryption_iv = SecureRandom.random_bytes(16)
|
75
|
+
self.master_seed2 = SecureRandom.random_bytes(32)
|
76
|
+
|
77
|
+
self.group_number = 0
|
78
|
+
self.entry_number = 0
|
79
|
+
self.rounds = 50000
|
80
|
+
else
|
81
|
+
header_bytes = StringIO.new header_bytes
|
82
|
+
self.signature1 = header_bytes.read(4).unpack('L*').first
|
83
|
+
self.signature2 = header_bytes.read(4).unpack('L*').first
|
84
|
+
self.flags = header_bytes.read(4).unpack('L*').first
|
85
|
+
self.version = header_bytes.read(4).unpack('L*').first
|
86
|
+
self.master_seed = header_bytes.read(16)
|
87
|
+
self.encryption_iv = header_bytes.read(16)
|
88
|
+
self.group_number = header_bytes.read(4).unpack('L*').first
|
89
|
+
self.entry_number = header_bytes.read(4).unpack('L*').first
|
90
|
+
self.contents_hash = header_bytes.read(32)
|
91
|
+
self.master_seed2 = header_bytes.read(32)
|
92
|
+
self.rounds = header_bytes.read(4).unpack('L*').first
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
|
97
|
+
def length
|
98
|
+
length = 0
|
99
|
+
FIELDS.each do |field|
|
100
|
+
length += field.length
|
101
|
+
end
|
102
|
+
length
|
103
|
+
end
|
104
|
+
|
105
|
+
|
106
|
+
# Return encoded header
|
107
|
+
#
|
108
|
+
# @return [String] Encoded header representation.
|
109
|
+
def encode
|
110
|
+
[signature1].pack('L*') <<
|
111
|
+
[signature2].pack('L*') <<
|
112
|
+
[flags].pack('L*') <<
|
113
|
+
[version].pack('L*') <<
|
114
|
+
master_seed <<
|
115
|
+
encryption_iv <<
|
116
|
+
[group_number].pack('L*') <<
|
117
|
+
[entry_number].pack('L*') <<
|
118
|
+
contents_hash <<
|
119
|
+
master_seed2 <<
|
120
|
+
[rounds].pack('L*')
|
121
|
+
|
122
|
+
end
|
123
|
+
|
124
|
+
|
125
|
+
def valid?
|
126
|
+
signature1 == SIGNATURES[0] && signature2 == SIGNATURES[1]
|
127
|
+
end
|
128
|
+
|
129
|
+
|
130
|
+
def encryption_type
|
131
|
+
ENCRYPTION_FLAGS.each do |(flag_mask, encryption_type)|
|
132
|
+
return encryption_type if flags & flag_mask
|
133
|
+
end
|
134
|
+
'Unknown'
|
135
|
+
end
|
136
|
+
|
137
|
+
|
138
|
+
def final_key(master_key, keyfile_data=nil)
|
139
|
+
key = Digest::SHA2.new.update(master_key).digest
|
140
|
+
|
141
|
+
if keyfile_data
|
142
|
+
if keyfile_data.size == 64 # Hex encoded key
|
143
|
+
keyfile_hash = [keyfile_data].pack("H*")
|
144
|
+
elsif keyfile_data.size == 32 # Raw key
|
145
|
+
keyfile_hash = keyfile_data
|
146
|
+
else
|
147
|
+
keyfile_hash = Digest::SHA2.new.update(keyfile_data).digest
|
148
|
+
end
|
149
|
+
|
150
|
+
if master_key == ""
|
151
|
+
key = keyfile_hash
|
152
|
+
else
|
153
|
+
key = Digest::SHA2.new.update(key + keyfile_hash).digest
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
aes = OpenSSL::Cipher::Cipher.new('AES-256-ECB')
|
158
|
+
aes.encrypt
|
159
|
+
aes.key = master_seed2
|
160
|
+
aes.padding = 0
|
161
|
+
|
162
|
+
rounds.times do
|
163
|
+
key = aes.update(key) + aes.final
|
164
|
+
end
|
165
|
+
|
166
|
+
key = Digest::SHA2.new.update(key).digest
|
167
|
+
key = Digest::SHA2.new.update(master_seed + key).digest
|
168
|
+
|
169
|
+
end
|
170
|
+
|
171
|
+
|
172
|
+
private
|
173
|
+
|
174
|
+
attr_writer :signature1, :signature2, :flags, :version, :master_seed,
|
175
|
+
:master_seed2, :rounds
|
176
|
+
|
177
|
+
end
|
178
|
+
end
|
@@ -0,0 +1,128 @@
|
|
1
|
+
module Keepassx
|
2
|
+
|
3
|
+
module Item
|
4
|
+
|
5
|
+
|
6
|
+
def initialize _
|
7
|
+
@fields = []
|
8
|
+
end
|
9
|
+
|
10
|
+
def length
|
11
|
+
@fields.map(&:length).reduce(&:+)
|
12
|
+
end
|
13
|
+
|
14
|
+
|
15
|
+
def encode
|
16
|
+
buffer = ''
|
17
|
+
# FIXME: Check if terminator field not in @fields
|
18
|
+
@fields.each do |field|
|
19
|
+
buffer << field.encode
|
20
|
+
end
|
21
|
+
buffer
|
22
|
+
end
|
23
|
+
|
24
|
+
|
25
|
+
# TODO: Remove debug method
|
26
|
+
def dump
|
27
|
+
@fields
|
28
|
+
end
|
29
|
+
|
30
|
+
|
31
|
+
def to_hash
|
32
|
+
result = {}
|
33
|
+
self.class.fields.each do |field|
|
34
|
+
|
35
|
+
filed = field.to_sym
|
36
|
+
if self.respond_to? field
|
37
|
+
data = self.send field
|
38
|
+
# Skip empty value
|
39
|
+
if data.respond_to? :empty?
|
40
|
+
result[field] = data unless data.empty?
|
41
|
+
else
|
42
|
+
unless data.nil?
|
43
|
+
# FIXME: Implement field export method
|
44
|
+
if data.is_a? Time
|
45
|
+
result[field] = data.to_i
|
46
|
+
else
|
47
|
+
result[field] = data
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
end
|
54
|
+
# warn "result: #{result}"
|
55
|
+
result
|
56
|
+
end
|
57
|
+
|
58
|
+
|
59
|
+
def terminator= _
|
60
|
+
self.class::FIELD_CLASS.new :name => :terminator
|
61
|
+
end
|
62
|
+
|
63
|
+
|
64
|
+
def inspect
|
65
|
+
output = "<#{self.class} "
|
66
|
+
default_fields.each_key do |field_name|
|
67
|
+
if field_name.eql? :password
|
68
|
+
output << ', password=[FILTERED]'
|
69
|
+
else
|
70
|
+
output << ", #{field_name}=#{self.send(field_name)}" unless
|
71
|
+
field_name.eql? :terminator
|
72
|
+
end
|
73
|
+
end
|
74
|
+
output << '>'
|
75
|
+
end
|
76
|
+
|
77
|
+
|
78
|
+
def to_xml
|
79
|
+
section_name = (self.is_a? Keepassx::Group) && 'group' || 'entry'
|
80
|
+
main_section = REXML::Element.new section_name
|
81
|
+
default_fields.each_key do |field_name|
|
82
|
+
unless field_name.eql? :terminator
|
83
|
+
filed_section = main_section.add_element field_name.to_s
|
84
|
+
filed_section.text = self.send field_name
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
main_section
|
89
|
+
end
|
90
|
+
|
91
|
+
|
92
|
+
private
|
93
|
+
|
94
|
+
def decode(buffer)
|
95
|
+
@fields = []
|
96
|
+
loop do
|
97
|
+
field = self.class::FIELD_CLASS.new(buffer)
|
98
|
+
@fields << field
|
99
|
+
break if field.terminator?
|
100
|
+
end
|
101
|
+
@fields
|
102
|
+
end
|
103
|
+
|
104
|
+
|
105
|
+
def set(name, value)
|
106
|
+
field = @fields.detect { |field| field.name.eql? name.to_s }
|
107
|
+
if field.nil?
|
108
|
+
field = self.class::FIELD_CLASS.new :name => name, :data => value
|
109
|
+
else
|
110
|
+
field.data = value
|
111
|
+
end
|
112
|
+
field
|
113
|
+
end
|
114
|
+
|
115
|
+
|
116
|
+
def get(name)
|
117
|
+
field = @fields.detect { |field| field.name.eql? name.to_s }
|
118
|
+
# if field.nil?
|
119
|
+
# # FIXME: Put proper field name into 'Field doesn't exists' exception
|
120
|
+
# # fail "Field '#{name}' doesn't exists or not yet created"
|
121
|
+
# else
|
122
|
+
# field.data
|
123
|
+
# end
|
124
|
+
field.data unless field.nil? # Return nil, if field doesn't exist
|
125
|
+
end
|
126
|
+
|
127
|
+
end
|
128
|
+
end
|
@@ -0,0 +1,226 @@
|
|
1
|
+
module Keepassx
|
2
|
+
module Utilities
|
3
|
+
|
4
|
+
|
5
|
+
private
|
6
|
+
|
7
|
+
# Organize descendants in proper structure for given group.
|
8
|
+
# @param [Keepassx::Group] group Root group, branch is build for.
|
9
|
+
def build_branch group
|
10
|
+
group_hash = group.to_hash
|
11
|
+
group_entries = entries :group => group
|
12
|
+
descendant_groups = groups :parent => group
|
13
|
+
|
14
|
+
unless group_entries.nil?
|
15
|
+
group_hash[:entries] = []
|
16
|
+
entries.each { |e| group_hash[:entries] << e.to_hash }
|
17
|
+
end
|
18
|
+
|
19
|
+
unless descendant_groups.nil?
|
20
|
+
group_hash[:groups] = []
|
21
|
+
# Recursively build branch for descendant groups
|
22
|
+
descendant_groups.each { |g| group_hash[:groups] << build_branch(g) }
|
23
|
+
end
|
24
|
+
|
25
|
+
group_hash
|
26
|
+
end
|
27
|
+
|
28
|
+
|
29
|
+
def deep_copy opts
|
30
|
+
Marshal.load Marshal.dump opts # Make deep copy
|
31
|
+
end
|
32
|
+
|
33
|
+
|
34
|
+
def decrypt
|
35
|
+
@payload = AESCrypt.decrypt @encrypted_payload, final_key,
|
36
|
+
header.encryption_iv, 'AES-256-CBC'
|
37
|
+
current_checksum = checksum
|
38
|
+
unless current_checksum.eql? header.contents_hash
|
39
|
+
raise Keepassx::HashError.new "Hash test failed, expected " \
|
40
|
+
"#{header.contents_hash.inspect}, got #{current_checksum.inspect}"
|
41
|
+
end
|
42
|
+
|
43
|
+
@payload
|
44
|
+
end
|
45
|
+
|
46
|
+
|
47
|
+
def encrypt
|
48
|
+
@encrypted_payload = AESCrypt.encrypt @payload, final_key,
|
49
|
+
header.encryption_iv, 'AES-256-CBC'
|
50
|
+
end
|
51
|
+
|
52
|
+
|
53
|
+
def read opts
|
54
|
+
read_method = File.respond_to?(:binread) && :binread || :read
|
55
|
+
File.send read_method, opts
|
56
|
+
|
57
|
+
# FIXME: Implement exceptions
|
58
|
+
rescue IOError => e
|
59
|
+
warn ">>>> IOError in database.rb"
|
60
|
+
fail
|
61
|
+
rescue SystemCallError => e
|
62
|
+
warn ">>>> SystemCallError in database.rb"
|
63
|
+
fail
|
64
|
+
end
|
65
|
+
|
66
|
+
|
67
|
+
def final_key
|
68
|
+
fail "No master password specified" if password.nil?
|
69
|
+
|
70
|
+
key_file_data = nil
|
71
|
+
if File.exists? key_file
|
72
|
+
read_method = File.respond_to?(:binread) ? :binread : :read
|
73
|
+
key_file_data = File.send read_method
|
74
|
+
end unless key_file.nil?
|
75
|
+
|
76
|
+
header.final_key(password, key_file_data)
|
77
|
+
end
|
78
|
+
|
79
|
+
|
80
|
+
def initialize_database raw_db
|
81
|
+
if raw_db.empty?
|
82
|
+
@header = Header.new
|
83
|
+
@locked = false
|
84
|
+
else
|
85
|
+
@header = Header.new raw_db[0..124]
|
86
|
+
@encrypted_payload = raw_db[124..-1]
|
87
|
+
end
|
88
|
+
|
89
|
+
@locked
|
90
|
+
end
|
91
|
+
|
92
|
+
|
93
|
+
# Set parents for groups
|
94
|
+
#
|
95
|
+
# @param [Array] list Array of groups.
|
96
|
+
# @return [Array] Updated array of groups.
|
97
|
+
def initialize_groups list
|
98
|
+
|
99
|
+
list.each_with_index do |group, index|
|
100
|
+
|
101
|
+
if index.eql? 0
|
102
|
+
previous_group = nil
|
103
|
+
else
|
104
|
+
previous_group = list[index - 1]
|
105
|
+
end
|
106
|
+
|
107
|
+
# If group is first entry or has level equal 0,
|
108
|
+
# it gets parent set to nil
|
109
|
+
if previous_group.nil? or group.level.eql? 0
|
110
|
+
group.parent = nil
|
111
|
+
|
112
|
+
# If group has level greater than parent's level by one,
|
113
|
+
# it gets parent set to the first previous group with level less
|
114
|
+
# than group's level by one
|
115
|
+
elsif group.level == previous_group.level + 1 or
|
116
|
+
group.level == previous_group.level
|
117
|
+
|
118
|
+
group.parent = previous_group
|
119
|
+
|
120
|
+
# If group has level less than or equal the level of the previous
|
121
|
+
# group and its level is no less than zero, then need to backward
|
122
|
+
# search for the first group which level is less than group's
|
123
|
+
# level by 1 and set it as a parent of the group
|
124
|
+
elsif 0 < group.level and group.level <= previous_group.level
|
125
|
+
group.parent = (index - 2).downto 0 do |i|
|
126
|
+
parent_candidate = list[i]
|
127
|
+
break parent_candidate if parent_candidate.level - 1 == group.level
|
128
|
+
end
|
129
|
+
|
130
|
+
# Invalid level
|
131
|
+
else
|
132
|
+
fail "Unexpected level '#{group.level}' for group '#{group.title}'"
|
133
|
+
end
|
134
|
+
|
135
|
+
end
|
136
|
+
|
137
|
+
@groups = list
|
138
|
+
end
|
139
|
+
|
140
|
+
|
141
|
+
def initialize_payload
|
142
|
+
result = ''
|
143
|
+
@groups.each { |group| result << group.encode }
|
144
|
+
@entries.each { |entry| result << entry.encode }
|
145
|
+
result
|
146
|
+
end
|
147
|
+
|
148
|
+
|
149
|
+
def payload
|
150
|
+
@payload ||= initialize_payload
|
151
|
+
end
|
152
|
+
|
153
|
+
|
154
|
+
# Retrieves last sibling index
|
155
|
+
#
|
156
|
+
# @param [Keepassx::Group] parent Last sibling group.
|
157
|
+
# @return [Integer] index Group index.
|
158
|
+
def last_sibling_index parent
|
159
|
+
|
160
|
+
if groups.empty?
|
161
|
+
return -1
|
162
|
+
|
163
|
+
elsif parent.nil?
|
164
|
+
parent_index = 0
|
165
|
+
sibling_level = 1
|
166
|
+
|
167
|
+
else
|
168
|
+
parent_index = groups.find_index parent
|
169
|
+
sibling_level = parent.level + 1
|
170
|
+
|
171
|
+
end
|
172
|
+
|
173
|
+
fail "Could not find group #{parent.title}" if parent_index.nil?
|
174
|
+
|
175
|
+
(parent_index..(header.group_number - 1)).each do |i|
|
176
|
+
break i unless groups[i].level.eql? sibling_level
|
177
|
+
end
|
178
|
+
|
179
|
+
end
|
180
|
+
|
181
|
+
|
182
|
+
# FIXME: Rename the method
|
183
|
+
# See spec/fixtures/test_data_array.yaml for data example
|
184
|
+
def parse_data_array opts
|
185
|
+
groups, entries = opts[:groups], opts[:entries]
|
186
|
+
|
187
|
+
# Remove groups and entries from options, so new group could be
|
188
|
+
# initialized from incoming Hash
|
189
|
+
fields = Keepassx::Group.fields
|
190
|
+
group_opts = opts.reject { |k, _| !fields.include? k }
|
191
|
+
group = add_group group_opts
|
192
|
+
|
193
|
+
entries.each do |e|
|
194
|
+
entry = e.clone
|
195
|
+
add_entry entry.merge(:group => group)
|
196
|
+
end unless entries.nil?
|
197
|
+
|
198
|
+
# Recursively proceed each child group
|
199
|
+
groups.each { |g| parse_data_array g } unless groups.nil?
|
200
|
+
end
|
201
|
+
|
202
|
+
|
203
|
+
def delete_entry item
|
204
|
+
item = entries.delete item
|
205
|
+
header.entry_number -= 1
|
206
|
+
item
|
207
|
+
end
|
208
|
+
|
209
|
+
|
210
|
+
# Recursively delete group
|
211
|
+
def delete_group item
|
212
|
+
group_entries = entries.select { |e| e.group.equal? item }
|
213
|
+
# Delete entries, which belongs to group
|
214
|
+
group_entries.each { |e| delete_entry e }
|
215
|
+
|
216
|
+
group_ancestors = groups.select { |g| g.parent.equal? item }
|
217
|
+
# Recursively delete ancestor groups
|
218
|
+
group_ancestors.each { |g| delete_group g }
|
219
|
+
|
220
|
+
item = groups.delete item
|
221
|
+
header.group_number -= 1
|
222
|
+
item
|
223
|
+
end
|
224
|
+
|
225
|
+
end
|
226
|
+
end
|