stickyflag 0.2.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 (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