attache_rails 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: d1fa11ba11f4fc5b00b924fd2491a67aa27a94a8
4
+ data.tar.gz: 9f2648a031e25cfd750d6c8d22f1b3f49ad931bd
5
+ SHA512:
6
+ metadata.gz: bbbea818903c87c0a1bcbbab938df1cd424326cabfeeda4529ab5b14d0e8c5aaa83acac38084974db12cea1b6071c6523ab9bca8c027495a94509ff5541963df
7
+ data.tar.gz: fc14a4292d0ebebb5f878c484c33bee445ea3b9dc31849ee4b41a17bdc2de8713e5669c24bdfdcd36555743134aa9fdbb1d165f348b5b12a0b10ed9ad252aa91
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2015 choonkeat
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # attache_rails
2
+
3
+ Ruby on Rails integration for [attache server](https://github.com/choonkeat/attache)
4
+
5
+ NOTE: You can learn how to use this gem by looking at the [changes made to our example app](https://github.com/choonkeat/attache-railsapp/commit/16cb1274dcce5be01b6c9d42ad60c30c106ad7f9) or follow the step by step instructions in this `README`
6
+
7
+ ## Dependencies
8
+
9
+ [React](https://github.com/reactjs/react-rails), jQuery, Bootstrap 3
10
+
11
+ ## Installation
12
+
13
+ Install the attache_rails package from Rubygems:
14
+
15
+ ``` bash
16
+ gem install attache_rails
17
+ ```
18
+
19
+ Or add this to your `Gemfile`
20
+
21
+ ``` ruby
22
+ gem "attache_rails"
23
+ ```
24
+
25
+ Add the attache javascript to your `application.js`
26
+
27
+ ``` javascript
28
+ //= require attache
29
+ ```
30
+
31
+ Or you can include the various scripts yourself
32
+
33
+ ``` javascript
34
+ //= require attache/cors_upload
35
+ //= require attache/bootstrap3
36
+ //= require attache/ujs
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ ### Database
42
+
43
+ To use `attache`, you only need to store the `path`, given to you after you've uploaded a file. So if you have an existing model, you only need to add a string, varchar or text field
44
+
45
+ ``` bash
46
+ rails generate migration AddPhotoPathToUsers photo_path:string
47
+ ```
48
+
49
+ To assign **multiple** images to **one** model, you'd only need one text field
50
+
51
+ ``` bash
52
+ rails generate migration AddPhotoPathToUsers photo_path:text
53
+ ```
54
+
55
+ ### Model
56
+
57
+ In your model, `serialize` the column
58
+
59
+ ``` ruby
60
+ class User < ActiveRecord::Base
61
+ serialize :photo_path, JSON
62
+ end
63
+ ```
64
+
65
+ ### New or Edit form
66
+
67
+ In your form, you would add some options to `file_field` using the `attache_options` helper method. For example, a regular file field may look like this:
68
+
69
+ ``` slim
70
+ = f.file_field :photo_path
71
+ ```
72
+
73
+ Change it to
74
+
75
+ ``` slim
76
+ = f.file_field :photo_path, **attache_options('64x64#', f.object.photo_path)
77
+ ```
78
+
79
+ Or if you're expecting multiple files uploaded, simply add `multiple: true`
80
+
81
+ ``` slim
82
+ = f.file_field :photo_path, multiple: true, **attache_options('64x64#', f.object.photo_path)
83
+ ```
84
+
85
+ NOTE: `64x64#` is just an example, you should define a suitable [geometry](http://www.imagemagick.org/Usage/resize/) for your form
86
+
87
+ ### Strong Parameters
88
+
89
+ You'd need to permit the new field in your controller. For example, a strong parameters definition may look like this in your `Users` controller
90
+
91
+ ``` ruby
92
+ def user_params
93
+ params.require(:user).permit(:name)
94
+ end
95
+ ```
96
+
97
+ If you're only accepting a single file upload, change it to
98
+
99
+ ``` ruby
100
+ def user_params
101
+ params.require(:user).permit(:name, :photo_path)
102
+ end
103
+ ```
104
+
105
+ If you're accepting multiple file uploads via `multiple: true`, change it to
106
+
107
+ ``` ruby
108
+ def user_params
109
+ params.require(:user).permit(:name, photo_path: [])
110
+ end
111
+ ```
112
+
113
+ ### Show
114
+
115
+ Use the `attache_urls` helper to obtain full urls for the values you've captured in your database.
116
+
117
+ ``` slim
118
+ - attache_urls(@user.photo_path, '128x128#') do |url|
119
+ = image_tag(url)
120
+ ```
121
+
122
+ Alternatively, you can get the list of urls and manipulate it however you want
123
+
124
+ ``` slim
125
+ = image_tag attache_urls(@user.photo_path, '128x128#').sample
126
+ ```
127
+
128
+ ### Environment configs
129
+
130
+ `ATTACHE_UPLOAD_URL` points to the attache server upload url. e.g. `http://localhost:9292/upload`
131
+
132
+ `ATTACHE_DOWNLOAD_URL` points to url prefix for downloading the resized images, e.g. `http://cdn.lvh.me:9292/view`
133
+
134
+ `ATTACHE_UPLOAD_DURATION` refers to the number of seconds before a signed upload request is considered expired, e.g. `600`
135
+
136
+ `ATTACHE_SECRET_KEY` is the shared secret with the `attache` server. e.g. `t0psecr3t`
137
+
138
+ * If this variable is not set, then upload requests will not be signed & `ATTACHE_UPLOAD_DURATION` will be ignored
139
+ * If this variable is set, it must be the same value as `SECRET_KEY` is set on the `attache` server
140
+
141
+
142
+ # License
143
+
144
+ MIT
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,3 @@
1
+ //= require attache/cors_upload
2
+ //= require attache/bootstrap3
3
+ //= require attache/ujs
@@ -0,0 +1,106 @@
1
+ var AttacheFileInput = React.createClass({displayName: "AttacheFileInput",
2
+
3
+ getInitialState: function() {
4
+ var files = {};
5
+ var array = ([].concat(JSON.parse(this.props['data-value'])));
6
+ $.each(array, function(uid, json) {
7
+ if (json) files[uid] = { path: json };
8
+ });
9
+ return {files: files};
10
+ },
11
+
12
+ onRemove: function(uid, e) {
13
+ delete this.state.files[uid];
14
+ this.setState(this.state);
15
+ e.preventDefault();
16
+ e.stopPropagation();
17
+ },
18
+
19
+ onChange: function() {
20
+ var file_element = this.getDOMNode().firstChild;
21
+ // user cancelled file chooser dialog. ignore
22
+ if (file_element.files.length == 0) return;
23
+ this.state.files = {};
24
+ this.setState(this.state);
25
+ // upload the file via CORS
26
+ var that = this;
27
+ new CORSUpload({
28
+ file_element: file_element, onComplete: this.setFileValue, onProgress: this.setFileValue,
29
+ onError: function(uid, status) { that.setFileValue(uid, { pctString: '90%', pctDesc: status, className: 'progress-bar progress-bar-danger' }); }
30
+ });
31
+ // we don't want the file binary to be uploaded in the main form
32
+ file_element.value = '';
33
+ },
34
+
35
+ setFileValue: function(key, value) {
36
+ this.state.files[key] = value;
37
+ this.setState(this.state);
38
+ },
39
+
40
+ render: function() {
41
+ var that = this;
42
+ var previews = [];
43
+ $.each(that.state.files, function(key, result) {
44
+ var json = JSON.stringify(result);
45
+ if (result.path) {
46
+ var parts = result.path.split('/');
47
+ parts.splice(parts.length-1, 0, encodeURIComponent(that.props['data-geometry'] || '128x128#'));
48
+ result.src = that.props['data-downloadurl'] + '/' + parts.join('/');
49
+ }
50
+ previews.push(
51
+ React.createElement("div", {className: "thumbnail"},
52
+ React.createElement("input", {type: "hidden", name: that.props.name, value: result.path, readOnly: "true"}),
53
+ React.createElement(AttacheFilePreview, React.__spread({}, result, {key: key, onRemove: that.onRemove.bind(that, key)}))
54
+ )
55
+ );
56
+ });
57
+ return (
58
+ React.createElement("label", {htmlFor: this.props.id, className: "attache-file-selector"},
59
+ React.createElement("input", React.__spread({type: "file"}, this.props, {onChange: this.onChange})),
60
+ previews
61
+ )
62
+ );
63
+ }
64
+ });
65
+
66
+ var AttacheFilePreview = React.createClass({displayName: "AttacheFilePreview",
67
+
68
+ getInitialState: function() {
69
+ return { srcWas: '' };
70
+ },
71
+
72
+ removeProgressBar: function() {
73
+ this.setState({ srcWas: this.props.src });
74
+ },
75
+
76
+ render: function() {
77
+ var className = this.props.className || "progress-bar progress-bar-striped active" + (this.props.src ? " progress-bar-success" : "");
78
+ var pctString = this.props.pctString || (this.props.src ? 100 : this.props.percentLoaded) + "%";
79
+ var pctDesc = this.props.pctDesc || (this.props.src ? 'Loading...' : pctString);
80
+ var img = (this.props.src ? (React.createElement("img", {src: this.props.src, onLoad: this.removeProgressBar})) : '');
81
+ var pctStyle = { width: pctString, minWidth: '3em' };
82
+ var cptStyle = { textOverflow: "ellipsis" };
83
+ var caption = React.createElement("div", {className: "pull-left", style: cptStyle}, this.props.filename || this.props.path && this.props.path.split('/').pop());
84
+
85
+ if (this.state.srcWas != this.props.src) {
86
+ var progress = (
87
+ React.createElement("div", {className: "progress"},
88
+ React.createElement("div", {className: className, role: "progressbar", "aria-valuenow": this.props.percentLoaded, "aria-valuemin": "0", "aria-valuemax": "100", style: pctStyle},
89
+ pctDesc
90
+ )
91
+ )
92
+ );
93
+ }
94
+
95
+ return (
96
+ React.createElement("div", {className: "attache-file-preview"},
97
+ progress,
98
+ img,
99
+ React.createElement("div", {className: "clearfix"},
100
+ caption,
101
+ React.createElement("a", {href: "#remove", className: "pull-right", onClick: this.props.onRemove, title: "Click to remove"}, "×")
102
+ )
103
+ )
104
+ );
105
+ }
106
+ });
@@ -0,0 +1,106 @@
1
+ var AttacheFileInput = React.createClass({
2
+
3
+ getInitialState: function() {
4
+ var files = {};
5
+ var array = ([].concat(JSON.parse(this.props['data-value'])));
6
+ $.each(array, function(uid, json) {
7
+ if (json) files[uid] = { path: json };
8
+ });
9
+ return {files: files};
10
+ },
11
+
12
+ onRemove: function(uid, e) {
13
+ delete this.state.files[uid];
14
+ this.setState(this.state);
15
+ e.preventDefault();
16
+ e.stopPropagation();
17
+ },
18
+
19
+ onChange: function() {
20
+ var file_element = this.getDOMNode().firstChild;
21
+ // user cancelled file chooser dialog. ignore
22
+ if (file_element.files.length == 0) return;
23
+ this.state.files = {};
24
+ this.setState(this.state);
25
+ // upload the file via CORS
26
+ var that = this;
27
+ new CORSUpload({
28
+ file_element: file_element, onComplete: this.setFileValue, onProgress: this.setFileValue,
29
+ onError: function(uid, status) { that.setFileValue(uid, { pctString: '90%', pctDesc: status, className: 'progress-bar progress-bar-danger' }); }
30
+ });
31
+ // we don't want the file binary to be uploaded in the main form
32
+ file_element.value = '';
33
+ },
34
+
35
+ setFileValue: function(key, value) {
36
+ this.state.files[key] = value;
37
+ this.setState(this.state);
38
+ },
39
+
40
+ render: function() {
41
+ var that = this;
42
+ var previews = [];
43
+ $.each(that.state.files, function(key, result) {
44
+ var json = JSON.stringify(result);
45
+ if (result.path) {
46
+ var parts = result.path.split('/');
47
+ parts.splice(parts.length-1, 0, encodeURIComponent(that.props['data-geometry'] || '128x128#'));
48
+ result.src = that.props['data-downloadurl'] + '/' + parts.join('/');
49
+ }
50
+ previews.push(
51
+ <div className="thumbnail">
52
+ <input type="hidden" name={that.props.name} value={result.path} readOnly="true" />
53
+ <AttacheFilePreview {...result} key={key} onRemove={that.onRemove.bind(that, key)}/>
54
+ </div>
55
+ );
56
+ });
57
+ return (
58
+ <label htmlFor={this.props.id} className="attache-file-selector">
59
+ <input type="file" {...this.props} onChange={this.onChange}/>
60
+ {previews}
61
+ </label>
62
+ );
63
+ }
64
+ });
65
+
66
+ var AttacheFilePreview = React.createClass({
67
+
68
+ getInitialState: function() {
69
+ return { srcWas: '' };
70
+ },
71
+
72
+ removeProgressBar: function() {
73
+ this.setState({ srcWas: this.props.src });
74
+ },
75
+
76
+ render: function() {
77
+ var className = this.props.className || "progress-bar progress-bar-striped active" + (this.props.src ? " progress-bar-success" : "");
78
+ var pctString = this.props.pctString || (this.props.src ? 100 : this.props.percentLoaded) + "%";
79
+ var pctDesc = this.props.pctDesc || (this.props.src ? 'Loading...' : pctString);
80
+ var img = (this.props.src ? (<img src={this.props.src} onLoad={this.removeProgressBar} />) : '');
81
+ var pctStyle = { width: pctString, minWidth: '3em' };
82
+ var cptStyle = { textOverflow: "ellipsis" };
83
+ var caption = <div className="pull-left" style={cptStyle}>{this.props.filename || this.props.path && this.props.path.split('/').pop()}</div>;
84
+
85
+ if (this.state.srcWas != this.props.src) {
86
+ var progress = (
87
+ <div className="progress">
88
+ <div className={className} role="progressbar" aria-valuenow={this.props.percentLoaded} aria-valuemin="0" aria-valuemax="100" style={pctStyle}>
89
+ {pctDesc}
90
+ </div>
91
+ </div>
92
+ );
93
+ }
94
+
95
+ return (
96
+ <div className="attache-file-preview">
97
+ {progress}
98
+ {img}
99
+ <div className="clearfix">
100
+ {caption}
101
+ <a href="#remove" className="pull-right" onClick={this.props.onRemove} title="Click to remove">&times;</a>
102
+ </div>
103
+ </div>
104
+ );
105
+ }
106
+ });
@@ -0,0 +1,85 @@
1
+ var CORSUpload = (function() {
2
+ var counter = 0;
3
+
4
+ CORSUpload.prototype.onComplete = function(uid, json) { };
5
+ CORSUpload.prototype.onProgress = function(uid, json) { };
6
+ CORSUpload.prototype.onError = function(uid, status) { alert(status); };
7
+
8
+ function CORSUpload(options) {
9
+ if (options == null) options = {};
10
+ for (option in options) {
11
+ this[option] = options[option];
12
+ }
13
+ this.handleFileSelect(options.file_element);
14
+ }
15
+
16
+ CORSUpload.prototype.handleFileSelect = function(file_element) {
17
+ var f, files, output, _i, _len, _results, url, $ele;
18
+ $ele = $(file_element);
19
+ url = $ele.data('uploadurl');
20
+ if ($ele.data('hmac')) {
21
+ url = url +
22
+ "?hmac=" + encodeURIComponent($ele.data('hmac')) +
23
+ "&uuid=" + encodeURIComponent($ele.data('uuid')) +
24
+ "&expiration=" + encodeURIComponent($ele.data('expiration')) +
25
+ ""
26
+ }
27
+
28
+ files = file_element.files;
29
+ output = [];
30
+ _results = [];
31
+ for (_i = 0, _len = files.length; _i < _len; _i++) {
32
+ f = files[_i];
33
+ f.uid = counter++;
34
+ this.onProgress(f.uid, { filename: f.name, percentLoaded: 0, bytesLoaded: 0, bytesTotal: f.size });
35
+ _results.push(this.performUpload(f, url));
36
+ }
37
+ return _results;
38
+ };
39
+
40
+ CORSUpload.prototype.createCORSRequest = function(method, url) {
41
+ var xhr;
42
+ xhr = new XMLHttpRequest();
43
+ if (xhr.withCredentials != null) {
44
+ xhr.open(method, url, true);
45
+ } else if (typeof XDomainRequest !== "undefined") {
46
+ xhr = new XDomainRequest();
47
+ xhr.open(method, url);
48
+ } else {
49
+ xhr = null;
50
+ }
51
+ return xhr;
52
+ };
53
+
54
+ CORSUpload.prototype.performUpload = function(file, url) {
55
+ var this_s3upload, xhr;
56
+ this_s3upload = this;
57
+ url = url + (url.indexOf('?') == -1 ? '?' : '&') + 'file=' + encodeURIComponent(file.name);
58
+ xhr = this.createCORSRequest('PUT', url);
59
+ if (!xhr) {
60
+ this.onError(file.uid, 'CORS not supported');
61
+ } else {
62
+ xhr.onload = function(e) {
63
+ if (xhr.status === 200) {
64
+ this_s3upload.onComplete(file.uid, JSON.parse(e.target.responseText));
65
+ } else {
66
+ return this_s3upload.onError(file.uid, xhr.status + ' ' + xhr.statusText);
67
+ }
68
+ };
69
+ xhr.onerror = function() {
70
+ return this_s3upload.onError(file.uid, 'Unable to reach server');
71
+ };
72
+ xhr.upload.onprogress = function(e) {
73
+ var percentLoaded;
74
+ if (e.lengthComputable) {
75
+ percentLoaded = Math.round((e.loaded / e.total) * 100);
76
+ return this_s3upload.onProgress(file.uid, { filename: file.name, percentLoaded: percentLoaded, bytesLoaded: e.loaded, bytesTotal: e.total });
77
+ }
78
+ };
79
+ }
80
+ return xhr.send(file);
81
+ };
82
+
83
+ return CORSUpload;
84
+
85
+ })();
@@ -0,0 +1,23 @@
1
+ (function() {
2
+ function attacheFileInputs() {
3
+ var safeWords = { 'class': 'className', 'for': 'htmlFor' };
4
+ var sel = document.getElementsByClassName('enable-attache');
5
+ var ele, attrs, name, value;
6
+ for (var i = sel.length-1; i >= 0; i--) {
7
+ ele = sel[i];
8
+ attrs = {};
9
+ for (var j = 0; j < ele.attributes.length; j++) {
10
+ name = ele.attributes[j].name;
11
+ value = ele.attributes[j].value;
12
+ if (name === 'class') value = value.replace('enable-attache', 'attache-enabled');
13
+ name = safeWords[name] || name;
14
+ attrs[name] = value;
15
+ }
16
+ var wrap = document.createElement('div');
17
+ ele.parentNode.replaceChild(wrap, ele); // ele.parentNode.insertBefore(wrap, ele.nextSibling);
18
+ React.render(React.createElement(AttacheFileInput, React.__spread({}, attrs)), wrap);
19
+ }
20
+ }
21
+
22
+ $(document).on('page:change', attacheFileInputs);
23
+ })();
@@ -0,0 +1,43 @@
1
+ require "attache_rails/engine"
2
+
3
+ module AttacheRails
4
+ module ViewHelper
5
+ ATTACHE_UPLOAD_URL = ENV.fetch('ATTACHE_UPLOAD_URL') { 'http://localhost:9292/upload' }
6
+ ATTACHE_DOWNLOAD_URL = ENV.fetch('ATTACHE_DOWNLOAD_URL') { 'http://localhost:9292/view' }
7
+ ATTACHE_UPLOAD_DURATION = ENV.fetch('ATTACHE_UPLOAD_DURATION') { '600' }.to_i # 10 minutes
8
+
9
+ def attache_urls(json, geometry)
10
+ array = json.kind_of?(Array) ? json : [*json]
11
+ array.collect do |path|
12
+ download_url = ATTACHE_DOWNLOAD_URL
13
+ prefix, basename = File.split(path)
14
+ [download_url, prefix, CGI.escape(geometry), basename].join('/').tap do |url|
15
+ yield url if block_given?
16
+ end
17
+ end
18
+ end
19
+
20
+ def attache_options(geometry, current_value)
21
+ auth = if ENV['ATTACHE_SECRET_KEY']
22
+ uuid = SecureRandom.uuid
23
+ expiration = (Time.now + ATTACHE_UPLOAD_DURATION).to_i
24
+ hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), ENV['ATTACHE_SECRET_KEY'], "#{uuid}#{expiration}")
25
+ { uuid: uuid, expiration: expiration, hmac: hmac }
26
+ else
27
+ {}
28
+ end
29
+
30
+ {
31
+ class: 'enable-attache',
32
+ data: {
33
+ geometry: geometry,
34
+ value: [*current_value],
35
+ uploadurl: ATTACHE_UPLOAD_URL,
36
+ downloadurl: ATTACHE_DOWNLOAD_URL,
37
+ }.merge(auth),
38
+ }
39
+ end
40
+ end
41
+ end
42
+
43
+ ActionView::Base.send(:include, AttacheRails::ViewHelper)
@@ -0,0 +1,5 @@
1
+ module AttacheRails
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace AttacheRails
4
+ end
5
+ end
@@ -0,0 +1,3 @@
1
+ module AttacheRails
2
+ VERSION = "0.0.4"
3
+ end
metadata ADDED
@@ -0,0 +1,55 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: attache_rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.4
5
+ platform: ruby
6
+ authors:
7
+ - choonkeat
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-02-26 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email:
15
+ - choonkeat@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - MIT-LICENSE
21
+ - README.md
22
+ - Rakefile
23
+ - app/assets/javascripts/attache.js
24
+ - app/assets/javascripts/attache/bootstrap3.js
25
+ - app/assets/javascripts/attache/bootstrap3.js.jsx
26
+ - app/assets/javascripts/attache/cors_upload.js
27
+ - app/assets/javascripts/attache/ujs.js
28
+ - lib/attache_rails.rb
29
+ - lib/attache_rails/engine.rb
30
+ - lib/attache_rails/version.rb
31
+ homepage: https://github.com/choonkeat/attache_rails
32
+ licenses:
33
+ - MIT
34
+ metadata: {}
35
+ post_install_message:
36
+ rdoc_options: []
37
+ require_paths:
38
+ - lib
39
+ required_ruby_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ required_rubygems_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ requirements: []
50
+ rubyforge_project:
51
+ rubygems_version: 2.4.5
52
+ signing_key:
53
+ specification_version: 4
54
+ summary: Client lib to use attache server
55
+ test_files: []