redsync 0.1.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.
@@ -0,0 +1,77 @@
1
+ h1. Redsync
2
+
3
+ h2. What's this?
4
+
5
+ Sync your Redmine's wiki contents to your local filesystem.
6
+ Edit them while offline, then upsync them to Redmine.
7
+
8
+ h2. Usage
9
+
10
+ Copy and edit config.yml.dist -> config.yml
11
+
12
+ <pre>
13
+ ---
14
+ :url: "http://your.redmine.url/"
15
+ :project_slug: "project_identifier"
16
+ :username: "username"
17
+ :password: "password"
18
+ :data_dir: "~/redsync"
19
+ </pre>
20
+
21
+ Run @bin/redsync@
22
+
23
+ <pre>
24
+ Usage: redsync [options]
25
+ -v, --[no-]verbose Output verbose logs
26
+ -c, --config FILE Use specified config file instead of config.yml
27
+ -u, --upsync-only Upsync only, don't downsync
28
+ -d, --downsync-only Downsync only, don't upsync
29
+ </pre>
30
+
31
+ h2. Dependencies
32
+
33
+ * Ruby 1.9
34
+ * Mechanize (@gem install mechanize@)
35
+ * ActiveSupport (@gem install activesupport@)
36
+
37
+ h2. How it works
38
+
39
+ Just some scraping...
40
+
41
+ h2. Warnings
42
+
43
+ This software has NOT BEEN TESTED :(
44
+ Use it at your own risk.
45
+ Please beware that it may not work on all versions of Redmine (it's working fine with my installation of 1.2-stable)
46
+
47
+ Redsync assumes that your local timezone is the same as your Redmine timezone.
48
+ Sign in and go to "My Account" to change it.
49
+
50
+ Redsync does not deal with edit conflicts well.
51
+ In fact, it does not deal with conflics AT ALL.
52
+ A default run of redsync will downsync first, overwriting any local changes if pages are updated on Redmine.
53
+ Then it will upsync any remaining changes.
54
+
55
+ h2. License
56
+
57
+ The MIT License
58
+
59
+ Copyright © 2011 Shinya Maeyama.
60
+
61
+ Permission is hereby granted, free of charge, to any person obtaining a copy
62
+ of this software and associated documentation files (the “Software”), to deal
63
+ in the Software without restriction, including without limitation the rights
64
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
65
+ copies of the Software, and to permit persons to whom the Software is
66
+ furnished to do so, subject to the following conditions:
67
+
68
+ The above copyright notice and this permission notice shall be included in
69
+ all copies or substantial portions of the Software.
70
+
71
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
72
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
73
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
74
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
75
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
76
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
77
+ THE SOFTWARE.
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+ # -*- vim: filetype=ruby
3
+
4
+ if RUBY_VERSION < '1.9'
5
+ puts "Redsync requires Ruby >= 1.9.0"
6
+ exit
7
+ end
8
+
9
+ require 'pathname'
10
+ self_file = Pathname.new(__FILE__).realpath
11
+ $LOAD_PATH << File.join(File.dirname(self_file), "../lib")
12
+ require 'redsync'
13
+
14
+ Redsync::CLI.run
@@ -0,0 +1,6 @@
1
+ ---
2
+ :url: "http://your.redmine.url/"
3
+ :project_slug: "project_identifier"
4
+ :username: "username"
5
+ :password: "password"
6
+ :data_dir: "~/redsync"
@@ -0,0 +1,235 @@
1
+ # encoding:UTF-8
2
+
3
+ require 'rubygems'
4
+ require 'fileutils'
5
+ require 'uri'
6
+ require 'yaml'
7
+ require 'date'
8
+ require 'iconv'
9
+ require 'mechanize'
10
+ require 'active_support/all'
11
+
12
+ require 'redsync/cli'
13
+
14
+
15
+ class Redsync
16
+
17
+ class Status
18
+ DOWNLOADED = 1
19
+ SKIPPED_UNKNOWN = 2
20
+ SKIPPED_OLD = 4
21
+ UPLOADED = 8
22
+ CREATED = 16
23
+ ERROR_ON_CREATE = 32
24
+ ERROR_ON_EDIT = 64
25
+ end
26
+
27
+ def initialize(options)
28
+ @config = {}
29
+ @config.merge! options
30
+ @config[:data_dir] = File.expand_path(@config[:data_dir])
31
+ @config[:conflicts_dir] = File.join(@config[:data_dir], "__redsync_conflicts__")
32
+ @config[:pages_list_file] = File.join(@config[:data_dir], "__redsync_pages_list__.yml")
33
+ puts "Using data dir: #{@config[:data_dir]}"
34
+
35
+ @login_url = @config[:url] + "/login"
36
+ @wiki_base_url = @config[:url] + "/projects/" + @config[:project_slug] + "/wiki"
37
+
38
+ initialize_system_files
39
+ recover_pages_list
40
+
41
+ @agent = Mechanize.new
42
+ @logged_in = false
43
+ end
44
+
45
+
46
+ def initialize_system_files
47
+ unless File.exist? @config[:data_dir]
48
+ puts "Creating data dir"
49
+ FileUtils.mkdir(@config[:data_dir])
50
+ end
51
+ FileUtils.mkdir(@config[:conflicts_dir]) unless File.exist? @config[:conflicts_dir]
52
+ FileUtils.touch(@config[:pages_list_file]) unless File.exist? @config[:pages_list_file]
53
+ end
54
+
55
+
56
+ def login
57
+ puts "Logging in as #{@config[:username]} to #{@login_url}..."
58
+ page = @agent.get(@login_url)
59
+ login_form = page.form_with(:action => "/login")
60
+ login_form.field_with(:name => "username").value = @config[:username]
61
+ login_form.field_with(:name => "password").value = @config[:password]
62
+ result_page = login_form.submit
63
+ if result_page.link_with(:text => "Sign out")
64
+ puts "Logged in successfully."
65
+ return true
66
+ else
67
+ puts "Login failed."
68
+ return false
69
+ end
70
+ end
71
+
72
+
73
+ def pages
74
+ refresh_pages_list if (!@pages || @pages.empty?)
75
+ @pages
76
+ end
77
+
78
+
79
+ def downsync
80
+ puts "\nDownsync:"
81
+ statuses = {
82
+ Status::DOWNLOADED => 0,
83
+ Status::SKIPPED_OLD => 0,
84
+ Status::SKIPPED_UNKNOWN => 0,
85
+ }
86
+ pages.each do |pagename, info|
87
+ statuses[downsync_page(pagename)] += 1
88
+ end
89
+ puts "Downloaded #{statuses[Status::DOWNLOADED]} pages."
90
+ puts "Skipped #{statuses[Status::SKIPPED_OLD] + statuses[Status::SKIPPED_UNKNOWN]} pages."
91
+ end
92
+
93
+
94
+ def downsync_page(pagename)
95
+ now = DateTime.now
96
+
97
+ page_info = pages[pagename]
98
+ if !page_info
99
+ puts "--Skipping #{pagename}: unknown page"
100
+ return Status::SKIPPED_UNKNOWN
101
+ end
102
+
103
+ filename = File.join(@config[:data_dir], "#{pagename}.txt")
104
+
105
+ if File.exist? filename
106
+ local_updated_at = File.stat(filename).mtime.to_datetime
107
+ remote_updated_at = DateTime.parse(page_info[:updated_at_str] + "T00:00:00" + now.zone)
108
+ if remote_updated_at.year == now.year && remote_updated_at.month == now.month && remote_updated_at.day == now.day
109
+ remote_updated_at = page_history(pagename)[0][:timestamp]
110
+ end
111
+ if local_updated_at >= remote_updated_at
112
+ puts "--Skip #{pagename}: local (#{local_updated_at}) is newer than remote (#{remote_updated_at})" if @config[:verbose]
113
+ return Status::SKIPPED_OLD
114
+ end
115
+ end
116
+
117
+ puts "--Download #{pagename}"
118
+ page = @agent.get(page_info[:url] + "/edit")
119
+ File.open(filename, "w+:UTF-8") { |f| f.write(page.search("textarea")[0].text) }
120
+ @pages[pagename][:downloaded_at] = File.stat(filename).mtime.to_datetime
121
+ write_pages_list
122
+
123
+ return Status::DOWNLOADED
124
+ end
125
+
126
+
127
+ def upsync
128
+ puts "\nUpsync:"
129
+ statuses = {
130
+ Status::UPLOADED => 0,
131
+ Status::SKIPPED_OLD => 0,
132
+ Status::CREATED => 0,
133
+ Status::ERROR_ON_CREATE => 0,
134
+ Status::ERROR_ON_EDIT => 0
135
+ }
136
+ Dir.entries(@config[:data_dir]).each do |file|
137
+ fullpath = File.join(@config[:data_dir], file)
138
+ next if File.directory?(fullpath)
139
+ next if file =~ /^__redsync_/
140
+ file = Iconv.iconv("UTF-8", "UTF-8-MAC", file).first
141
+ statuses[upsync_page(File.basename(file, ".txt"))] += 1
142
+ end
143
+ print "Created #{statuses[Status::CREATED]} pages."
144
+ print " (#{statuses[Status::ERROR_ON_CREATE]} errors)" if statuses[Status::ERROR_ON_CREATE] > 0
145
+ print "\n"
146
+ print "Uploaded #{statuses[Status::UPLOADED]} pages."
147
+ print " (#{statuses[Status::ERROR_ON_EDIT]} errors)" if statuses[Status::ERROR_ON_EDIT] > 0
148
+ print "\n"
149
+ puts "Skipped #{statuses[Status::SKIPPED_OLD]} pages."
150
+ end
151
+
152
+
153
+ def upsync_page(pagename)
154
+ now = DateTime.now
155
+ page_info = pages[pagename]
156
+ filename = File.join(@config[:data_dir], "#{pagename}.txt")
157
+ local_updated_at = File.stat(filename).mtime.to_datetime
158
+
159
+ if page_info.try(:[], :downloaded_at) && local_updated_at <= page_info[:downloaded_at].to_datetime
160
+ puts "--Skip #{pagename}" if @config[:verbose]
161
+ return Status::SKIPPED_OLD
162
+ end
163
+
164
+ puts (page_info ? "--Upload #{pagename}" : "--Create #{pagename}")
165
+ page = @agent.get(@wiki_base_url + "/" + pagename + "/edit")
166
+ form = page.form_with(:id=>"wiki_form")
167
+ form.field_with(:name=>"content[text]").value = File.open(filename, "r:UTF-8").read
168
+ result_page = form.submit
169
+ errors = result_page.search("#errorExplanation li").map{|li|li.text}
170
+ if errors.any?
171
+ print "--Error: #{pagename}: "
172
+ puts errors
173
+ return (page_info ? Status::ERROR_ON_EDIT : Status::ERROR_ON_CREATE)
174
+ else
175
+ now = DateTime.now
176
+ pages[pagename] ||= {}
177
+ pages[pagename][:name] = pagename
178
+ pages[pagename][:url] = @wiki_base_url + "/" + URI.encode(pagename)
179
+ pages[pagename][:downloaded_at] = now
180
+ pages[pagename][:updated_at_str] = now.strftime("%Y-%m-%d")
181
+ write_pages_list
182
+ return (page_info ? Status::UPLOADED : Status::CREATED)
183
+ end
184
+ end
185
+
186
+
187
+ def refresh_pages_list
188
+ puts "Refreshing pages list"
189
+ @pages ||= {}
190
+ page = @agent.get(@wiki_base_url + "/date_index")
191
+
192
+ page.search("#content h3").each do |h3|
193
+ links = h3.next_element.search("a")
194
+ links.each do |link|
195
+ page_url = @config[:url] + link.attr("href")
196
+ pagename = URI.decode(page_url.match(/^#{@wiki_base_url}\/(.*)$/)[1]).force_encoding("UTF-8")
197
+ @pages[pagename] ||= {}
198
+ @pages[pagename][:name] = pagename
199
+ @pages[pagename][:url] = page_url
200
+ @pages[pagename][:updated_at_str] = h3.text
201
+ end
202
+ end
203
+
204
+ @pages
205
+ end
206
+
207
+
208
+ def recover_pages_list
209
+ @pages = YAML.load_file(@config[:pages_list_file])
210
+ @pages ||= {}
211
+ end
212
+
213
+
214
+ def write_pages_list
215
+ File.open(@config[:pages_list_file], "w+:UTF-8") do |f|
216
+ f.write(@pages.to_yaml)
217
+ end
218
+ end
219
+
220
+
221
+ def page_history(pagename)
222
+ puts "--Getting page history for #{pagename}" if @config[:verbose]
223
+ history = []
224
+ page = @agent.get(@wiki_base_url + "/" + URI.encode(pagename) + "/history")
225
+ page.search("table.wiki-page-versions tbody tr").each do |tr|
226
+ timestamp = DateTime.parse(tr.search("td")[3].text+"+0900")
227
+ author_name = tr.search("td")[4].text.strip
228
+ history << {
229
+ :timestamp => timestamp,
230
+ :author_name => author_name
231
+ }
232
+ end
233
+ history
234
+ end
235
+ end
@@ -0,0 +1,89 @@
1
+ require 'optparse'
2
+ require 'yaml'
3
+
4
+ class Redsync
5
+ class CLI
6
+ class << self
7
+
8
+ def run
9
+ parse_options
10
+ check_config_file
11
+
12
+ redsync = Redsync.new(YAML.load_file(@options.delete(:config_file)).merge(@options))
13
+ exit unless redsync.login
14
+
15
+ time do
16
+ redsync.downsync unless @options[:uponly]
17
+ redsync.upsync unless @options[:downonly]
18
+ end
19
+ end
20
+
21
+
22
+ def parse_options
23
+ @options = {
24
+ :config_file => "~/redsync.yml",
25
+ }
26
+
27
+ OptionParser.new do |opts|
28
+ opts.banner = "Usage: redsync [options]"
29
+ opts.on("-v", "--[no-]verbose", "Output verbose logs") do |v|
30
+ @options[:verbose] = v
31
+ end
32
+ opts.on("-c", "--config FILE", "Use specified config file instead of ~/redsync.yml") do |file|
33
+ @options[:config_file] = file
34
+ end
35
+ opts.on("-u", "--upsync-only", "Upsync only, don't downsync") do |v|
36
+ @options[:uponly] = v
37
+ end
38
+ opts.on("-d", "--downsync-only", "Downsync only, don't upsync") do |v|
39
+ @options[:downonly] = v
40
+ end
41
+ opts.on("-D", "--debug", "Debug mode. Requires ruby-debug19") do |v|
42
+ @options[:debug] = v
43
+ end
44
+ end.parse!
45
+
46
+ if @options[:debug]
47
+ require 'ruby-debug'
48
+ Debugger.settings[:autoeval] = true
49
+ end
50
+
51
+ @options[:config_file] = File.expand_path(@options[:config_file])
52
+ end
53
+
54
+
55
+ def check_config_file
56
+ if !File.exist? @options[:config_file]
57
+ Redsync::CLI.confirm("Config file #{@options[:config_file]} doesn't exist. Create?") do
58
+ FileUtils.cp("config.yml.dist", @options[:config_file])
59
+ puts "Creating config file in #{@options[:config_file]}."
60
+ puts "Edit it and call me again when you're done."
61
+ end
62
+ exit
63
+ end
64
+ end
65
+
66
+
67
+ def confirm(question, default_yes = true, &block)
68
+ print question
69
+ print (default_yes ? " [Y/n] " : " [N/y] ")
70
+ c = gets.strip
71
+ result =
72
+ if c =~ /^$/
73
+ default_yes
74
+ else
75
+ c =~ /^y/i
76
+ end
77
+ block.call if result && block
78
+ end
79
+
80
+
81
+ def time(&block)
82
+ start = Time.now
83
+ yield
84
+ puts "Finished in #{Time.now - start} seconds."
85
+ end
86
+
87
+ end
88
+ end
89
+ end
metadata ADDED
@@ -0,0 +1,73 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: redsync
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - merikonjatta
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2011-12-07 00:00:00.000000000Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: mechanize
16
+ requirement: &2153510840 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: '2.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: *2153510840
25
+ - !ruby/object:Gem::Dependency
26
+ name: activesupport
27
+ requirement: &2153510440 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ! '>='
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: *2153510440
36
+ description: Sync Redmine's wiki pages to your local filesystem. Edit as you like,
37
+ then upsync.
38
+ email: merikonjatta@gmail.com
39
+ executables:
40
+ - redsync
41
+ extensions: []
42
+ extra_rdoc_files: []
43
+ files:
44
+ - lib/redsync/cli.rb
45
+ - lib/redsync.rb
46
+ - README.textile
47
+ - config.yml.dist
48
+ - bin/redsync
49
+ homepage: http://github.com/merikonjatta/redsync
50
+ licenses: []
51
+ post_install_message:
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ none: false
57
+ requirements:
58
+ - - ! '>='
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ none: false
63
+ requirements:
64
+ - - ! '>='
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubyforge_project:
69
+ rubygems_version: 1.8.6
70
+ signing_key:
71
+ specification_version: 3
72
+ summary: Sync Redmine's wiki pages to your local filesystem.
73
+ test_files: []