redsync 0.1.1 → 0.1.2

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 (4) hide show
  1. data/README.textile +20 -17
  2. data/lib/redsync.rb +50 -139
  3. data/lib/redsync/sync_stat.rb +147 -0
  4. metadata +6 -5
data/README.textile CHANGED
@@ -4,54 +4,57 @@ h2. What's this?
4
4
 
5
5
  Sync your Redmine's wiki contents to your local filesystem.
6
6
  Edit them while offline, then upsync them to Redmine.
7
+ (Requires Ruby 1.9)
7
8
 
8
- h2. Usage
9
-
10
- Copy and edit config.yml.dist -> config.yml
9
+ h2. Install
11
10
 
12
11
  <pre>
13
- ---
14
- :url: "http://your.redmine.url/"
15
- :project_slug: "project_identifier"
16
- :username: "username"
17
- :password: "password"
18
- :data_dir: "~/redsync"
12
+ gem install redsync
19
13
  </pre>
20
14
 
21
- Run @bin/redsync@
15
+
16
+ h2. Usage
22
17
 
23
18
  <pre>
19
+ $ redsync --help
24
20
  Usage: redsync [options]
25
21
  -v, --[no-]verbose Output verbose logs
26
- -c, --config FILE Use specified config file instead of config.yml
22
+ -c, --config FILE Use specified config file instead of ~/redsync.yml
27
23
  -u, --upsync-only Upsync only, don't downsync
28
24
  -d, --downsync-only Downsync only, don't upsync
29
25
  </pre>
30
26
 
31
- h2. Dependencies
32
-
33
- * Ruby 1.9
34
- * Mechanize (@gem install mechanize@)
35
- * ActiveSupport (@gem install activesupport@)
36
27
 
37
28
  h2. How it works
38
29
 
39
30
  Just some scraping...
40
31
 
32
+
41
33
  h2. Warnings
42
34
 
