smugsync 0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,95 @@
1
+
2
+ module Smug
3
+
4
+ # TODO: maybe rename
5
+ class FetchCommand < Command
6
+
7
+ def exec
8
+ optparser = Trollop::Parser.new do
9
+ banner <<-END
10
+ Usage: smug fetch [<options>]
11
+ Show the status of local SmugMug folder.
12
+
13
+ Options:
14
+ END
15
+ opt :force,
16
+ "Force full refresh of albums and images list",
17
+ :short => :f
18
+ end
19
+
20
+ opts = Trollop::with_standard_exception_handling(optparser) do
21
+ optparser.parse(ARGV)
22
+ end
23
+
24
+ authenticate
25
+
26
+ status_message "Refreshing albums cache"
27
+ refresh_cache(:all_albums, opts) { |album, cache_status| status_message "." }
28
+ status_message " done\n"
29
+ end
30
+
31
+ # block is executed for each album with two argumens: album and cache_status
32
+ # cache_status is:
33
+ # - :refreshed album cache was refreshed
34
+ # - :fresh album in cache is the same as in server
35
+ # options can be:
36
+ # - :force => true list of album's images is always refreshed
37
+ # this is necessary for refreshing cache after uploads and here's why:
38
+ # - smugmug stores LastUpdated data for albums with 1 second precision
39
+ # - upload can be fast enough to create album and add image to the album
40
+ # in one second
41
+ def refresh_cache(albums_to_refresh, options = {}, &block)
42
+ authenticate
43
+
44
+ old_cache = if File.exist?(Config::config_file_name("cache"))
45
+ JSON::parse(Config::config_file("cache", "r").read)
46
+ else
47
+ []
48
+ end
49
+
50
+ # - to refresh all albums we need to reconstruct the cache from scratch
51
+ # to make sure that deleted albums are not left in the cache
52
+ # - to refresh selected albums we start from existing cache
53
+ # and replace only refreshed albums in that cache
54
+ albums_on_server = smugmug_albums_get(:Heavy => true)["Albums"]
55
+ if albums_to_refresh == :all_albums
56
+ albums_to_refresh = albums_on_server
57
+ new_cache = []
58
+ else
59
+ albums_to_refresh_ids = albums_to_refresh.map { |a| a["id"] }
60
+ # refetch albums metadata
61
+ albums_to_refresh = albums_on_server.select { |a| albums_to_refresh_ids.include?(a["id"]) }
62
+ # leave albums that should not be refreshed in the cache
63
+ new_cache = old_cache.reject { |a| albums_to_refresh_ids.include?(a["id"]) }
64
+ end
65
+
66
+ albums_to_refresh.each do |album|
67
+ cached_album = old_cache.find { |a| a["id"] == album["id"] }
68
+
69
+ if !options[:force] and cached_album and cached_album["LastUpdated"] == album["LastUpdated"]
70
+ # album is in cache and is not changed: copy images from old cache
71
+ # because it can take a long time to get the list of images
72
+ # from server for large albums
73
+ album["Images"] = cached_album["Images"]
74
+ yield(album, :skipped) if block_given?
75
+ else
76
+ # album was changed, get the list of images
77
+ album["Images"] = smugmug_images_get(
78
+ :AlbumID => album["id"],
79
+ :AlbumKey => album["Key"],
80
+ :Heavy => true
81
+ )["Album"]["Images"]
82
+ yield(album, :refreshed) if block_given?
83
+ end
84
+
85
+ new_cache << album
86
+ end
87
+
88
+ cache_file = Config::config_file("cache", "w+")
89
+ cache_file.puts JSON::pretty_generate(new_cache)
90
+ cache_file.close
91
+ end
92
+
93
+ end
94
+
95
+ end
@@ -0,0 +1,40 @@
1
+ require 'oauth'
2
+
3
+ module Smug
4
+
5
+ class InitCommand < Command
6
+
7
+ def exec
8
+ # need to request access token from the user
9
+ request_token = oauth_consumer.get_request_token
10
+ # TODO: explain what happens better
11
+ puts <<-EOS
12
+ Authorize app at:
13
+ #{request_token.authorize_url}&Access=Full&Permissions=Modify
14
+ Press Enter when finished
15
+ EOS
16
+ gets
17
+ access_token = nil
18
+ begin
19
+ access_token = request_token.get_access_token
20
+ rescue OAuth::Unauthorized
21
+ $stderr.puts "Fatal: Could not authorize with SmugMug. Run 'smug init' again."
22
+ exit(-1)
23
+ end
24
+
25
+ config = {
26
+ :access_token => {
27
+ :secret => access_token.secret,
28
+ :token => access_token.token
29
+ }
30
+ }
31
+ Smug::Config::create_config_dir
32
+ File.open(Smug::Config::config_file_name(Smug::Config::ACCESS_TOKEN_CONFIG_FILE), "w+") do |f|
33
+ f.puts JSON.pretty_generate(config)
34
+ end
35
+ puts "Initialized SmugMug folder and authorized with SmugMug"
36
+ end
37
+
38
+ end
39
+
40
+ end
@@ -0,0 +1,123 @@
1
+
2
+ module Smug
3
+
4
+ class StatusCommand < Command
5
+
6
+ include Utils
7
+
8
+ ALBUM_STATUS_FORMAT = "%20s\t%s/(%s files)"
9
+ IMAGE_STATUS_FORMAT = "%20s\t%s/%s"
10
+
11
+ def exec
12
+ authenticate
13
+
14
+ # TODO: support categories and nested categories
15
+ # for now comparison supports only flat list of albums
16
+ status = current_dir_status
17
+ status.each do |album_status|
18
+ if album_status[:status] != :not_modified
19
+ puts sprintf(ALBUM_STATUS_FORMAT, album_status[:status], album_status[:album], album_status[:images].length)
20
+ else
21
+ album_status[:images].each do |image_status|
22
+ puts sprintf(IMAGE_STATUS_FORMAT, image_status[:status], album_status[:album], image_status[:image])
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ def current_dir_status
29
+ current_dir = Config::relative_to_root(Dir.pwd).to_s
30
+ if current_dir == '.'
31
+ albums_status
32
+ else
33
+ Dir::chdir('..')
34
+ status = [album_status(
35
+ :local => current_dir,
36
+ :remote => albums.find { |a| a["Title"] == current_dir }
37
+ )]
38
+ Dir::chdir(current_dir)
39
+ status
40
+ end
41
+ end
42
+
43
+ def albums_status
44
+ status = []
45
+
46
+ local_albums = Pathname::pwd.children(false).find_all { |a| a.directory? and a.basename.to_s != ".smug" }.map { |a| a.basename.to_s }
47
+ remote_albums = albums.map { |a| a["Title"] }
48
+
49
+ status += albums.map do |album|
50
+ album_status(
51
+ :remote => album,
52
+ :local => local_albums.find { |a| a == album["Title"] }
53
+ )
54
+ end
55
+ status += (local_albums - remote_albums).map do |local_album|
56
+ album_status(:remote => nil, :local => local_album)
57
+ end
58
+
59
+ status
60
+ end
61
+
62
+ def album_status(options = { :local => nil, :remote => nil })
63
+ local = options[:local]
64
+ remote = options[:remote]
65
+
66
+ if !local and !remote
67
+ raise "album_status: requires either local or remote album"
68
+ elsif local and !remote
69
+ {
70
+ :album => local,
71
+ :status => :not_uploaded,
72
+ :images => local_images(local).map { |img| { :image => img, :status => :not_uploaded} }
73
+ }
74
+ elsif !local and remote
75
+ {
76
+ :album => remote["Title"],
77
+ :status => :not_downloaded,
78
+ :images => remote_images(remote).map { |img| { :image => img, :status => :not_downloaded} }
79
+ }
80
+ else
81
+ # TODO: assure that local and remote titles are the same
82
+ # TODO: document case insensitivity
83
+ not_uploaded = local_images(local) - remote_images(remote)
84
+ not_downloaded = remote_images(remote) - local_images(local)
85
+
86
+ {
87
+ :album => remote["Title"],
88
+ :status => :not_modified,
89
+ :images => not_uploaded.map { |img| { :image => img, :status => :not_uploaded} } + not_downloaded.map { |img| { :image => img, :status => :not_downloaded} }
90
+ }
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def albums
97
+ # TODO: status doesn't work without cache
98
+ @albums ||= JSON::parse Config::config_file("cache", "r").read
99
+ end
100
+
101
+ def local_images(album_path)
102
+ ignore_list = []
103
+
104
+ ignore_file_name = Pathname.new(album_path).join(".smugignore")
105
+ if File.exists?(ignore_file_name)
106
+ ignore_list = File.read(ignore_file_name).split($/)
107
+ end
108
+
109
+ Pathname.new(album_path).children(false).reject do |p|
110
+ filename = p.basename.to_s
111
+ filename == '.smugignore' or ignore_list.include? filename
112
+ end.map do |p|
113
+ p.basename.to_s.gsub(/^\d\d\d\./, '')
114
+ end
115
+ end
116
+
117
+ def remote_images(album)
118
+ album["Images"].map {|p| p["FileName"] }
119
+ end
120
+
121
+ end
122
+
123
+ end
@@ -0,0 +1,117 @@
1
+ require 'system_timer'
2
+ require 'md5'
3
+ require 'set'
4
+
5
+ module Smug
6
+
7
+ class UploadCommand < Command
8
+
9
+ # current upload algorithm:
10
+ # 1. get the list not uploaded stuff starting from current dir and below
11
+ # 2. chdir to root (for simplicity)
12
+ # 3. for each album
13
+ # 3.1. create it if it doesn't exist
14
+ # 3.2. upload files
15
+ def exec
16
+ optparser = Trollop::Parser.new do
17
+ banner <<-END
18
+ Usage: smug upload [<options>]
19
+ Upload files to SmugMug.
20
+
21
+ Options:
22
+ END
23
+ opt :timeout,
24
+ "Timeout to upload one file in seconds",
25
+ :short => :t,
26
+ :default => 24*3600
27
+ end
28
+
29
+ opts = Trollop::with_standard_exception_handling(optparser) do
30
+ optparser.parse(ARGV)
31
+ end
32
+
33
+ authenticate
34
+
35
+ status = StatusCommand.new.current_dir_status
36
+ Dir::chdir(Config::root_dir)
37
+
38
+ modified_albums = Set.new
39
+
40
+ status.each_with_index do |album_status, i|
41
+ if album_status[:status] == :not_uploaded
42
+ # create album
43
+ puts "Creating album #{album_status[:album]}"
44
+ result = smugmug_albums_create(
45
+ "Title" => album_status[:album]
46
+ )
47
+ raise "Cannot create album" unless result["stat"] == "ok"
48
+
49
+ # refresh cache for the newly created album
50
+ album = smugmug_albums_getInfo(
51
+ :AlbumID => result["Album"]["id"],
52
+ :AlbumKey => result["Album"]["Key"]
53
+ )["Album"]
54
+
55
+ FetchCommand.new.refresh_cache([album])
56
+ @albums = nil # TODO: hack to force reparsing of cache file
57
+ elsif album_status[:status] == :not_downloaded
58
+ next
59
+ end
60
+
61
+ album = albums.find { |a| a["Title"] == album_status[:album] }
62
+
63
+ files_to_upload = album_status[:images].find_all { |i| i[:status] == :not_uploaded }.map { |i| "#{album["Title"]}/#{i[:image]}" }
64
+
65
+ File.open("upload.log", "w+") { |f| f.puts "start" }
66
+ num_files = files_to_upload.length
67
+ files_to_upload.each_with_index do |filename, i|
68
+ modified_albums << album
69
+
70
+ begin
71
+ SystemTimer.timeout_after(opts[:timeout]) do
72
+ puts "Uploading #{filename} (#{Time.now.to_s}) (#{i}/#{num_files})"
73
+
74
+ data = File.open(filename, "rb") { |f| f.read }
75
+
76
+ req = {}
77
+ req['Content-Length'] = File.size(filename).to_s
78
+ req['Content-MD5'] = MD5.hexdigest(data)
79
+ req['X-Smug-AlbumID'] = album["id"].to_s
80
+ req['X-Smug-Version'] = '1.3.0'
81
+ req['X-Smug-FileName'] = filename
82
+ req['X-Smug-ResponseType'] = "JSON"
83
+
84
+ results = put("http://upload.smugmug.com/#{filename}", data, req)
85
+ puts " => #{results["stat"]} (#{Time.now.to_s})"
86
+ end
87
+ rescue Timeout::Error
88
+ puts " => timed out"
89
+ File.open("upload.log", "a") { |f| f.puts filename }
90
+
91
+ # TODO: delete image from server
92
+ rescue Exception => e
93
+ puts " => error #{e.message}"
94
+ puts e.backtrace.join("\n")
95
+ File.open("upload.log", "a") { |f| f.puts filename }
96
+
97
+ # TODO: delete image from server
98
+ end
99
+ end
100
+ end
101
+
102
+ # refresh cache for modified albums only
103
+ puts "Refreshing albums cache"
104
+ FetchCommand.new.refresh_cache(modified_albums, :force => true)
105
+ end
106
+
107
+ private
108
+
109
+ def albums
110
+ # TODO: upload doesn't work without cache
111
+ @albums ||= JSON::parse Config::config_file("cache", "r").read
112
+ end
113
+
114
+ end
115
+
116
+ end
117
+
@@ -0,0 +1,22 @@
1
+
2
+ module Smug
3
+
4
+ module Utils
5
+
6
+ def status_message(message)
7
+ $stdout.print message
8
+ $stdout.flush
9
+ end
10
+
11
+ def method_missing(method, *args, &block)
12
+ # if method looks like smugmug API call, call get()
13
+ if method.to_s =~ /^smugmug_/
14
+ get(method.to_s.gsub('_', '.'), *args, &block)
15
+ else
16
+ super(method, *args, &block)
17
+ end
18
+ end
19
+
20
+ end
21
+
22
+ end
data/lib/smugsync.rb ADDED
@@ -0,0 +1,11 @@
1
+ require 'json'
2
+ require 'trollop'
3
+ require 'smugsync/config'
4
+ require 'smugsync/utils'
5
+ require 'smugsync/command'
6
+ require 'smugsync/albums'
7
+ require 'smugsync/upload'
8
+ require 'smugsync/init'
9
+ require 'smugsync/status'
10
+ require 'smugsync/fetch'
11
+ require 'smugsync/download'
data/smugsync.gemspec ADDED
@@ -0,0 +1,27 @@
1
+ require 'rubygems'
2
+
3
+ SPEC = Gem::Specification.new do |s|
4
+ s.name = "smugsync"
5
+ s.version = "0.2"
6
+ s.author = "Alexander Dymo"
7
+ s.email = "adymo@kdevelop.org"
8
+ s.homepage = "http://github.com/adymo/smugsync"
9
+ s.platform = Gem::Platform::RUBY
10
+ s.summary = "."
11
+
12
+ s.add_development_dependency('bundler', '>= 1.0.0')
13
+ s.add_development_dependency('assert_same', '>= 0.1.0')
14
+
15
+ s.add_dependency('trollop', '>= 1.16.0')
16
+ s.add_dependency('json', '>= 1.6.0')
17
+ s.add_dependency('oauth', '>= 0.4.0')
18
+ s.add_dependency('system_timer', '>= 1.2.0')
19
+
20
+ s.files = `git ls-files`.split("\n")
21
+ s.test_files = `git ls-files -- test/*`.split("\n")
22
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
23
+
24
+ s.require_path = "lib"
25
+ s.autorequire = "smugsync"
26
+ s.has_rdoc = false
27
+ end
data/smugsync.kdev4 ADDED
@@ -0,0 +1,3 @@
1
+ [Project]
2
+ Manager=KDevGenericManager
3
+ Name=smugsync
@@ -0,0 +1 @@
1
+ *
@@ -0,0 +1,15 @@
1
+
2
+ class Smug::Command
3
+
4
+ alias_method :get_without_mock, :get
5
+ def get(method, params = {})
6
+ mocked_method = method.gsub(".", "_")
7
+ server = SmugServer.new
8
+ if server.respond_to?(mocked_method)
9
+ JSON.parse(server.send(mocked_method, params))
10
+ else
11
+ get_without_mock(method, params)
12
+ end
13
+ end
14
+
15
+ end
@@ -0,0 +1,11 @@
1
+
2
+ class OAuth::RequestToken
3
+
4
+ TEST_ACCESS_TOKEN = {
5
+ "access_token" => {
6
+ :secret => "f186f4ec22d33f1ffda956cd2fdcfa247c0282686cfe5fd80611dd9d62f20a12",
7
+ :token => "fc5eed66ecef208453842516b1cac024"
8
+ }
9
+ }
10
+
11
+ end
@@ -0,0 +1,196 @@
1
+
2
+ class SmugServer
3
+
4
+ attr_reader :server_albums
5
+
6
+ def initialize
7
+ @server_albums = get_server_albums_info
8
+ end
9
+
10
+ def smugmug_albums_get(params = { :Heavy => false })
11
+ result = {
12
+ "stat" => "ok",
13
+ "method" => "smugmug.albums.get",
14
+ "Albums" => []
15
+ }
16
+
17
+ server_albums.each do |album|
18
+ if params[:Heavy]
19
+ result["Albums"] << album.delete_if { |key, value| key == 'Images' }
20
+ else
21
+ result["Albums"] << album.delete_if do |key, value|
22
+ not ["id", "Key", "Category", "SubCategory", "Title"].include?(key)
23
+ end
24
+ end
25
+ end
26
+
27
+ result.to_json
28
+ end
29
+
30
+ def smugmug_images_get(params = { :AlbumID => nil, :AlbumKey => nil, :Heavy => false })
31
+ result = {
32
+ "stat" => "ok",
33
+ "method" => "smugmug.images.get",
34
+ }
35
+
36
+ album = server_albums.find { |album| album["id"] == params[:AlbumID] and album["Key"] == params[:AlbumKey] }
37
+ return result unless album
38
+
39
+ result["Album"] = {
40
+ "id" => params[:AlbumID],
41
+ "Key" => params[:AlbumKey],
42
+ "ImageCount" => 0,
43
+ "Images" => []
44
+ }
45
+
46
+ album["Images"].each do |image|
47
+ result["Album"]["ImageCount"] += 1
48
+ if params[:Heavy]
49
+ result["Album"]["Images"] << image
50
+ else
51
+ result["Album"]["Images"] << image.delete_if do |key, value|
52
+ not ["id", "Key"].include?(key)
53
+ end
54
+ end
55
+ end
56
+
57
+ result.to_json
58
+ end
59
+
60
+ private
61
+
62
+ def get_server_albums_info
63
+ albums = []
64
+
65
+ server_dir = Dir.new(SmugTestCase.server_dir)
66
+ album_id = 0
67
+ image_id = 0
68
+ server_dir.each do |album_name|
69
+ next if ['.', '..', '.gitignore'].include?(album_name)
70
+
71
+ album_full_path = File.join(SmugTestCase.server_dir, album_name)
72
+ raise "Server directory contains file #{album_name} outside of album" unless File.directory?(album_full_path)
73
+
74
+ # nice name in smugmug is the name users see in url
75
+ # currently we do nothing with that but let's keep it
76
+ # different from album name
77
+ album_nice_name = album_name.gsub(" ", "_")
78
+ album_id += 1
79
+ album_key = "key#{album_id.to_s(27).tr("0-9a-q", "A-Z")}"
80
+
81
+ album = {
82
+ # default settings for smugmug albums that we do not care about
83
+ "WordSearchable" => true,
84
+ "Theme" => {
85
+ "Name" => "default",
86
+ "id" => 1
87
+ },
88
+ "SortMethod" => "Position",
89
+ "ImageCount" => 1,
90
+ "Highlight" => {
91
+ "id" => 1725930094,
92
+ "Key" => "b9fk6m6",
93
+ "Type" => "Random"
94
+ },
95
+ "External" => true,
96
+ "Comments" => true,
97
+ "Clean" => false,
98
+ "X3Larges" => true,
99
+ "Keywords" => "",
100
+ "UnsharpAmount" => 0.2,
101
+ "Public" => true,
102
+ "Position" => 1,
103
+ "UnsharpRadius" => 1,
104
+ "SortDirection" => false,
105
+ "Filenames" => true,
106
+ "ColorCorrection" => 2,
107
+ "SquareThumbs" => true,
108
+ "Originals" => true,
109
+ "SmugSearchable" => true,
110
+ "Header" => false,
111
+ "X2Larges" => true,
112
+ "FamilyEdit" => false,
113
+ "EXIF" => true,
114
+ "UnsharpThreshold" => 0.05,
115
+ "Template" => {
116
+ "id" => 0
117
+ },
118
+ "HideOwner" => false,
119
+ "CanRank" => true,
120
+ "UnsharpSigma" => 1,
121
+ "Share" => true,
122
+ "Protected" => false,
123
+ "Printable" => true,
124
+ "UploadKey" => "",
125
+ "Geography" => true,
126
+ "FriendEdit" => false,
127
+ "Category" => {
128
+ "Name" => "TestCategory",
129
+ "id" => 1
130
+ },
131
+ "Passworded" => false,
132
+ "PasswordHint" => "",
133
+ "Password" => "",
134
+ "Description" => "",
135
+
136
+ # albums settings that we currently care about
137
+ "id" => album_id,
138
+ "Key" => album_key,
139
+ "NiceName" => album_nice_name,
140
+ "URL" => "http://user.smugmug.com/TestCategory/#{album_nice_name}/#{album_id}_#{album_key}",
141
+ "Title" => album_name,
142
+ "LastUpdated" => "2012-02-26 02:40:44",
143
+ "Images" => []
144
+ }
145
+
146
+ album_dir = Dir.new(album_full_path)
147
+ album_dir.each do |image_name|
148
+ next if ['.', '..'].include?(image_name)
149
+
150
+ image_id += 1
151
+ image_key = "key#{image_id.to_s(27).tr("0-9a-q", "A-Z")}"
152
+
153
+ album["Images"] << {
154
+ # image urls, for now not used/tested
155
+ "URL" => "http://url",
156
+ "OriginalURL" => "http://original_url",
157
+ "X3LargeURL" => "http://xl3_url",
158
+ "X2LargeURL" => "http://xl2_url",
159
+ "XLargeURL" => "http://xl_url",
160
+ "LargeURL" => "http://large_url",
161
+ "MediumURL" => "http://medium_url",
162
+ "SmallURL" => "http://small_url",
163
+ "TinyURL" => "http://tiny_url",
164
+ "ThumbURL" => "http://thumb_url",
165
+ "LightboxURL" => "http://lightbox_url",
166
+
167
+ # settings that we don't care about
168
+ "Status" => "Open",
169
+ "Keywords" => "",
170
+ "Hidden" => false,
171
+ "Serial" => 1,
172
+ "Position" => 1,
173
+ "Size" => 91144,
174
+ "Width" => 638,
175
+ "Height" => 638,
176
+ "Format" => "JPG",
177
+ "Date" => "2012-02-26 02:40:44",
178
+ "Watermark" => false,
179
+ "MD5Sum" => "d63af258e5ec78db084af9164a8f3581",
180
+ "Caption" => "",
181
+ "LastUpdated" => "2012-02-26 02:42:01",
182
+
183
+ # albums settings that we currently care about
184
+ "FileName" => image_name,
185
+ "id" => image_id,
186
+ "Key" => image_key,
187
+ "Type" => "Album",
188
+ }
189
+ end
190
+
191
+ albums << album
192
+ end
193
+ albums
194
+ end
195
+
196
+ end
@@ -0,0 +1 @@
1
+ *