smugsync 0.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.
@@ -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
+ *