s7n 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.
Files changed (47) hide show
  1. data/.gitignore +7 -0
  2. data/Gemfile +4 -0
  3. data/Gemfile.lock +28 -0
  4. data/LICENCE +24 -0
  5. data/README.rdoc +54 -0
  6. data/Rakefile +67 -0
  7. data/bin/s7ncli +23 -0
  8. data/lib/s7n.rb +12 -0
  9. data/lib/s7n/action.rb +7 -0
  10. data/lib/s7n/attribute.rb +226 -0
  11. data/lib/s7n/cipher.rb +110 -0
  12. data/lib/s7n/configuration.rb +41 -0
  13. data/lib/s7n/entry.rb +106 -0
  14. data/lib/s7n/entry_collection.rb +44 -0
  15. data/lib/s7n/entry_template.rb +10 -0
  16. data/lib/s7n/exception.rb +116 -0
  17. data/lib/s7n/file.rb +77 -0
  18. data/lib/s7n/gpass_file.rb +203 -0
  19. data/lib/s7n/key.rb +83 -0
  20. data/lib/s7n/message_catalog.rb +5 -0
  21. data/lib/s7n/s7n_file.rb +47 -0
  22. data/lib/s7n/s7ncli.rb +226 -0
  23. data/lib/s7n/s7ncli/attribute_command.rb +83 -0
  24. data/lib/s7n/s7ncli/command.rb +215 -0
  25. data/lib/s7n/s7ncli/entry_collection_command.rb +63 -0
  26. data/lib/s7n/s7ncli/entry_command.rb +728 -0
  27. data/lib/s7n/s7ncli/option.rb +101 -0
  28. data/lib/s7n/secret_generator.rb +30 -0
  29. data/lib/s7n/undo_stack.rb +7 -0
  30. data/lib/s7n/unicode_data.rb +29 -0
  31. data/lib/s7n/utils.rb +37 -0
  32. data/lib/s7n/version.rb +3 -0
  33. data/lib/s7n/world.rb +158 -0
  34. data/po/ja/s7n.po +533 -0
  35. data/po/s7n.pot +533 -0
  36. data/s7n.gemspec +30 -0
  37. data/test/s7n/attribute_test.rb +50 -0
  38. data/test/s7n/gpass_file_test.rb +169 -0
  39. data/test/s7n/gpass_file_test/passwords.gps.empty +1 -0
  40. data/test/s7n/gpass_file_test/passwords.gps.one_entry +2 -0
  41. data/test/s7n/gpass_file_test/passwords.gps.three_entries +3 -0
  42. data/test/s7n/gpass_file_test/passwords.gps.with_folders +0 -0
  43. data/test/s7n/secret_generator_test.rb +29 -0
  44. data/test/s7n/unicode_data_test.rb +28 -0
  45. data/test/s7n/world_test.rb +35 -0
  46. data/test/test_helper.rb +11 -0
  47. metadata +153 -0
