s7 0.0.1

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 (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