43
35
  This software has NOT BEEN TESTED :(
44
36
  Use it at your own risk.
45
37
  Please beware that it may not work on all versions of Redmine (it's working fine with my installation of 1.2-stable)
46
38
 
39
+ h3. Time zones
40
+
47
41
  Redsync assumes that your local timezone is the same as your Redmine timezone.
48
42
  Sign in and go to "My Account" to change it.
49
43
 
50
- Redsync does not deal with edit conflicts well.
44
+ h3. Conflicts
45
+
46
+ Redsync does not deal with conflicts well.
51
47
  In fact, it does not deal with conflics AT ALL.
52
48
  A default run of redsync will downsync first, overwriting any local changes if pages are updated on Redmine.
53
49
  Then it will upsync any remaining changes.
54
50
 
51
+ h3. Deletions
52
+
53
+ Redsy does not delete anything.
54
+ It doesn't delete local files when remote wiki pages are gone.
55
+ It doesn't delete remote wiki pages when local files are gone either.
56
+
57
+
55
58
  h2. License
56
59
 
57
60
  The MIT License
data/lib/redsync.rb CHANGED
@@ -10,35 +10,38 @@ require 'mechanize'
10
10
  require 'active_support/all'
11
11
 
12
12
  require 'redsync/cli'
13
+ require 'redsync/sync_stat'
13
14
 
14
15
 
15
16
  class Redsync
16
17
 
17
- class Status
18
+ class Result
18
19
  DOWNLOADED = 1
19
20
  SKIPPED_UNKNOWN = 2
20
21
  SKIPPED_OLD = 4
21
22
  UPLOADED = 8
22
23
  CREATED = 16
23
- ERROR_ON_CREATE = 32
24
- ERROR_ON_EDIT = 64
24
+ ERROR_ON_UPLOAD = 32
25
+ ERROR_ON_CREATE = 64
25
26
  end
26
27
 
28
+
27
29
  def initialize(options)
28
30
  @config = {}
29
31
  @config.merge! options
30
32
  @config[:data_dir] = File.expand_path(@config[:data_dir])
31
33
  @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
34
  puts "Using data dir: #{@config[:data_dir]}"
34
35
 
35
- @login_url = @config[:url] + "/login"
36
- @wiki_base_url = @config[:url] + "/projects/" + @config[:project_slug] + "/wiki"
36
+ @config[:url] = @config[:url].match(/(.*?)\/?$/)[1]
37
+ @config[:login_url] = @config[:url] + "/login"
38
+ @config[:wiki_base_url] = @config[:url] + "/projects/" + @config[:project_slug] + "/wiki"
37
39
 
38
40
  initialize_system_files
39
- recover_pages_list
40
41
 
41
42
  @agent = Mechanize.new
43
+ @syncstat = SyncStat.new(@config, @agent)
44
+
42
45
  @logged_in = false
43
46
  end
44
47
 
@@ -48,19 +51,17 @@ class Redsync
48
51
  puts "Creating data dir"
49
52
  FileUtils.mkdir(@config[:data_dir])
50
53
  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
54
  end
54
55
 
55
56
 
56
57
  def login
57
- puts "Logging in as #{@config[:username]} to #{@login_url}..."
58
- page = @agent.get(@login_url)
58
+ puts "Logging in as #{@config[:username]} to #{@config[:login_url]}..."
59
+ page = @agent.get(@config[:login_url])
59
60
  login_form = page.form_with(:action => "/login")
60
61
  login_form.field_with(:name => "username").value = @config[:username]
61
62
  login_form.field_with(:name => "password").value = @config[:password]
62
63
  result_page = login_form.submit
63
- if result_page.link_with(:text => "Sign out")
64
+ if result_page.search("a.logout").any?
64
65
  puts "Logged in successfully."
65
66
  return true
66
67
  else
@@ -70,166 +71,76 @@ class Redsync
70
71
  end
71
72
 
72
73
 
73
- def pages
74
- refresh_pages_list if (!@pages || @pages.empty?)
75
- @pages
76
- end
77
-
78
-
79
74
  def downsync
75
+ @syncstat.refresh
80
76
  puts "\nDownsync:"
81
77
  statuses = {
82
- Status::DOWNLOADED => 0,
83
- Status::SKIPPED_OLD => 0,
84
- Status::SKIPPED_UNKNOWN => 0,
78
+ Result::DOWNLOADED => 0,
85
79
  }
86
- pages.each do |pagename, info|
87
- statuses[downsync_page(pagename)] += 1
80
+ @syncstat.remote_updated_page_names.each do |pagename|
81
+ statuses[download(pagename)] += 1
88
82
  end
89
- puts "Downloaded #{statuses[Status::DOWNLOADED]} pages."
90
- puts "Skipped #{statuses[Status::SKIPPED_OLD] + statuses[Status::SKIPPED_UNKNOWN]} pages."
83
+ puts "Downloaded #{statuses[Result::DOWNLOADED]} pages."
91
84
  end
92
85
 
93
86
 
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
87
+ def download(pagename)
88
+ stat = @syncstat.for(pagename)
116
89
 
117
90
  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
91
+ page = @agent.get(stat[:url] + "/edit")
92
+ File.open(stat[:local_file], "w+:UTF-8") { |f| f.write(page.search("textarea")[0].text) }
93
+ @syncstat.update(pagename, :downloaded_at => File.stat(stat[:local_file]).mtime.to_datetime)
122
94
 
123
- return Status::DOWNLOADED
95
+ return Result::DOWNLOADED
124
96
  end
125
97
 
126
98
 
127
99
  def upsync
128
100
  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
101
+ results = {
102
+ Result::UPLOADED => 0,
103
+ Result::CREATED => 0,
104
+ Result::ERROR_ON_UPLOAD => 0,
105
+ Result::ERROR_ON_CREATE => 0,
135
106
  }
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
107
+ @syncstat.new_page_names.each do |pagename|
108
+ results[upload(pagename, true)] += 1
109
+ end
110
+ @syncstat.local_updated_page_names.each do |pagename|
111
+ results[upload(pagename)] += 1
142
112
  end
143
- print "Created #{statuses[Status::CREATED]} pages."
144
- print " (#{statuses[Status::ERROR_ON_CREATE]} errors)" if statuses[Status::ERROR_ON_CREATE] > 0
113
+ print "Created #{results[Result::CREATED]} pages."
114
+ print " (#{results[Result::ERROR_ON_CREATE]} errors)" if results[Result::ERROR_ON_CREATE] > 0
145
115
  print "\n"
146
- print "Uploaded #{statuses[Status::UPLOADED]} pages."
147
- print " (#{statuses[Status::ERROR_ON_EDIT]} errors)" if statuses[Status::ERROR_ON_EDIT] > 0
116
+ print "Uploaded #{results[Result::UPLOADED]} pages."
117
+ print " (#{results[Result::ERROR_ON_UPLOAD]} errors)" if results[Result::ERROR_ON_UPLOAD] > 0
148
118
  print "\n"
149
- puts "Skipped #{statuses[Status::SKIPPED_OLD]} pages."
150
119
  end
151
120
 
152
121
 
153
- def upsync_page(pagename)
122
+ def upload(pagename, create = false)
154
123
  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
124
+ stat = @syncstat.for(pagename)
125
+ puts (create ? "--Create #{pagename}" : "--Upload #{pagename}")
158
126
 
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")
127
+ page = @agent.get(@config[:wiki_base_url] + "/" + pagename + "/edit")
166
128
  form = page.form_with(:id=>"wiki_form")
167
- form.field_with(:name=>"content[text]").value = File.open(filename, "r:UTF-8").read
129
+ form.field_with(:name=>"content[text]").value = File.open(stat[:local_file], "r:UTF-8").read
168
130
  result_page = form.submit
169
131
  errors = result_page.search("#errorExplanation li").map{|li|li.text}
132
+
170
133
  if errors.any?
171
134
  print "--Error: #{pagename}: "
172
135
  puts errors
173
- return (page_info ? Status::ERROR_ON_EDIT : Status::ERROR_ON_CREATE)
136
+ return (create ? Result::ERROR_ON_CREATE : Result::ERROR_ON_UPLOAD)
174
137
  else
175
138
  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
- }
139
+ @syncstat.update(pagename, {
140
+ :downloaded_at => now,
141
+ :remote_updated_at => now
142
+ })
143
+ return (create ? Result::CREATED : Result::UPLOADED)
232
144
  end
233
- history
234
145
  end
235
146
  end
@@ -0,0 +1,147 @@
1
+ require 'yaml'
2
+ require 'fileutils'
3
+
4
+ class Redsync
5
+ class SyncStat
6
+ include Enumerable
7
+
8
+ def initialize(config, agent)
9
+ @config = config
10
+ @agent = agent
11
+
12
+ @file = File.join(@config[:data_dir], "__redsync_stat.yml")
13
+ FileUtils.touch @file unless File.exist? @file
14
+ @stat = YAML.load_file(@file) || {}
15
+ end
16
+
17
+
18
+ def refresh
19
+ puts "Refreshing pages list"
20
+ page = @agent.get(@config[:wiki_base_url] + "/date_index")
21
+ now = DateTime.now
22
+
23
+ # Get remote and local update times using remote list
24
+ page.search("#content h3").each do |h3|
25
+ links = h3.next_element.search("a")
26
+ links.each do |link|
27
+ url = @config[:url] + link.attr("href")
28
+ name = URI.decode(url.match(/^#{@config[:wiki_base_url]}\/(.*)$/)[1]).force_encoding("UTF-8")
29
+ local_file = File.join(@config[:data_dir], "#{name}.txt")
30
+
31
+ remote_updated_at = DateTime.parse(h3.text + "T00:00:00" + now.zone)
32
+
33
+ if File.exist? local_file
34
+ local_updated_at = File.stat(local_file).mtime.to_datetime
35
+ if remote_updated_at.year == now.year && remote_updated_at.month == now.month && remote_updated_at.day == now.day
36
+ remote_updated_at = history(name)[0][:timestamp]
37
+ end
38
+ else
39
+ local_updated_at = DateTime.civil
40
+ end
41
+
42
+ update(name, {
43
+ :name => name,
44
+ :url => url,
45
+ :local_file => local_file,
46
+ :remote_updated_at => remote_updated_at,
47
+ :local_updated_at => local_updated_at,
48
+ }, true)
49
+
50
+ update(name, {
51
+ :downloaded_at => local_updated_at
52
+ }, true) unless File.exist? local_file
53
+ end
54
+ end
55
+
56
+ # Look for new page files at local
57
+ Dir.entries(@config[:data_dir]).each do |file|
58
+ fullpath = File.join(@config[:data_dir], file)
59
+ next if File.directory?(fullpath)
60
+ next if file =~ /^__redsync_/
61
+ name = Iconv.iconv("UTF-8", "UTF-8-MAC", file) if RUBY_PLATFORM =~ /darwin/
62
+ name = name.first.match(/(.*)\.txt$/)[1]
63
+ next if @stat[name]
64
+
65
+ local_file = File.join(@config[:data_dir], "#{name}.txt")
66
+ local_updated_at = File.stat(local_file).mtime.to_datetime
67
+ update(name, {
68
+ :name => name,
69
+ :url => @config[:wiki_base_url] + "/#{name}",
70
+ :local_file => local_file,
71
+ :remote_updated_at => nil,
72
+ :local_updated_at => local_updated_at,
73
+ :downloaded_at => local_updated_at
74
+ }, true)
75
+ end
76
+
77
+ write
78
+ end
79
+
80
+
81
+ def history(name)
82
+ puts "--Getting page history for #{name}" if @config[:verbose]
83
+ now = DateTime.now
84
+ history = []
85
+ page = @agent.get(@config[:wiki_base_url] + "/" + URI.encode(name) + "/history")
86
+ page.search("table.wiki-page-versions tbody tr").each do |tr|
87
+ timestamp = DateTime.parse(tr.search("td")[3].text + now.zone)
88
+ author_name = tr.search("td")[4].text.strip
89
+ history << {
90
+ :timestamp => timestamp,
91
+ :author_name => author_name
92
+ }
93
+ end
94
+ history
95
+ end
96
+
97
+
98
+ def each
99
+ @stat.each {|k,v| yield v}
100
+ end
101
+
102
+
103
+ def for(name)
104
+ @stat[name]
105
+ end
106
+
107
+
108
+ def update(name, hash, suspend_write = false)
109
+ @stat[name] ||= {}
110
+ @stat[name].merge! hash
111
+ write unless suspend_write
112
+ end
113
+
114
+
115
+ def write
116
+ File.open(@file, "w+:UTF-8") do |f|
117
+ f.write("# DO NOT EDIT THIS FILE!\n")
118
+ f.write(@stat.to_yaml)
119
+ end
120
+ end
121
+
122
+
123
+ def new_page_names
124
+ self.inject([]) do |sum, page|
125
+ sum << page[:name] if !page[:remote_updated_at]
126
+ sum
127
+ end
128
+ end
129
+
130
+
131
+ def remote_updated_page_names
132
+ self.inject([]) do |sum, page|
133
+ sum << page[:name] if page[:remote_updated_at] && (page[:remote_updated_at] > page[:downloaded_at])
134
+ sum
135
+ end
136
+ end
137
+
138
+
139
+ def local_updated_page_names
140
+ self.inject([]) do |sum, page|
141
+ sum << page[:name] if page[:downloaded_at] && (page[:local_updated_at] > page[:downloaded_at])
142
+ sum
143
+ end
144
+ end
145
+
146
+ end
147
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: redsync
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -13,7 +13,7 @@ date: 2011-12-07 00:00:00.000000000Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: mechanize
16
- requirement: &2153510840 !ruby/object:Gem::Requirement
16
+ requirement: &2160416520 !ruby/object:Gem::Requirement
17
17
  none: false
18
18
  requirements:
19
19
  - - ~>
@@ -21,10 +21,10 @@ dependencies:
21
21
  version: '2.0'
22
22
  type: :runtime
23
23
  prerelease: false
24
- version_requirements: *2153510840
24
+ version_requirements: *2160416520
25
25
  - !ruby/object:Gem::Dependency
26
26
  name: activesupport
27
- requirement: &2153510440 !ruby/object:Gem::Requirement
27
+ requirement: &2160416120 !ruby/object:Gem::Requirement
28
28
  none: false
29
29
  requirements:
30
30
  - - ! '>='
@@ -32,7 +32,7 @@ dependencies:
32
32
  version: '0'
33
33
  type: :runtime
34
34
  prerelease: false
35
- version_requirements: *2153510440
35
+ version_requirements: *2160416120
36
36
  description: Sync Redmine's wiki pages to your local filesystem. Edit as you like,
37
37
  then upsync.
38
38
  email: merikonjatta@gmail.com
@@ -42,6 +42,7 @@ extensions: []
42
42
  extra_rdoc_files: []
43
43
  files:
44
44
  - lib/redsync/cli.rb
45
+ - lib/redsync/sync_stat.rb
45
46
  - lib/redsync.rb
46
47
  - README.textile
47
48
  - config.yml.dist