@@ -0,0 +1,83 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require "s7n/s7ncli/command"
4
+ require "open3"
5
+
6
+ module S7n
7
+ class S7nCli
8
+ # 機密情報の属性のうち、指定したものの値をクリップボードにコピーするコ
9
+ # マンドを表現する。
10
+ class AttributeCopyCommand < Command
11
+ command("copy",
12
+ ["attribute:copy",
13
+ "c"],
14
+ _("Copy the attribute value to the clipboard."),
15
+ _("usage: copy [ID] [ATTRIBUTE NAME] [OPTIONS]"))
16
+
17
+ def initialize(*args)
18
+ super
19
+ copy_commands = ["/usr/bin/pbcopy", "/usr/bin/xclip"]
20
+ copy_commands.each do |cmd|
21
+ if File.executable?(cmd)
22
+ @config["copy_command"] = cmd
23
+ end
24
+ end
25
+ @config["inclement_rate"] = true
26
+ @options.push(IntOption.new("i", "id", "ID", _("Entry ID.")),
27
+ StringOption.new("n", "name", _("Attribute name.")),
28
+ BoolOption.new("inclement_rate",
29
+ _("Inclement the entry's rate after copied.")),
30
+ BoolOption.new("l", "lock",
31
+ _("Lock screen after copied.")),
32
+ StringOption.new("copy_command", _("Copy command.")))
33
+ end
34
+
35
+ # コマンドを実行する。
36
+ def run(argv)
37
+ option_parser.parse!(argv)
38
+ if argv.length > 0
39
+ config["id"] = argv.shift.to_i
40
+ end
41
+ if argv.length > 0
42
+ config["name"] = argv.shift
43
+ else
44
+ config["name"] = "password"
45
+ end
46
+ if config["id"].nil? || config["name"].nil?
47
+ puts(_("Too few arguments."))
48
+ help
49
+ return true
50
+ end
51
+ puts(_("Copy the attribute value to the clipboard."))
52
+ entry = world.entry_collection.find(config["id"])
53
+ if entry.nil?
54
+ raise NoSuchEntry.new("id" => config["id"])
55
+ end
56
+ attr = entry.get_attribute(config["name"])
57
+ if attr.nil?
58
+ raise NoSuchAttribute.new("id" => config["id"],
59
+ "name" => config["name"])
60
+ end
61
+ copy_command = @config["copy_command"]
62
+ res, status = Open3.capture2(copy_command,
63
+ stdin_data: attr.value.to_s,
64
+ binmode: true)
65
+ if status != 0
66
+ raise CommandFailed.new(copy_command, status)
67
+ end
68
+ if config["inclement_rate"]
69
+ # TODO: undo スタックに操作を追加する。
70
+ entry.rate += 1
71
+ puts(_("Changed rate to %d.") % entry.rate)
72
+ world.changed = true
73
+ end
74
+ if config["lock"]
75
+ sleep(1)
76
+ lock_command = Command.create_instance("lock", world)
77
+ return lock_command.run([])
78
+ end
79
+ return true
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,215 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require "gettext"
4
+
5
+ module S7n
6
+ class S7nCli
7
+ # コマンドを表現する。
8
+ class Command
9
+ include GetText
10
+
11
+ # オプションの配列。
12
+ attr_reader :options
13
+
14
+ # 設定。
15
+ attr_reader :config
16
+
17
+ # World オブジェクト。
18
+ attr_reader :world
19
+
20
+ @@command_classes = {}
21
+
22
+ class << self
23
+ # s で指定した文字列をコマンド名とオプションに分割する。
24
+ def split_name_and_argv(s)
25
+ s = s.strip
26
+ name = s.slice!(/\A\w+/)
27
+ argv = s.split
28
+ return name, argv
29
+ end
30
+
31
+ def create_instance(name, world)
32
+ klass = @@command_classes[name]
33
+ if klass
34
+ return klass.new(world)
35
+ else
36
+ return nil
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def command(name, aliases, description, usage)
43
+ @@command_classes[name] = self
44
+ self.const_set("NAME", name)
45
+ aliases.each do |s|
46
+ @@command_classes[s] = self
47
+ end
48
+ self.const_set("ALIASES", aliases)
49
+ self.const_set("DESCRIPTION", description)
50
+ self.const_set("USAGE", usage)
51
+ end
52
+ end
53
+
54
+ def initialize(world)
55
+ @world = world
56
+ @config = {}
57
+ @options = []
58
+ end
59
+
60
+ # コマンド名を取得する。
61
+ def name
62
+ return self.class.const_get("NAME")
63
+ end
64
+
65
+ # 別名の配列を取得する。
66
+ def aliases
67
+ return self.class.const_get("ALIASES")
68
+ end
69
+
70
+ # 概要を取得する。
71
+ def description
72
+ return self.class.const_get("DESCRIPTION")
73
+ end
74
+
75
+ # 使用例を取得する。
76
+ def usage
77
+ return self.class.const_get("USAGE")
78
+ end
79
+
80
+ # コマンドラインオプションを解析するための OptionParser オブジェ
81
+ # クトを取得する。
82
+ def option_parser
83
+ return OptionParser.new { |opts|
84
+ opts.banner = [
85
+ "#{name} (#{aliases.join(",")}): #{description}",
86
+ usage,
87
+ ].join("\n")
88
+ if options && options.length > 0
89
+ opts.separator("")
90
+ opts.separator("Valid options:")
91
+ for option in options
92
+ option.define(opts, config)
93
+ end
94
+ end
95
+ }
96
+ end
97
+
98
+ # 使用方法を示す文字列を返す。
99
+ def help
100
+ puts(option_parser)
101
+ return
102
+ end
103
+
104
+ private
105
+
106
+ # キャンセルした旨を出力し、true を返す。
107
+ # Command オブジェクトの run メソッドで return canceled という使
108
+ # い方を想定している。
109
+ def canceled
110
+ puts("\n" + Canceled::MESSAGE)
111
+ return true
112
+ end
113
+ end
114
+
115
+ # プログラムを終了するコマンドを表現する。
116
+ class HelpCommand < Command
117
+ command("help",
118
+ ["usage", "h"],
119
+ _("Describe the commands."),
120
+ _("usage: help [COMMAND]"))
121
+
122
+ def run(argv)
123
+ if argv.length > 0
124
+ command_name = argv.shift
125
+ command = Command.create_instance(command_name, world)
126
+ if command
127
+ command.help
128
+ else
129
+ puts(_("Unknown command: %s") % command_name)
130
+ end
131
+ else
132
+ puts(_("Type 'help <command>' for help on a specific command."))
133
+ puts("")
134
+ puts(_("Available commands:"))
135
+ command_classes = @@command_classes.values.uniq.sort_by { |klass|
136
+ klass.const_get("NAME")
137
+ }
138
+ command_classes.each do |klass|
139
+ s = " #{klass.const_get("NAME")}"
140
+ aliases = klass.const_get("ALIASES")
141
+ if aliases.length > 0
142
+ s << " (#{aliases.sort_by { |s| s.length}.first})"
143
+ end
144
+ puts(s)
145
+ end
146
+ end
147
+ return true
148
+ end
149
+ end
150
+
151
+ # 現在の状態を保存するコマンドを表現する。
152
+ class SaveCommand < Command
153
+ command("save", [], _("Save."), _("usage: save"))
154
+
155
+ def initialize(*args)
156
+ super
157
+ @options.push(BoolOption.new("f", "force", _("Force.")))
158
+ end
159
+
160
+ def run(argv)
161
+ option_parser.parse!(argv)
162
+ world.save(config["force"])
163
+ puts(_("Saved."))
164
+ return true
165
+ end
166
+ end
167
+
168
+ # スクリーンロックし、マスターキーを入力するまでは操作できないよう
169
+ # にするコマンドを表現する。
170
+ class LockCommand < Command
171
+ command("lock", [], _("Lock screen after saved."), _("usage: lock"))
172
+
173
+ def run(argv)
174
+ system("clear")
175
+ puts(_("Locked screen."))
176
+ world.save
177
+ prompt = _("Enter the master key for s7n: ")
178
+ begin
179
+ input = S7Cli.input_string_without_echo(prompt)
180
+ if input != world.master_key
181
+ raise InvalidPassphrase
182
+ end
183
+ puts(_("Unlocked."))
184
+ return true
185
+ rescue Interrupt
186
+ rescue ApplicationError => e
187
+ puts($!.message + _("Try again."))
188
+ retry
189
+ rescue Exception => e
190
+ puts(e.message)
191
+ if world.debug
192
+ puts(_("----- back trace -----"))
193
+ e.backtrace.each do |line|
194
+ puts(" #{line}")
195
+ end
196
+ end
197
+ end
198
+ puts(_("Quit."))
199
+ return false
200
+ end
201
+ end
202
+
203
+ # プログラムを終了するコマンドを表現する。
204
+ class QuitCommand < Command
205
+ command("quit",
206
+ ["exit", "q"],
207
+ _("Quit."),
208
+ _("usage: quit"))
209
+
210
+ def run(argv)
211
+ return false
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,63 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require "s7n/s7ncli/command"
4
+
5
+ module S7n
6
+ class S7nCli
7
+ # 機密情報をインポートするコマンドを表現する。
8
+ class EntryCollectionImportCommand < Command
9
+ command("import",
10
+ ["entry_collection:import",
11
+ "entry_collection:read",
12
+ "entry_collection:load",
13
+ "read",
14
+ "load"],
15
+ _("Import the entry colleciton from file."),
16
+ _("usage: import [PATH] [OPTIONS]"))
17
+
18
+ def initialize(*args)
19
+ super
20
+ @config["type"] = "gpass"
21
+ @config["path"] = "~/.gpass/passwords.gps"
22
+ @options.push(StringOption.new("type", _("type")),
23
+ StringOption.new("path", _("path")))
24
+ end
25
+
26
+ # コマンドを実行する。
27
+ def run(argv)
28
+ option_parser.parse!(argv)
29
+ if argv.length > 0
30
+ config["path"] = argv.shift
31
+ end
32
+ case config["type"]
33
+ when "gpass"
34
+ path = File.expand_path(config["path"])
35
+ if !File.file?(path)
36
+ raise NotExist.new(path)
37
+ end
38
+ puts(_("Import the entry collection in the GPass file: %s") % path)
39
+ gpass_file = GPassFile.new
40
+ begin
41
+ msg = _("Enter the master passphrase for GPass(^D is cancel): ")
42
+ passphrase = S7nCli.input_string_without_echo(msg)
43
+ if passphrase
44
+ ec = gpass_file.read(passphrase, path)
45
+ # TODO: UNDO スタックにタグの追加と機密情報の追加の操作を積む。
46
+ world.entry_collection.merge(ec)
47
+ world.changed = true
48
+ puts(_("Imported %d entries.") % ec.entries.length)
49
+ else
50
+ canceled
51
+ end
52
+ rescue InvalidPassphrase => e
53
+ puts(e.message)
54
+ retry
55
+ end
56
+ else
57
+ raise ArgumentError, _("not supported type: %s") % config["type"]
58
+ end
59
+ return true
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,728 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require "s7n/s7ncli/command"
4
+ require "s7n/secret_generator"
5
+ require "s7n/unicode_data"
6
+
7
+ module S7n
8
+ class S7nCli
9
+ # 機密情報の操作をするコマンドで使用するメソッド群。
10
+ module EntryCommandUtils
11
+ include GetText
12
+
13
+ EDITING_USAGE =
14
+ ["-----",
15
+ _("<n>,e<n>:edit attribute a:add attribute d<n>:delete attribute"),
16
+ _("t:edit tags s:save")].join("\n")
17
+
18
+ # ユーザに属性名を入力するように促し、入力された値を返す。
19
+ def input_attribute_name(default = nil)
20
+ names = (@entry.attributes.collect(&:name) +
21
+ @attributes.collect(&:name)).uniq
22
+ if default
23
+ s = " (#{default})"
24
+ else
25
+ s = ""
26
+ end
27
+ prompt = _("Attribute name%s: ") % s
28
+ begin
29
+ if input = S7nCli.input_string(prompt)
30
+ input.strip!
31
+ if input.empty?
32
+ input = default
33
+ elsif names.include?(input)
34
+ raise ApplicationError, _("Already exist: %s") % input
35
+ end
36
+ end
37
+ return input
38
+ rescue ApplicationError => e
39
+ puts("")
40
+ puts(e.message)
41
+ retry
42
+ end
43
+ end
44
+
45
+ # ユーザに属性の値を入力するように促し、入力された値を返す。
46
+ # ^D を入力されて、なおかつ protected が false の場合、nil を返す。
47
+ def input_value(value, secret, protected)
48
+ if value.length > 0
49
+ current = _(" (%s)") % value
50
+ else
51
+ current = ""
52
+ end
53
+ prompt = _("Value%s: ") % current
54
+ begin
55
+ if secret
56
+ if input = S7nCli.input_string_without_echo(prompt)
57
+ confirmation =
58
+ S7nCli.input_string_without_echo(_("Comfirm value: "))
59
+ if input != confirmation
60
+ raise ApplicationError, _("Mismatched.")
61
+ end
62
+ end
63
+ else
64
+ input = S7nCli.input_string(prompt)
65
+ end
66
+ if input
67
+ input.strip!
68
+ if input.empty? && value
69
+ input = value
70
+ end
71
+ end
72
+ if protected && (input.nil? || input.empty?)
73
+ raise ApplicationError, _("The value is empty.")
74
+ end
75
+ rescue ApplicationError => e
76
+ puts("")
77
+ puts(e.message + _("Try again."))
78
+ retry
79
+ end
80
+ return input
81
+ end
82
+
83
+ # ユーザにタグの入力を促す。入力内容を解析し、タグ名の配列を返す。
84
+ # ^D を入力された場合、nil を返す。
85
+ def input_tags(tags)
86
+ if tags.length > 0
87
+ current = " (" + tags.join(", ") + ")"
88
+ else
89
+ current = ""
90
+ end
91
+ prompt = _("Edit tags%s: ") % current
92
+ if input = S7nCli.input_string(prompt)
93
+ return input.split(",").collect { |t| t.strip }
94
+ else
95
+ return nil
96
+ end
97
+ end
98
+
99
+ # ユーザに機密情報かどうかの入力を促す。
100
+ def input_secret(default)
101
+ prompt = _("Is this a secret? (%s): ") % (default ? "yes" : "no")
102
+ return S7nCli.input_boolean(prompt, default)
103
+ end
104
+
105
+ # ユーザに属性の型の入力を促す。
106
+ def input_attribute_type
107
+ prompt = _("Attribute type: ")
108
+ types = Attribute.types
109
+ Readline.completion_proc = Proc.new { |text|
110
+ begin
111
+ s = Readline.line_buffer.lstrip
112
+ rescue NotImplementedError, NoMethodError
113
+ s = text
114
+ end
115
+ types.select { |type|
116
+ type =~ /\A#{s}/
117
+ }
118
+ }
119
+ begin
120
+ if input = S7nCli.input_string(prompt)
121
+ input.strip!
122
+ if !types.include?(input)
123
+ raise ApplicationError, _("Unknown type: %s") % input
124
+ end
125
+ end
126
+ return input
127
+ rescue ApplicationError => e
128
+ puts(e.message)
129
+ puts(_("Try again."))
130
+ retry
131
+ ensure
132
+ begin
133
+ Readline.completion_proc = nil
134
+ rescue ArgumentError
135
+ end
136
+ end
137
+ end
138
+
139
+ # ユーザにコマンドの入力を促す。終了コマンドを入力するまで繰り返す。
140
+ def command_loop
141
+ begin
142
+ loop do
143
+ begin
144
+ puts("-----")
145
+ @attributes.each.with_index do |attr, i|
146
+ value = attr.display_value
147
+ printf(" %2d) %s: %s\n", i, _(attr.name), value)
148
+ end
149
+ printf(" t) %s: %s\n", _("tags"), @tags.join(", "))
150
+ puts("-----")
151
+ prompt = _("Command (h:help, s:save, ^C:cancel): ")
152
+ input = S7nCli.input_string(prompt)
153
+ if input.nil?
154
+ raise Interrupt
155
+ end
156
+ begin
157
+ if !dispatch_command(input)
158
+ break
159
+ end
160
+ rescue Interrupt
161
+ raise Canceled
162
+ end
163
+ rescue ApplicationError => e
164
+ puts(e.message)
165
+ end
166
+ end
167
+ rescue Interrupt
168
+ canceled
169
+ end
170
+ end
171
+
172
+ # ユーザからの入力 input を解析し、適切なコマンドを処理する。
173
+ def dispatch_command(input)
174
+ case input
175
+ when /\A\s*(\d+)\z/, /\Ae\s*(\d+)\z/
176
+ index = $1.to_i
177
+ attr = @attributes[index]
178
+ if attr.nil?
179
+ raise ApplicationError, _("Invalid index: %d") % index
180
+ end
181
+ puts(_("Edit attribute: %s") % _(attr.name))
182
+ if attr.protected?
183
+ name = attr.name
184
+ secret = attr.secret?
185
+ else
186
+ name = input_attribute_name(attr.name)
187
+ if name.nil?
188
+ raise Canceled
189
+ end
190
+ secret = input_secret(attr.secret?)
191
+ end
192
+ if secret
193
+ prompt = _("Generate value? (no): ")
194
+ if S7nCli.input_boolean(prompt, false)
195
+ prompt = _("Length (8): ")
196
+ length = S7nCli.input_string(prompt).to_i
197
+ if length <= 0
198
+ length = 8
199
+ end
200
+ value = SecretGenerator.generate(length: length,
201
+ characters: [:alphabet, :number])
202
+ end
203
+ end
204
+ if value.nil?
205
+ value =
206
+ input_value(attr.display_value(secret), secret, attr.protected?)
207
+ end
208
+ if value.nil?
209
+ raise Canceled
210
+ end
211
+ attr.name = name
212
+ attr.secret = secret
213
+ attr.value = value
214
+ puts(_("Updated '%s'.") % _(attr.name))
215
+ when "a"
216
+ puts(_("Add attribute."))
217
+ name = input_attribute_name
218
+ if name.nil?
219
+ raise Canceled
220
+ end
221
+ type = input_attribute_type
222
+ if type.nil?
223
+ raise Canceled
224
+ end
225
+ secret = input_secret(false)
226
+ if secret
227
+ prompt = _("Generate value? (no): ")
228
+ if S7nCli.input_boolean(prompt, false)
229
+ # TODO: pwgen コマンドを呼び出す生成機を選択できるようにする。
230
+ prompt = _("Length (8): ")
231
+ length = S7nCli.input_string(prompt).to_i
232
+ if length <= 0
233
+ length = 8
234
+ end
235
+ # TODO: 文字種も選べるようにする。
236
+ value = SecretGenerator.generate(length: length)
237
+ end
238
+ end
239
+ if value.nil?
240
+ value = input_value("", secret, false)
241
+ end
242
+ if value.nil?
243
+ raise Canceled
244
+ end
245
+ options = {
246
+ "name" => name,
247
+ "secret" => secret,
248
+ "value" => value,
249
+ }
250
+ attr = Attribute.create_instance(type, options)
251
+ @attributes.push(attr)
252
+ puts(_("Added attribute: '%s'") % _(attr.name))
253
+ when /\Ad\s*(\d+)\z/
254
+ index = $1.to_i
255
+ attr = @attributes[index]
256
+ if attr.nil?
257
+ raise ApplicationError, _("Invalid index: %d") % index
258
+ end
259
+ if attr.protected?
260
+ msg = _("Can't delete attribute: %s") % _(attr.name)
261
+ raise ApplicationError, msg
262
+ end
263
+ prompt = _("Delete attribute '%s'? (no): ") % _(attr.name)
264
+ if !S7nCli.input_boolean(prompt)
265
+ raise Canceled
266
+ end
267
+ @attributes.delete(attr)
268
+ puts(_("Deleted attribute: '%s'") % _(attr.name))
269
+ when "t"
270
+ if input = input_tags(@tags)
271
+ @tags = input
272
+ else
273
+ raise Canceled
274
+ end
275
+ puts(_("Updated tags."))
276
+ when "s"
277
+ save
278
+ world.changed = true
279
+ puts(_("Saved: %d") % @entry.id)
280
+ return false
281
+ when "h"
282
+ puts(EDITING_USAGE)
283
+ else
284
+ raise ApplicationError, _("Unknown command: %s") % input
285
+ end
286
+ return true
287
+ end
288
+
289
+ # 機密情報に対する属性とタグの変更を反映させる。
290
+ def apply_entry_changes
291
+ @entry.tags.replace(@tags)
292
+ d = @entry.attributes.collect(&:name) - @attributes.collect(&:name)
293
+ if d.length > 0
294
+ @entry.attributes.delete_if { |attr|
295
+ attr.editable? && d.include?(attr.name)
296
+ }
297
+ end
298
+ @entry.add_attributes(@attributes)
299
+ end
300
+
301
+ # entry に指定した機密情報を表示する。
302
+ def display_entry(entry)
303
+ attr_names = entry.attributes.collect { |attr|
304
+ _(attr.name)
305
+ }
306
+ n = attr_names.collect(&:length).max
307
+ entry.attributes.each do |attr|
308
+ secret = attr.secret? && !config["show_secret"]
309
+ value = attr.display_value(secret)
310
+ puts("%-#{n}s: %s" % [_(attr.name), value])
311
+ end
312
+ puts("%-#{n}s: %s" % [_("tag"), entry.tags.join(", ")])
313
+ end
314
+ end
315
+
316
+ # 機密情報を追加するコマンドを表現する。
317
+ class EntryAddCommand < Command
318
+ include EntryCommandUtils
319
+
320
+ command("add",
321
+ ["entry:add",
322
+ "entry:new",
323
+ "entry:create",
324
+ "new",
325
+ "create",
326
+ "a"],
327
+ _("Add the entry."),
328
+ _("usage: add [NAME] [TAGS] [OPTIONS]"))
329
+
330
+ def initialize(*args)
331
+ super
332
+ @config["tags"] = []
333
+ @config["attributes"] = []
334
+ @options.push(StringOption.new("n", "name", _("Name.")),
335
+ ArrayOption.new("t", "tags", _("Tags.")),
336
+ IntOption.new("c", "copy", "ID",
337
+ _("Entry ID that is copied.")))
338
+ end
339
+
340
+ # 機密情報を追加する。
341
+ def save
342
+ apply_entry_changes
343
+ # TODO: undo スタックに操作を追加する。
344
+ world.entry_collection.add_entries(@entry)
345
+ end
346
+
347
+ # コマンドを実行する。
348
+ def run(argv)
349
+ option_parser.parse!(argv)
350
+ if argv.length > 0
351
+ config["name"] = argv.shift
352
+ end
353
+ if argv.length > 0
354
+ config["tags"].push(*argv.shift.split(","))
355
+ end
356
+ if config["copy"]
357
+ from_entry = world.entry_collection.find(config["copy"])
358
+ if !from_entry
359
+ raise NoSuchEntry.new("id" => config["copy"])
360
+ end
361
+ end
362
+ puts(_("Add the entry."))
363
+ @entry = Entry.new
364
+ if from_entry
365
+ @entry.add_attributes(from_entry.duplicate_attributes)
366
+ end
367
+ @attributes = @entry.attributes.select { |attr|
368
+ attr.editable?
369
+ }
370
+ attr = @entry.get_attribute("name")
371
+ if config["name"]
372
+ attr.value = config["name"]
373
+ end
374
+ if attr.value.to_s.empty?
375
+ puts(_("Edit attribute: %s") % _(attr.name))
376
+ attr.value =
377
+ input_value(attr.display_value, attr.secret?, attr.protected?)
378
+ end
379
+ if config["tags"].length > 0
380
+ @tags = config["tags"]
381
+ else
382
+ @tags = @entry.tags
383
+ end
384
+ if @tags.empty?
385
+ @tags = input_tags(@tags) || []
386
+ end
387
+ command_loop
388
+ return true
389
+ end
390
+ end
391
+
392
+ # 機密情報を追加するコマンドを表現する。
393
+ class EntryEditCommand < Command
394
+ include EntryCommandUtils
395
+
396
+ command("edit",
397
+ ["entry:edit",
398
+ "entry:update",
399
+ "update",
400
+ "e"],
401
+ _("Edit the entry."),
402
+ _("usage: edit <ID> [OPTIONS]"))
403
+
404
+ def initialize(*args)
405
+ super
406
+ @options.push(IntOption.new("i", "id", "ID", _("Entry ID.")))
407
+ end
408
+
409
+ # 機密情報を追加する。
410
+ def save
411
+ # TODO: undo スタックに操作を追加する。
412
+ apply_entry_changes
413
+ end
414
+
415
+ # コマンドを実行する。
416
+ def run(argv)
417
+ option_parser.parse!(argv)
418
+ if argv.length > 0
419
+ config["id"] = argv.shift.to_i
420
+ end
421
+ if config["id"].nil?
422
+ puts(_("Too few arguments."))
423
+ help
424
+ return true
425
+ end
426
+ @entry = world.entry_collection.find(config["id"])
427
+ if @entry.nil?
428
+ raise NoSuchEntry.new("id" => config["id"])
429
+ end
430
+ puts(_("Edit the entry."))
431
+ @attributes = @entry.duplicate_attributes
432
+ @tags = @entry.tags.dup
433
+ if @tags.empty?
434
+ @tags = input_tags(@tags) || []
435
+ end
436
+ command_loop
437
+ return true
438
+ end
439
+ end
440
+
441
+ # 機密情報を検索するコマンドを表現する。
442
+ class EntryShowCommand < Command
443
+ include EntryCommandUtils
444
+
445
+ command("show",
446
+ ["entry:show",
447
+ "s"],
448
+ _("Show the entry."),
449
+ _("usage: show <ID> [OPTIONS]"))
450
+
451
+ def initialize(*args)
452
+ super
453
+ @config["show_secret"] = false
454
+ @options.push(IntOption.new("id", _("Entry ID.")),
455
+ BoolOption.new("s", "show_secret",
456
+ _("Show value of secret attributes.")))
457
+ end
458
+
459
+ # コマンドを実行する。
460
+ def run(argv)
461
+ option_parser.parse!(argv)
462
+ if argv.length > 0
463
+ config["id"] = argv.shift.to_i
464
+ end
465
+ if config["id"].nil?
466
+ puts(_("Too few arguments."))
467
+ help
468
+ return true
469
+ end
470
+ entries = world.entry_collection.entries
471
+ entry = entries.find { |entry|
472
+ entry.id == config["id"]
473
+ }
474
+ if !entry
475
+ raise NoSuchEntry.new("id" => config["id"])
476
+ end
477
+ display_entry(entry)
478
+ if config["show_secret"]
479
+ # TODO: undo スタックに操作を追加する。
480
+ entry.rate += 1
481
+ puts(_("Changed rate to %d.") % entry.rate)
482
+ world.changed = true
483
+ end
484
+ return true
485
+ end
486
+ end
487
+
488
+ # 機密情報を検索するコマンドを表現する。
489
+ class EntrySearchCommand < Command
490
+ include EntryCommandUtils
491
+
492
+ command("search",
493
+ ["entry:search",
494
+ "entry:list",
495
+ "list",
496
+ "ls",
497
+ "l"],
498
+ _("Search the entry."),
499
+ _("usage: search [QUERY] [OPTIONS]"))
500
+
501
+ def initialize(*args)
502
+ super
503
+ @config["query"] = ""
504
+ @config["tags"] = []
505
+ @config["num"] = 10
506
+ @config["ignore-case"] = false
507
+ @options.push(StringOption.new("query", _("Query.")),
508
+ ArrayOption.new("tags", _("Tags.")),
509
+ IntOption.new("n", "num", _("Number of entries.")),
510
+ BoolOption.new("i", "ignore_case", _("Ignore case.")))
511
+ end
512
+
513
+ def search(entries, words, tags, ignore_case)
514
+ if words.empty?
515
+ matched_entries = entries
516
+ else
517
+ matched_entries = entries.select { |entry|
518
+ s = entry.attributes.collect { |attr|
519
+ attr.value.to_s
520
+ }.join("\n")
521
+ res = true
522
+ if ignore_case
523
+ s.downcase!
524
+ words.each(&:downcase!)
525
+ end
526
+ words.each do |word|
527
+ if !s.include?(word)
528
+ res = false
529
+ break
530
+ end
531
+ end
532
+ res
533
+ }
534
+ end
535
+ if tags.length > 0
536
+ matched_entries = matched_entries.select { |entry|
537
+ tags == (entry.tags & tags)
538
+ }
539
+ end
540
+ return matched_entries
541
+ end
542
+
543
+ # コマンドを実行する。
544
+ def run(argv)
545
+ option_parser.parse!(argv)
546
+ if argv.length > 0
547
+ config["query"].concat(" " + argv.join(" "))
548
+ end
549
+ words = config["query"].split
550
+ entries = search(world.entry_collection.entries,
551
+ words, config["tags"], config["ignore_case"])
552
+ if entries.empty?
553
+ puts(_("No entry."))
554
+ else
555
+ sort_key = :id
556
+ page_no = 1
557
+ per_page = ENV["LINES"].to_i
558
+ if per_page <= 3
559
+ per_page = 25 - 3
560
+ else
561
+ per_page -= 3
562
+ end
563
+ current_mode = :next
564
+
565
+ begin
566
+ loop do
567
+ begin
568
+ if words.length > 0
569
+ print(_("Search result of '%s', %d entries.") %
570
+ [words.join(" "), entries.length])
571
+ else
572
+ print(_("List of %d entries.") % entries.length)
573
+ end
574
+ num_pages = (entries.length.to_f / per_page).ceil
575
+ puts(_(" [page %d/%d]") % [page_no, num_pages])
576
+
577
+ list_entries(entries, sort_key, page_no, per_page)
578
+
579
+ if num_pages == 1
580
+ raise Interrupt
581
+ end
582
+
583
+ prompt = _("Command (p:prev, n:next, s:sort, q:quit): ")
584
+ input = S7nCli.input_string(prompt)
585
+ if input.nil?
586
+ raise Interrupt
587
+ end
588
+ begin
589
+ case input
590
+ when /\A(n|next)\z/i
591
+ page_no += 1
592
+ current_mode = :next
593
+ when /\A(p|prev)\z/i
594
+ page_no -= 1
595
+ current_mode = :prev
596
+ when /\A(s|sort)\z/i
597
+ sort_key = next_sort_key(sort_key)
598
+ when /\A(q|quit)/i
599
+ break
600
+ else
601
+ if current_mode == :next
602
+ page_no += 1
603
+ else
604
+ page_no -= 1
605
+ end
606
+ end
607
+ if page_no < 1
608
+ page_no = 1
609
+ elsif page_no >= num_pages
610
+ page_no = num_pages
611
+ end
612
+ end
613
+ rescue ApplicationError => e
614
+ puts(e.message)
615
+ end
616
+ end
617
+ rescue Interrupt
618
+ puts("\n")
619
+ end
620
+ end
621
+ return true
622
+ end
623
+
624
+ # ソート対象の属性の配列。
625
+ SORT_KEYS = [:id, :name, :tags, :rate]
626
+
627
+ def next_sort_key(current)
628
+ return SORT_KEYS[(SORT_KEYS.index(current) + 1) % SORT_KEYS.length]
629
+ end
630
+
631
+ # 指定した文字列 str の表示幅が width よりも長い場合は切り詰める。
632
+ # width よりも短い場合、left が true だと右部分にスペースをつめ、
633
+ # left が false だと、左部分にスペースをつめる。
634
+ def truncate_or_add_space(str, width, left = true)
635
+ len = 0
636
+ truncate_index = nil
637
+ truncate_with_space = false
638
+ str.chars.each_with_index do |chr, i|
639
+ case UnicodeData.east_asian_width(chr)
640
+ when :H, :N
641
+ n = 1
642
+ else
643
+ n = 2
644
+ end
645
+ len += n
646
+ if truncate_index.nil? && len >= width
647
+ truncate_index = i
648
+ if len > width
649
+ truncate_with_space = true
650
+ end
651
+ end
652
+ end
653
+ if len < width
654
+ space = " " * (width - len)
655
+ if left
656
+ res = str + space
657
+ else
658
+ res = space + str
659
+ end
660
+ elsif len == width
661
+ res = str
662
+ else
663
+ res = str[0..truncate_index]
664
+ if truncate_with_space
665
+ res[-1] = " "
666
+ end
667
+ end
668
+ return res
669
+ end
670
+
671
+ def list_entries(entries, sort_key, page_no, per_page)
672
+ sort_marks = [" ", " ", " ", " "]
673
+ sort_marks[SORT_KEYS.index(sort_key)] = ">"
674
+ puts(_("%sID |%sName |%sTags(s) |%sRate") % sort_marks)
675
+ sorted_entries = entries.sort_by { |e| e.send(sort_key) }
676
+ sorted_entries[((page_no - 1) * per_page), per_page].each do |entry|
677
+ puts([truncate_or_add_space(entry.id.to_s, 4, false),
678
+ truncate_or_add_space(entry.name, 48),
679
+ truncate_or_add_space(entry.tags.join(","), 20),
680
+ truncate_or_add_space(entry.rate.to_s, 5, false)].join("|"))
681
+ end
682
+ end
683
+ end
684
+
685
+ # 機密情報を削除するコマンドを表現する。
686
+ class EntryDeleteCommand < Command
687
+ include EntryCommandUtils
688
+
689
+ command("delete",
690
+ ["entry:delete",
691
+ "entry:destroy",
692
+ "entry:remove",
693
+ "destroy",
694
+ "remove",
695
+ "del",
696
+ "rm",
697
+ "d"],
698
+ _("Delete the entry."),
699
+ _("usage: delete <ID>"))
700
+
701
+ # コマンドを実行する。
702
+ def run(argv)
703
+ if argv.length > 0
704
+ id = argv.shift.to_i
705
+ end
706
+ if id.nil?
707
+ puts(_("Too few arguments."))
708
+ help
709
+ return true
710
+ end
711
+ entry = world.entry_collection.find(id)
712
+ if entry.nil?
713
+ raise NoSuchEntry.new("id" => id)
714
+ end
715
+ display_entry(entry)
716
+ puts("-----")
717
+ if S7nCli.input_boolean(_("Delete? (no): "))
718
+ # TODO: undo スタックに操作を追加。
719
+ world.entry_collection.delete_entries(entry)
720
+ puts(_("Deleted: %d") % entry.id)
721
+ else
722
+ canceled
723
+ end
724
+ return true
725
+ end
726
+ end
727
+ end
728
+ end