stickyflag 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (76) hide show
  1. data/.gitignore +7 -0
  2. data/.rspec +4 -0
  3. data/.simplecov +9 -0
  4. data/.travis.yml +13 -0
  5. data/Gemfile +2 -0
  6. data/LICENSE.md +7 -0
  7. data/README.md +49 -0
  8. data/Rakefile +19 -0
  9. data/TODO.md +3 -0
  10. data/bin/stickyflag +6 -0
  11. data/features/clear.feature +14 -0
  12. data/features/clear_quietly.feature +23 -0
  13. data/features/configuration.feature +14 -0
  14. data/features/get.feature +14 -0
  15. data/features/get_quietly.feature +23 -0
  16. data/features/set.feature +14 -0
  17. data/features/set_quietly.feature +22 -0
  18. data/features/step_definitions/configuration_steps.rb +31 -0
  19. data/features/step_definitions/database_steps.rb +41 -0
  20. data/features/step_definitions/pending_steps.rb +5 -0
  21. data/features/step_definitions/tag_steps.rb +62 -0
  22. data/features/support/cukegem.rb +82 -0
  23. data/features/support/env.rb +37 -0
  24. data/features/tags.feature +18 -0
  25. data/features/unset.feature +14 -0
  26. data/features/unset_quietly.feature +23 -0
  27. data/lib/stickyflag/configuration.rb +66 -0
  28. data/lib/stickyflag/database.rb +162 -0
  29. data/lib/stickyflag/external_cmds.rb +64 -0
  30. data/lib/stickyflag/patches/tempfile_encoding.rb +22 -0
  31. data/lib/stickyflag/patches/tmpnam.rb +38 -0
  32. data/lib/stickyflag/paths.rb +47 -0
  33. data/lib/stickyflag/tag_factory.rb +108 -0
  34. data/lib/stickyflag/tags/c.rb +25 -0
  35. data/lib/stickyflag/tags/mmd.rb +168 -0
  36. data/lib/stickyflag/tags/pdf.rb +119 -0
  37. data/lib/stickyflag/tags/png.rb +61 -0
  38. data/lib/stickyflag/tags/source_code.rb +99 -0
  39. data/lib/stickyflag/tags/tex.rb +25 -0
  40. data/lib/stickyflag/version.rb +12 -0
  41. data/lib/stickyflag.rb +253 -0
  42. data/spec/spec_helper.rb +22 -0
  43. data/spec/stickyflag/configuration_spec.rb +132 -0
  44. data/spec/stickyflag/database_spec.rb +331 -0
  45. data/spec/stickyflag/external_cmds_spec.rb +175 -0
  46. data/spec/stickyflag/patches/tempfile_encoding_spec.rb +26 -0
  47. data/spec/stickyflag/patches/tmpnam_spec.rb +35 -0
  48. data/spec/stickyflag/paths_spec.rb +29 -0
  49. data/spec/stickyflag/tag_factory_spec.rb +185 -0
  50. data/spec/stickyflag/tags/c_spec.rb +14 -0
  51. data/spec/stickyflag/tags/mmd_spec.rb +40 -0
  52. data/spec/stickyflag/tags/pdf_spec.rb +39 -0
  53. data/spec/stickyflag/tags/png_spec.rb +6 -0
  54. data/spec/stickyflag/tags/tex_spec.rb +6 -0
  55. data/spec/stickyflag_spec.rb +482 -0
  56. data/spec/support/examples/c_all_comments.c +3 -0
  57. data/spec/support/examples/c_no_tags.c +5 -0
  58. data/spec/support/examples/c_with_tag.c +6 -0
  59. data/spec/support/examples/mmd_all_meta.mmd +6 -0
  60. data/spec/support/examples/mmd_crazy_keys.mmd +8 -0
  61. data/spec/support/examples/mmd_crazy_tags.mmd +9 -0
  62. data/spec/support/examples/mmd_no_tags.mmd +1 -0
  63. data/spec/support/examples/mmd_with_tag.mmd +3 -0
  64. data/spec/support/examples/pdf_no_tags.pdf +0 -0
  65. data/spec/support/examples/pdf_with_tag.pdf +0 -0
  66. data/spec/support/examples/png_no_tags.png +0 -0
  67. data/spec/support/examples/png_with_tag.png +0 -0
  68. data/spec/support/examples/tex_no_tags.tex +10 -0
  69. data/spec/support/examples/tex_with_tag.tex +11 -0
  70. data/spec/support/examples/untaggable.txt +0 -0
  71. data/spec/support/examples.rb +32 -0
  72. data/spec/support/run_with_args.rb +36 -0
  73. data/spec/support/silence_stream.rb +12 -0
  74. data/spec/support/tag_handler_behavior.rb +125 -0
  75. data/stickyflag.gemspec +48 -0
  76. metadata +399 -0
