s7 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. data/ChangeLog +311 -0
  2. data/LICENCE +24 -0
  3. data/README +56 -0
  4. data/Rakefile +128 -0
  5. data/VERSION +1 -0
  6. data/bin/s7cli +27 -0
  7. data/lib/gettext.rb +28 -0
  8. data/lib/s7.rb +12 -0
  9. data/lib/s7/action.rb +7 -0
  10. data/lib/s7/attribute.rb +226 -0
  11. data/lib/s7/cipher.rb +110 -0
  12. data/lib/s7/configuration.rb +41 -0
  13. data/lib/s7/entry.rb +106 -0
  14. data/lib/s7/entry_collection.rb +44 -0
  15. data/lib/s7/entry_template.rb +10 -0
  16. data/lib/s7/exception.rb +116 -0
  17. data/lib/s7/file.rb +77 -0
  18. data/lib/s7/gpass_file.rb +202 -0
  19. data/lib/s7/key.rb +83 -0
  20. data/lib/s7/message_catalog.rb +5 -0
  21. data/lib/s7/s7_file.rb +47 -0
  22. data/lib/s7/s7cli.rb +213 -0
  23. data/lib/s7/s7cli/attribute_command.rb +69 -0
  24. data/lib/s7/s7cli/command.rb +210 -0
  25. data/lib/s7/s7cli/entry_collection_command.rb +63 -0
  26. data/lib/s7/s7cli/entry_command.rb +728 -0
  27. data/lib/s7/s7cli/option.rb +101 -0
  28. data/lib/s7/secret_generator.rb +30 -0
  29. data/lib/s7/undo_stack.rb +7 -0
  30. data/lib/s7/unicode_data.rb +29 -0
  31. data/lib/s7/utils.rb +37 -0
  32. data/lib/s7/world.rb +150 -0
  33. data/setup.rb +1585 -0
  34. data/test/s7/attribute_test.rb +45 -0
  35. data/test/s7/gpass_file_test.rb +169 -0
  36. data/test/s7/gpass_file_test/passwords.gps.empty +1 -0
  37. data/test/s7/gpass_file_test/passwords.gps.one_entry +2 -0
  38. data/test/s7/gpass_file_test/passwords.gps.three_entries +3 -0
  39. data/test/s7/gpass_file_test/passwords.gps.with_folders +0 -0
  40. data/test/s7/secret_generator_test.rb +29 -0
  41. data/test/s7/unicode_data_test.rb +28 -0
  42. data/test/s7/world_test.rb +35 -0
  43. data/test/test_helper.rb +19 -0
  44. metadata +108 -0
@@ -0,0 +1,63 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require "s7/s7cli/command"
4
+
5
+ module S7
6
+ class S7Cli
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 = S7Cli.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 "s7/s7cli/command"
4
+ require "s7/secret_generator"
5
+ require "s7/unicode_data"
6
+
7
+ module S7
8
+ class S7Cli
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 = S7Cli.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 = S7Cli.input_string_without_echo(prompt)
57
+ confirmation =
58
+ S7Cli.input_string_without_echo(_("Comfirm value: "))
59
+ if input != confirmation
60
+ raise ApplicationError, _("Mismatched.")
61
+ end
62
+ end
63
+ else
64
+ input = S7Cli.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 = S7Cli.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 S7Cli.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 = S7Cli.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 = S7Cli.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 S7Cli.input_boolean(prompt, false)
195
+ prompt = _("Length (8): ")
196
+ length = S7Cli.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 S7Cli.input_boolean(prompt, false)
229
+ # TODO: pwgen コマンドを呼び出す生成機を選択できるようにする。
230
+ prompt = _("Length (8): ")
231
+ length = S7Cli.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 !S7Cli.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 = S7Cli.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, 49),
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 S7Cli.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