@@ -0,0 +1,162 @@
1
+ # -*- encoding : utf-8 -*-
2
+ require 'thread'
3
+ require 'sequel'
4
+
5
+ module StickyFlag
6
+ module Database
7
+ def load_database
8
+ @database.disconnect if @database
9
+
10
+ if RUBY_PLATFORM == 'java'
11
+ @database = Sequel.connect("jdbc:sqlite:#{database_path}")
12
+ else
13
+ @database = Sequel.sqlite(:database => database_path)
14
+ end
15
+ raise Thor::Error.new("ERROR: Could not create database at '#{database_path}'") if @database.nil?
16
+
17
+ create_tables
18
+
19
+ # Do not do automatic cleanup from the RSpec test suite; this registers
20
+ # dozens of at_exit hooks and crashes Ruby
21
+ unless ENV['RSPEC_TESTING']
22
+ at_exit { @database.disconnect }
23
+ end
24
+ end
25
+
26
+ def create_tables
27
+ @database.create_table?(:tag_list) do
28
+ primary_key :id, :type => Bignum
29
+ String :tag_name, :text => true
30
+ end
31
+ @database.create_table?(:file_list) do
32
+ primary_key :id, :type => Bignum
33
+ String :file_name, :text => true
34
+ end
35
+ @database.create_table?(:tagged_files) do
36
+ primary_key :id
37
+ Bignum :file
38
+ Bignum :tag
39
+ end
40
+ end
41
+
42
+ def drop_tables
43
+ @database.drop_table(:tag_list, :file_list, :tagged_files)
44
+ end
45
+
46
+ def get_tag_id(tag)
47
+ tag_id = @database[:tag_list].where(:tag_name => tag).get(:id)
48
+ unless tag_id
49
+ tag_id = @database[:tag_list].insert(:tag_name => tag)
50
+ end
51
+
52
+ tag_id
53
+ end
54
+
55
+ def get_file_id(file_name)
56
+ file_id = @database[:file_list].where(:file_name => file_name).get(:id)
57
+ unless file_id
58
+ file_id = @database[:file_list].insert(:file_name => file_name)
59
+ end
60
+
61
+ file_id
62
+ end
63
+
64
+ def update_database_from_files(directory = '.')
65
+ drop_tables
66
+ create_tables
67
+
68
+ Dir.glob(File.join(directory, '**', "*{#{available_tagging_extensions.join(',')}}")).each do |file|
69
+ begin
70
+ tags = get_tags_for file
71
+ rescue Thor::Error
72
+ # Just skip this file, then, don't error out entirely
73
+ say_status :warning, "Could not read tags from '#{file}', despite a valid extension", :yellow
74
+ next
75
+ end
76
+
77
+ # Don't record files in the DB that have no tags
78
+ next if tags.empty?
79
+
80
+ file_id = get_file_id file
81
+ tags.each do |tag|
82
+ tag_id = get_tag_id tag
83
+ @database[:tagged_files].insert(:file => file_id, :tag => tag_id)
84
+ end
85
+ end
86
+ end
87
+
88
+ def set_database_tag(file_name, tag)
89
+ # Don't put in multiple entries for the same tag
90
+ file_id = get_file_id file_name
91
+ tag_id = get_tag_id tag
92
+
93
+ return unless @database[:tagged_files].where(:file => file_id).and(:tag => tag_id).empty?
94
+ @database[:tagged_files].insert(:file => file_id, :tag => tag_id)
95
+ end
96
+
97
+ def unset_database_tag(file_name, tag)
98
+ file_id = get_file_id file_name
99
+ tag_id = get_tag_id tag
100
+ @database[:tagged_files].where(:file => file_id).and(:tag => tag_id).delete
101
+
102
+ # See if that was the last file with this tag, and delete it if so
103
+ if @database[:tagged_files].where(:tag => tag_id).empty?
104
+ @database[:tag_list].where(:id => tag_id).delete
105
+ end
106
+ end
107
+
108
+ def clear_database_tags(file_name)
109
+ file_id = get_file_id file_name
110
+ @database[:tagged_files].where(:file => file_id).delete
111
+
112
+ # That operation might have removed the last instance of a tag, clean up
113
+ # the tag list
114
+ @database[:tag_list].each do |row|
115
+ raise Thor::Error.new("INTERNAL ERROR: Database row error in tag_list") unless row.include? :id
116
+ tag_id = row[:id]
117
+
118
+ if @database[:tagged_files].where(:tag => tag_id).empty?
119
+ @database[:tag_list].where(:id => tag_id).delete
120
+ end
121
+ end
122
+ end
123
+
124
+ def files_for_tags(tags)
125
+ # Map tags to tag IDs, but don't add any missing tags
126
+ bad_tag = false
127
+ tag_ids = []
128
+ tags.each do |tag|
129
+ tag_id = @database[:tag_list].where(:tag_name => tag).get(:id)
130
+ unless tag_id
131
+ say_status :warning, "Tag '#{tag}' is not present in the database (try `stickyflag update`)", :yellow unless options.quiet?
132
+ bad_tag = true
133
+ next
134
+ end
135
+
136
+ tag_ids << tag_id
137
+ end
138
+ return [] if bad_tag
139
+
140
+ file_rows = @database[:tagged_files].where(:tag => tag_ids).group_by(:file).having{[[count(:*){}, tag_ids.count]]}
141
+ if file_rows.empty?
142
+ say_status :warning, "Requested combination of tags not found", :yellow unless options.quiet?
143
+ return []
144
+ end
145
+
146
+ files = []
147
+ file_rows.each do |row|
148
+ file_id = row[:file]
149
+ file = @database[:file_list].where(:id => file_id).get(:file_name)
150
+ raise Thor::Error.new("ERROR: Could not get file_name for id saved in database (re-run `stickyflag update`)") unless file
151
+
152
+ files << file
153
+ end
154
+
155
+ files
156
+ end
157
+
158
+ def tag_list
159
+ @database[:tag_list].map { |r| r[:tag_name] }
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,64 @@
1
+ # -*- encoding : utf-8 -*-
2
+
3
+ module StickyFlag
4
+ module ExternalCmds
5
+
6
+ EXTERNAL_CMDS = {
7
+ 'pdftk' => 'read and write PDF tags'
8
+ }
9
+
10
+ def find_external_cmds
11
+ EXTERNAL_CMDS.each do |cmd, desc|
12
+ path = get_config "#{cmd}_path"
13
+
14
+ # First, make sure that the listed file actually exists and is executable,
15
+ # so that if it isn't, we'll be able to fix it by checking $PATH down
16
+ # below.
17
+ unless path.nil? || path.empty?
18
+ unless File.executable? path
19
+ say_status :error, "Path set for #{cmd} is invalid", :red unless options.quiet?
20
+
21
+ path = ''
22
+ set_config "#{cmd}_path", ''
23
+ set_config "have_#{cmd}", false
24
+ else
25
+ # We do have this, make sure it's set that way
26
+ set_config "have_#{cmd}", true
27
+ end
28
+ end
29
+
30
+ if path.nil? || path.empty?
31
+ # We don't have a path for this command, see if we can find it
32
+ found = which(cmd)
33
+ if found.nil?
34
+ say_status :warning, "Cannot find #{cmd} in path, will not be able to #{desc}", :yellow unless options.quiet?
35
+ set_config "have_#{cmd}", false
36
+ next
37
+ end
38
+
39
+ # Okay, found it, set the configuration parameter
40
+ set_config "#{cmd}_path", found
41
+ set_config "have_#{cmd}", true
42
+ end
43
+ end
44
+
45
+ # Save our results out to the configuration file
46
+ save_config!
47
+ end
48
+
49
+ private
50
+
51
+ # Thanks to mislav on Stack Overflow for this
52
+ def which(cmd)
53
+ exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : ['']
54
+ ENV['PATH'].split(File::PATH_SEPARATOR).each do |path|
55
+ exts.each { |ext|
56
+ exe = "#{path}#{File::SEPARATOR}#{cmd}#{ext}"
57
+ return exe if File.executable? exe
58
+ }
59
+ end
60
+ return nil
61
+ end
62
+
63
+ end
64
+ end
@@ -0,0 +1,22 @@
1
+ # -*- encoding : utf-8 -*-
2
+ require 'tempfile'
3
+
4
+ class Tempfile
5
+
6
+ unless Tempfile.respond_to?(:new_with_encoding)
7
+ def self.new_with_encoding(params)
8
+ # JRuby's Tempfile constructor got the third hash parameter in JRuby
9
+ # 1.7.0-preview1.
10
+ if RUBY_VERSION >= "1.9.0" && (RUBY_PLATFORM != 'java' || JRUBY_VERSION >= '1.7.0')
11
+ return Tempfile.new(params, Dir.tmpdir, :encoding => "UTF-8")
12
+ else
13
+ # No coverage on Ruby 1.8, ignore
14
+ #:nocov:
15
+ return Tempfile.new(params)
16
+ #:nocov:
17
+ end
18
+ end
19
+ end
20
+
21
+ end
22
+
@@ -0,0 +1,38 @@
1
+ # -*- encoding : utf-8 -*-
2
+ require 'tmpdir'
3
+
4
+ class File
5
+
6
+ unless File.respond_to?(:tmpnam)
7
+ def self.tmpnam(ext = '')
8
+ if ext != ''
9
+ if ext[0] != '.'
10
+ ext = ".#{ext}"
11
+ end
12
+ end
13
+
14
+ pid = Process.pid
15
+ time = Time.now
16
+ sec = time.to_i
17
+ usec = time.usec
18
+
19
+ counter = 0
20
+ path = File.join(Dir.tmpdir, "#{pid}_#{sec}_#{usec}_#{rand(1000)}#{ext}")
21
+
22
+ # This is a corner case that should effectively never be possible, so
23
+ # don't try to cover it.
24
+ #:nocov:
25
+ while File.exist? path
26
+ sec = Time.now.to_i
27
+ usec = Time.now.usec
28
+ path = File.join(Dir.tmpdir, "#{pid}_#{sec}_#{usec}_#{rand(1000)}#{ext}")
29
+
30
+ counter += 1
31
+ raise 'ERROR: Cannot get unique temporary name' if counter >= 100
32
+ end
33
+ #:nocov:
34
+
35
+ path
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,47 @@
1
+ # -*- encoding : utf-8 -*-
2
+ require 'rbconfig'
3
+ require 'fileutils'
4
+
5
+ module StickyFlag
6
+ module Paths
7
+ def config_path
8
+ # No code coverage: only ever runs one operating system branch
9
+ #:nocov:
10
+ case RbConfig::CONFIG['target_os']
11
+ when /darwin/i
12
+ root_dir = File.expand_path("~/Library/Application Support/StickyFlag")
13
+ when /linux/i
14
+ require 'xdg'
15
+ root_dir = File.join(XDG['CONFIG_HOME'].to_s, 'stickyflag')
16
+ when /mswin|mingw/i
17
+ root_dir = File.join(ENV['APPDATA'], 'StickyFlag')
18
+ else
19
+ root_dir = File.expand_path('~/.stickyflag')
20
+ end
21
+ #:nocov:
22
+
23
+ FileUtils.mkdir_p root_dir
24
+ File.join(root_dir, 'config.yml')
25
+ end
26
+
27
+ def database_path
28
+ # No code coverage: only ever runs one operating system branch
29
+ #:nocov:
30
+ case RbConfig::CONFIG['target_os']
31
+ when /darwin/i
32
+ root_dir = File.expand_path("~/Library/Application Support/StickyFlag")
33
+ when /linux/i
34
+ require 'xdg'
35
+ root_dir = File.join(XDG['DATA_HOME'].to_s, 'stickyflag')
36
+ when /mswin|mingw/i
37
+ root_dir = File.join(ENV['APPDATA'], 'StickyFlag')
38
+ else
39
+ root_dir = File.expand_path('~/.stickyflag')
40
+ end
41
+ #:nocov:
42
+
43
+ FileUtils.mkdir_p root_dir
44
+ File.join(root_dir, 'db.sqlite')
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,108 @@
1
+ # -*- encoding : utf-8 -*-
2
+ require 'thor'
3
+ require 'pathname'
4
+ require 'backports'
5
+ require 'stickyflag/tags/pdf'
6
+ require 'stickyflag/tags/pdf'
7
+ require 'stickyflag/tags/png'
8
+ require 'stickyflag/tags/tex'
9
+ require 'stickyflag/tags/c'
10
+ require 'stickyflag/tags/mmd'
11
+
12
+ module StickyFlag
13
+ module TagFactory
14
+ def available_tagging_extensions
15
+ ['.pdf', '.png', '.tex', '.c', '.cpp', '.cxx', '.c++',
16
+ '.h', '.hpp', '.hxx', '.mmd']
17
+ end
18
+
19
+ def get_tags_for(file_name)
20
+ extension = File.extname file_name
21
+ case extension
22
+ when '.pdf' then return StickyFlag::Tags::PDF::get(file_name, get_config(:pdftk_path))
23
+ when '.png' then return StickyFlag::Tags::PNG::get(file_name)
24
+ when '.tex' then return StickyFlag::Tags::TeX::get(file_name)
25
+ when '.c' then return StickyFlag::Tags::C::get(file_name)
26
+ when '.cpp' then return StickyFlag::Tags::C::get(file_name)
27
+ when '.cxx' then return StickyFlag::Tags::C::get(file_name)
28
+ when '.c++' then return StickyFlag::Tags::C::get(file_name)
29
+ when '.h' then return StickyFlag::Tags::C::get(file_name)
30
+ when '.hpp' then return StickyFlag::Tags::C::get(file_name)
31
+ when '.hxx' then return StickyFlag::Tags::C::get(file_name)
32
+ when '.mmd' then return StickyFlag::Tags::MMD::get(file_name)
33
+ else raise Thor::Error.new("ERROR: Don't know how to get tags for a file of extension '#{extension}'")
34
+ end
35
+ end
36
+
37
+ def set_tag_for(file_name, tag)
38
+ tags = get_tags_for file_name
39
+ if tags.include? tag
40
+ # Already set
41
+ return
42
+ end
43
+
44
+ set_database_tag file_name, tag
45
+
46
+ extension = File.extname file_name
47
+ case extension
48
+ when '.pdf' then return StickyFlag::Tags::PDF::set(file_name, tag, get_config(:pdftk_path))
49
+ when '.png' then return StickyFlag::Tags::PNG::set(file_name, tag)
50
+ when '.tex' then return StickyFlag::Tags::TeX::set(file_name, tag)
51
+ when '.c' then return StickyFlag::Tags::C::set(file_name, tag)
52
+ when '.cpp' then return StickyFlag::Tags::C::set(file_name, tag)
53
+ when '.cxx' then return StickyFlag::Tags::C::set(file_name, tag)
54
+ when '.c++' then return StickyFlag::Tags::C::set(file_name, tag)
55
+ when '.h' then return StickyFlag::Tags::C::set(file_name, tag)
56
+ when '.hpp' then return StickyFlag::Tags::C::set(file_name, tag)
57
+ when '.hxx' then return StickyFlag::Tags::C::set(file_name, tag)
58
+ when '.mmd' then return StickyFlag::Tags::MMD::set(file_name, tag)
59
+ else raise Thor::Error.new("ERROR: Don't know how to set tags for a file of extension '#{extension}'")
60
+ end
61
+ end
62
+
63
+ def unset_tag_for(file_name, tag)
64
+ tags = get_tags_for file_name
65
+ unless tags.include? tag
66
+ raise Thor::Error.new("ERROR: Cannot unset tag #{tag} from file, not set")
67
+ end
68
+
69
+ unset_database_tag file_name, tag
70
+
71
+ extension = File.extname file_name
72
+ case extension
73
+ when '.pdf' then return StickyFlag::Tags::PDF::unset(file_name, tag, get_config(:pdftk_path))
74
+ when '.png' then return StickyFlag::Tags::PNG::unset(file_name, tag)
75
+ when '.tex' then return StickyFlag::Tags::TeX::unset(file_name, tag)
76
+ when '.c' then return StickyFlag::Tags::C::unset(file_name, tag)
77
+ when '.cpp' then return StickyFlag::Tags::C::unset(file_name, tag)
78
+ when '.cxx' then return StickyFlag::Tags::C::unset(file_name, tag)
79
+ when '.c++' then return StickyFlag::Tags::C::unset(file_name, tag)
80
+ when '.h' then return StickyFlag::Tags::C::unset(file_name, tag)
81
+ when '.hpp' then return StickyFlag::Tags::C::unset(file_name, tag)
82
+ when '.hxx' then return StickyFlag::Tags::C::unset(file_name, tag)
83
+ when '.mmd' then return StickyFlag::Tags::MMD::unset(file_name, tag)
84
+ else raise Thor::Error.new("ERROR: Don't know how to unset tags for a file of extension '#{extension}'")
85
+ end
86
+ end
87
+
88
+ def clear_tags_for(file_name)
89
+ clear_database_tags file_name
90
+
91
+ extension = File.extname file_name
92
+ case extension
93
+ when '.pdf' then return StickyFlag::Tags::PDF::clear(file_name, get_config(:pdftk_path))
94
+ when '.png' then return StickyFlag::Tags::PNG::clear(file_name)
95
+ when '.tex' then return StickyFlag::Tags::TeX::clear(file_name)
96
+ when '.c' then return StickyFlag::Tags::C::clear(file_name)
97
+ when '.cpp' then return StickyFlag::Tags::C::clear(file_name)
98
+ when '.cxx' then return StickyFlag::Tags::C::clear(file_name)
99
+ when '.c++' then return StickyFlag::Tags::C::clear(file_name)
100
+ when '.h' then return StickyFlag::Tags::C::clear(file_name)
101
+ when '.hpp' then return StickyFlag::Tags::C::clear(file_name)
102
+ when '.hxx' then return StickyFlag::Tags::C::clear(file_name)
103
+ when '.mmd' then return StickyFlag::Tags::MMD::clear(file_name)
104
+ else raise Thor::Error.new("ERROR: Don't know how to clear all tags for a file of extension '#{extension}'")
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,25 @@
1
+ # -*- encoding : utf-8 -*-
2
+ require 'stickyflag/tags/source_code'
3
+
4
+ module StickyFlag
5
+ module Tags
6
+ module C
7
+ module_function
8
+
9
+ def comment_line_regex
10
+ /\A\/\/ .*/
11
+ end
12
+
13
+ def tag_line_regex
14
+ /\A\/\/ SF_TAGS = (.*)/
15
+ end
16
+
17
+ def tag_line_for(str)
18
+ "// SF_TAGS = #{str}"
19
+ end
20
+
21
+ include StickyFlag::Tags::SourceCode
22
+ module_function :get, :set, :unset, :clear
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,168 @@
1
+ # -*- encoding : utf-8 -*-
2
+ require 'stickyflag/patches/tmpnam.rb'
3
+
4
+ module StickyFlag
5
+ module Tags
6
+ module MMD
7
+ module_function
8
+
9
+ # We duplicate the source-code module here because we want to have
10
+ # cute support for the indentation of tag contents in MMD, and because
11
+ # MMD metadata blocks have slightly weird syntax rules.
12
+
13
+ def metadata_key_regex
14
+ /([A-Za-z0-9][A-Za-z0-9\-_ ]*):\s*(.*)/
15
+ end
16
+ def tag_line_regex
17
+ /[Tt]\s*[Aa]\s*[Gg]\s*[Ss]\s*:.*/
18
+ end
19
+
20
+ def tag_line_for(tags, last_line = nil)
21
+ padding = ''
22
+
23
+ if last_line
24
+ match = last_line.match(/([A-Za-z0-9][A-Za-z0-9\-_ ]*:\s*|\s*).*/)
25
+ if match
26
+ num = match[1].length - 'Tags: '.length
27
+ if num > 0
28
+ padding = ' ' * num
29
+ end
30
+ end
31
+ end
32
+
33
+ "Tags: #{padding}#{tags.join(', ')} "
34
+ end
35
+
36
+ def write_tags(file_name, tags)
37
+ set_tags = false
38
+ eating_tags = false
39
+ last_line = ''
40
+ counter = 0
41
+
42
+ outpath = File.tmpnam
43
+ File.open(outpath, 'w:UTF-8') do |outfile|
44
+ File.open(file_name, 'r:UTF-8').each_line do |line|
45
+ if counter == 0
46
+ if line !~ metadata_key_regex
47
+ # If we're on the first line and there's no metadata at all,
48
+ # then we have to add a metadata block
49
+ outfile.puts tag_line_for(tags) if tags
50
+ outfile.puts ''
51
+ set_tags = true
52
+ end
53
+ else
54
+ # Not on the first line
55
+ if set_tags == true
56
+ # If we've already set tags, this is easy
57
+ outfile.puts line
58
+ else
59
+ # We're somewhere in the metadta, and we've yet to set any
60
+ # tags.
61
+ if line =~ tag_line_regex
62
+ # We want to eat the tag block, which could theoretically
63
+ # be extended onto multiple lines
64
+ eating_tags = true
65
+ elsif eating_tags
66
+ # We're currently eating tags; if this line is blank or a
67
+ # key/value pair, we're done eating tags, time to print
68
+ if line =~ metadata_key_regex || line.strip.empty?
69
+ eating_tags = false
70
+ outfile.puts tag_line_for(tags, last_line) if tags
71
+ set_tags = true
72
+ end
73
+ else
74
+ # Not eating the tags key, just keep going, checking to see
75
+ # if the metadata block is over
76
+ if line.strip.empty?
77
+ outfile.puts tag_line_for(tags, last_line) if tags
78
+ set_tags = true
79
+ end
80
+
81
+ outfile.puts line
82
+ end
83
+ end
84
+ end
85
+
86
+ counter += 1
87
+ last_line = line
88
+ end
89
+ end
90
+
91
+ FileUtils.mv(outpath, file_name)
92
+ end
93
+
94
+ def get(file_name)
95
+ counter = 0
96
+ key = ''
97
+ value = ''
98
+
99
+ File.open(file_name, 'r:UTF-8').each_line do |line|
100
+ if counter == 0
101
+ # Check to see if there's any metadata at all
102
+ m = line.match(metadata_key_regex)
103
+ return [] unless m
104
+
105
+ # Start capturing values
106
+ key = m[1]
107
+ value = m[2]
108
+ else
109
+ # Check to see if the metadata is over, or if there's another
110
+ # key/value pair starting on this line
111
+ m = line.match(metadata_key_regex)
112
+
113
+ if m || line.strip.empty?
114
+ # We're done capturing this value, check if it's 'tags' (after
115
+ # removing whitespace and lowercasing, as per the spec)
116
+ key.gsub!(/\s/, '')
117
+ key.downcase!
118
+
119
+ if key == 'tags'
120
+ return [] if value.nil? || value.empty?
121
+ return value.split(',').map { |t| t.empty? ? nil : t.strip }.compact
122
+ end
123
+ end
124
+
125
+ # Quit if there's no more metadata
126
+ return if line.strip.empty?
127
+
128
+ # Start grabbing a new key/value if there is one, or else just
129
+ # add to the current value
130
+ if m
131
+ key = m[1]
132
+ value = m[2]
133
+ else
134
+ # This doesn't strip the indentation off of the values, but
135
+ # that's okay, since we always strip whitespace off
136
+ # individual tags
137
+ value << line
138
+ end
139
+ end
140
+
141
+ counter += 1
142
+ end
143
+
144
+ []
145
+ end
146
+
147
+ def set(file_name, tag)
148
+ tags = get(file_name)
149
+ return if tags.include? tag
150
+
151
+ tags << tag
152
+ write_tags(file_name, tags)
153
+ end
154
+
155
+ def unset(file_name, tag)
156
+ tags = get(file_name)
157
+ return unless tags.include? tag
158
+
159
+ tags.delete(tag)
160
+ write_tags(file_name, tags)
161
+ end
162
+
163
+ def clear(file_name)
164
+ write_tags(file_name, nil)
165
+ end
166
+ end
167
+ end
168
+ end