nippocf 0.0.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.
- checksums.yaml +7 -0
- data/.gitignore +18 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +22 -0
- data/README.md +59 -0
- data/Rakefile +1 -0
- data/bin/nippocf +5 -0
- data/lib/confluence/confluence_connector.rb +63 -0
- data/lib/confluence/confluence_remote_data_object.rb +187 -0
- data/lib/confluence/confluence_rpc.rb +83 -0
- data/lib/confluence/metadata.rb +117 -0
- data/lib/confluence/page.rb +139 -0
- data/lib/confluence/user.rb +108 -0
- data/lib/nippocf.rb +5 -0
- data/lib/nippocf/cli.rb +139 -0
- data/lib/nippocf/version.rb +3 -0
- data/nippocf.gemspec +27 -0
- metadata +145 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 579949b4522549a1a6035a829c03bef5577e0269
|
4
|
+
data.tar.gz: 2a6c9671173d25b5b3385cc8e4a8458935783467
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 1d237fc674ff329c45eb069d1e122d8bdeaf3a260579947dd26108f5fff8d98e871c7019c2076f2f5ac6d96709a99629f1c6429fed3da812e4028426caeeaebe
|
7
|
+
data.tar.gz: 72a937c571bdf1d66fa5158f94070d8a67ea823c79a048d95b5f5fb1f0ce7ff042b6ad86ea9122c90861be992ac86a5a907eb3925f92642bd77a2cb169372a37
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2014 Ryota Arai
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
# Nippocf
|
2
|
+
|
3
|
+
Write nippo (daily report in Japanese) on Atlassian Confluence.
|
4
|
+
|
5
|
+
Nippocf:
|
6
|
+
* posts blog entry
|
7
|
+
* convert markdown to html
|
8
|
+
|
9
|
+
## Installation
|
10
|
+
|
11
|
+
$ gem install nippocf
|
12
|
+
|
13
|
+
## Requirements
|
14
|
+
|
15
|
+
* Mac OS X
|
16
|
+
* because this gem uses keychain to save your password
|
17
|
+
|
18
|
+
## Usage
|
19
|
+
|
20
|
+
```
|
21
|
+
$ export CONFL_URL="https://your-confluence-host"
|
22
|
+
$ export CONFL_USERNAME="your.user.name"
|
23
|
+
$ nippocf
|
24
|
+
Password for your.user.name: <type here, the password will be stored in keychain>
|
25
|
+
# Editor opens automatically
|
26
|
+
# Write nippo in markdown
|
27
|
+
# Nippocf posts a blog entry.
|
28
|
+
```
|
29
|
+
|
30
|
+
```
|
31
|
+
$ # For instance, it's 2013/12/31
|
32
|
+
$ # Edit nippo of 2013/12/31
|
33
|
+
$ nippocf
|
34
|
+
$ # Edit nippo of 2013/12/20
|
35
|
+
$ nippocf 20
|
36
|
+
$ # Edit nippo of 2013/11/20
|
37
|
+
$ nippocf 11/20
|
38
|
+
$ # Edit nippo of 2012/11/20
|
39
|
+
$ nippocf 2012/11/20
|
40
|
+
```
|
41
|
+
|
42
|
+
### SOCKS Proxy
|
43
|
+
|
44
|
+
If you set `ENV['SOCKS_PROXY']`, this connect to confluence server via SOCKS proxy.
|
45
|
+
|
46
|
+
## Contributing
|
47
|
+
|
48
|
+
1. Fork it ( http://github.com/<my-github-username>/nippo_confl/fork )
|
49
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
50
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
51
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
52
|
+
5. Create new Pull Request
|
53
|
+
|
54
|
+
# Licence
|
55
|
+
|
56
|
+
MIT Licence
|
57
|
+
|
58
|
+
This includes source code of confluence4r which requires MIT licence.
|
59
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
data/bin/nippocf
ADDED
@@ -0,0 +1,63 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
require 'confluence/confluence_rpc'
|
4
|
+
|
5
|
+
class Confluence::Connector
|
6
|
+
|
7
|
+
attr_accessor :username, :password, :default_service, :url,
|
8
|
+
:admin_proxy_username, :admin_proxy_password
|
9
|
+
|
10
|
+
def initialize(options = {})
|
11
|
+
load_confluence_config
|
12
|
+
@url ||= options[:url]
|
13
|
+
@username = options[:username] || @username
|
14
|
+
@password = options[:password] || @password
|
15
|
+
@default_service = options[:service] || 'confluence1'
|
16
|
+
end
|
17
|
+
|
18
|
+
def connect(service = nil)
|
19
|
+
unless url and username and password and service || default_service
|
20
|
+
raise "Cannot get Confluence::RPC instance because the confluence url, username, password, or service have not been set"
|
21
|
+
end
|
22
|
+
|
23
|
+
rpc = Confluence::RPC.new(url, service || default_service)
|
24
|
+
rpc.login(username, password)
|
25
|
+
|
26
|
+
return rpc
|
27
|
+
end
|
28
|
+
|
29
|
+
def load_confluence_config
|
30
|
+
conf_path = File.expand_path("~/.confluence.yml")
|
31
|
+
conf = if File.exist?(conf_path)
|
32
|
+
YAML.load_file(conf_path)
|
33
|
+
else
|
34
|
+
{}
|
35
|
+
end
|
36
|
+
@url = conf['url'] || conf[:url]
|
37
|
+
@default_service = conf['service'] || conf[:service]
|
38
|
+
@username = conf['username'] || conf[:username]
|
39
|
+
@password = conf['password'] || conf[:password]
|
40
|
+
@admin_proxy_username = conf['admin_proxy_username'] || conf[:admin_proxy_username]
|
41
|
+
@admin_proxy_password = conf['admin_proxy_password'] || conf[:admin_proxy_password]
|
42
|
+
end
|
43
|
+
|
44
|
+
# The given block will be executed using another account, as set in the confluence.yml file under admin_proxy_username
|
45
|
+
# and admin_proxy_password. This is useful when you want to execute some function that requires admin privileges. You
|
46
|
+
# will of course have to set up a corresponding account on your Confluence server with administrative rights.
|
47
|
+
def self.through_admin_proxy
|
48
|
+
super_connector = Confluence::Connector.new
|
49
|
+
|
50
|
+
raise "Cannot execute through_admin_proxy because the admin_proxy_username has not been set." unless super_connector.admin_proxy_username
|
51
|
+
raise "Cannot execute through_admin_proxy because the admin_proxy_password has not been set." unless super_connector.admin_proxy_password
|
52
|
+
|
53
|
+
super_connector.username = super_connector.admin_proxy_username
|
54
|
+
super_connector.password = super_connector.admin_proxy_password
|
55
|
+
|
56
|
+
normal_connector = Confluence::RemoteDataObject.connector
|
57
|
+
Confluence::RemoteDataObject.connector = super_connector
|
58
|
+
|
59
|
+
yield
|
60
|
+
|
61
|
+
Confluence::RemoteDataObject.connector = normal_connector
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,187 @@
|
|
1
|
+
require 'active_support'
|
2
|
+
require 'confluence/confluence_connector'
|
3
|
+
|
4
|
+
# Abstract object representing some piece of data in Confluence.
|
5
|
+
# This must be overridden by a child class that defines values
|
6
|
+
# for the class attributes save_method and get_method and/or
|
7
|
+
# implements its own get and save methods.
|
8
|
+
class Confluence::RemoteDataObject
|
9
|
+
# include Reloadable
|
10
|
+
|
11
|
+
class_inheritable_accessor :attr_conversions, :readonly_attrs
|
12
|
+
class_inheritable_accessor :save_method, :get_method, :destroy_method
|
13
|
+
|
14
|
+
attr_accessor :attributes
|
15
|
+
|
16
|
+
attr_accessor :confluence, :encore
|
17
|
+
|
18
|
+
@@connector = nil
|
19
|
+
|
20
|
+
def self.connector=(connector)
|
21
|
+
@@connector = connector
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.connector
|
25
|
+
@@connector
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.confluence
|
29
|
+
raise "Cannot establish confluence connection because the connector has not been set." unless @@connector
|
30
|
+
@@connector.connect
|
31
|
+
end
|
32
|
+
|
33
|
+
def confluence
|
34
|
+
raise "Cannot establish confluence connection because the connector has not been set." unless @@connector
|
35
|
+
@@connector.connect
|
36
|
+
end
|
37
|
+
|
38
|
+
# TODO: encore-specific code like this probably shouldn't be here...
|
39
|
+
def self.encore
|
40
|
+
raise "Cannot establish confluence connection because the connector has not been set." unless @@connector
|
41
|
+
@@connector.connect("encore")
|
42
|
+
end
|
43
|
+
|
44
|
+
def encore
|
45
|
+
raise "Cannot establish confluence connection because the connector has not been set." unless @@connector
|
46
|
+
@@connector.connect("encore")
|
47
|
+
end
|
48
|
+
|
49
|
+
def initialize(data_object = nil)
|
50
|
+
self.attributes = {}
|
51
|
+
load_from_object(data_object) unless data_object.nil?
|
52
|
+
end
|
53
|
+
|
54
|
+
def load_from_object(data_object)
|
55
|
+
data_object.each do |attr, value|
|
56
|
+
if self.class.attr_conversions.has_key? attr.to_sym
|
57
|
+
value = self.send("as_#{attr_conversions[attr.to_sym]}", value)
|
58
|
+
end
|
59
|
+
self.send("#{attr}=", value)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def save
|
64
|
+
before_save if respond_to? :before_save
|
65
|
+
|
66
|
+
data = {} unless data
|
67
|
+
|
68
|
+
self.attributes.each do |attr,value|
|
69
|
+
data[attr.to_s] = value.to_s unless self.readonly_attrs.include? attr
|
70
|
+
end
|
71
|
+
|
72
|
+
raise NotImplementedError.new("Can't call #{self}.save because no @@save_method is defined for this class") unless self.save_method
|
73
|
+
|
74
|
+
self.confluence.send(self.class.send(:save_method), data)
|
75
|
+
|
76
|
+
# we need to reload because the version number has probably changed, we want the new ID, etc.
|
77
|
+
reload
|
78
|
+
|
79
|
+
after_save if respond_to? :after_save
|
80
|
+
end
|
81
|
+
|
82
|
+
def reload
|
83
|
+
before_reload if respond_to? :before_reload
|
84
|
+
|
85
|
+
if self.id
|
86
|
+
self.load_from_object(self.class.send(:get, self.id))
|
87
|
+
else
|
88
|
+
# We don't have an ID, so try to use alternate method for reloading. (This is for newly created records that may not yet have an id assigned)
|
89
|
+
raise NotImplementedError, "Can't reload this #{self.class} because it does not have an id and does not implement the reload_newly_created! method." unless self.respond_to? :reload_newly_created!
|
90
|
+
self.reload_newly_created!
|
91
|
+
end
|
92
|
+
|
93
|
+
after_reload if respond_to? :after_reload
|
94
|
+
end
|
95
|
+
|
96
|
+
def destroy
|
97
|
+
before_destroy if respond_to? :before_destroy
|
98
|
+
|
99
|
+
raise NotImplementedError.new("Can't call #{self}.destroy because no @@destroy_method is defined for this class") unless self.destroy_method
|
100
|
+
eval "confluence.#{self.destroy_method}(self.id.to_s)"
|
101
|
+
|
102
|
+
after_destroy if respond_to? :after_destroy
|
103
|
+
end
|
104
|
+
|
105
|
+
def [](attr)
|
106
|
+
self.attributes[attr]
|
107
|
+
end
|
108
|
+
|
109
|
+
def []=(attr, value)
|
110
|
+
self.attributes[attr] = value
|
111
|
+
end
|
112
|
+
|
113
|
+
def id
|
114
|
+
self[:id]
|
115
|
+
end
|
116
|
+
|
117
|
+
def method_missing(name, *args)
|
118
|
+
if name.to_s =~ /^(.*?)=$/
|
119
|
+
self[$1.intern] = args[0]
|
120
|
+
elsif name.to_s =~ /^[\w_]+$/
|
121
|
+
self[name]
|
122
|
+
else
|
123
|
+
raise NoMethodError, name.to_s
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def ==(obj)
|
128
|
+
if obj.kind_of? self.class
|
129
|
+
self.attributes == obj.attributes
|
130
|
+
else
|
131
|
+
super
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
### class methods #########################################################
|
136
|
+
|
137
|
+
def self.find(id)
|
138
|
+
r = get(id)
|
139
|
+
self.new(r)
|
140
|
+
end
|
141
|
+
|
142
|
+
### type conversions ######################################################
|
143
|
+
|
144
|
+
# TODO: put these in a module?
|
145
|
+
def as_int(val)
|
146
|
+
val.to_i
|
147
|
+
end
|
148
|
+
|
149
|
+
def as_string(val)
|
150
|
+
val.to_s
|
151
|
+
end
|
152
|
+
|
153
|
+
def as_boolean(val)
|
154
|
+
val == "true"
|
155
|
+
end
|
156
|
+
|
157
|
+
def as_datetime(val)
|
158
|
+
if val.is_a?(String)
|
159
|
+
# for older versions of Confluence (pre 2.7?)
|
160
|
+
val =~ /\w{3} (\w{3}) (\d{2}) (\d{2}):(\d{2}):(\d{2}) (\w{3}) (\d{4})/
|
161
|
+
month = $1
|
162
|
+
day = $2
|
163
|
+
hour = $3
|
164
|
+
minute = $4
|
165
|
+
second = $5
|
166
|
+
tz = $6
|
167
|
+
year = $7
|
168
|
+
Time.local(year, month, day, hour, minute, second)
|
169
|
+
else
|
170
|
+
Time.local(val.year, val.month, val.day, val.hour, val.min, val.sec)
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
###########################################################################
|
175
|
+
|
176
|
+
protected
|
177
|
+
# Returns the raw XML-RPC anonymous object with the data corresponding to
|
178
|
+
# the given id. This depends on the get_method class attribute, which must
|
179
|
+
# be defined for this method to work.
|
180
|
+
def self.get(id)
|
181
|
+
raise NotImplementedError.new("Can't call #{self}.get(#{id}) because no get_method is defined for this class") unless self.get_method
|
182
|
+
raise ArgumentError.new("You must specify a #{self} id!") unless id
|
183
|
+
confluence.log.debug("get_method for #{self} is #{self.get_method}")
|
184
|
+
obj = confluence.send(self.send(:get_method), id.to_s)
|
185
|
+
return obj
|
186
|
+
end
|
187
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
require 'xmlrpc/client'
|
2
|
+
require 'logger'
|
3
|
+
|
4
|
+
# A useful helper for running Confluence XML-RPC from Ruby. Takes care of
|
5
|
+
# adding the token to each method call (so you can call server.getSpaces()
|
6
|
+
# instead of server.getSpaces(token)). Also takes care of re-logging in
|
7
|
+
# if your login times out.
|
8
|
+
#
|
9
|
+
# Usage:
|
10
|
+
#
|
11
|
+
# server = Confluence::RPC.new("http://confluence.atlassian.com")
|
12
|
+
# server.login("user", "password")
|
13
|
+
# puts server.getSpaces()
|
14
|
+
#
|
15
|
+
module Confluence
|
16
|
+
|
17
|
+
class RPC
|
18
|
+
attr_accessor :log
|
19
|
+
|
20
|
+
def initialize(server_url, proxy = "confluence1")
|
21
|
+
server_url += "/rpc/xmlrpc" unless server_url[-11..-1] == "/rpc/xmlrpc"
|
22
|
+
@server_url = server_url
|
23
|
+
server = XMLRPC::Client.new2(server_url)
|
24
|
+
@conf = server.proxy(proxy)
|
25
|
+
@token = "12345"
|
26
|
+
|
27
|
+
@log = Logger.new($stdout)
|
28
|
+
end
|
29
|
+
|
30
|
+
def login(username, password)
|
31
|
+
log.info "Logging in as '#{username}'."
|
32
|
+
@user = username
|
33
|
+
@pass = password
|
34
|
+
do_login()
|
35
|
+
end
|
36
|
+
|
37
|
+
def method_missing(method_name, *args)
|
38
|
+
log.info "Calling #{method_name}(#{args.inspect})."
|
39
|
+
begin
|
40
|
+
@conf.send(method_name, *([@token] + args))
|
41
|
+
rescue XMLRPC::FaultException => e
|
42
|
+
log.error "#{e}: #{e.message}"
|
43
|
+
if (e.faultString.include?("InvalidSessionException"))
|
44
|
+
do_login
|
45
|
+
retry
|
46
|
+
else
|
47
|
+
raise RemoteException, e.respond_to?(:message) ? e.message : e
|
48
|
+
end
|
49
|
+
rescue
|
50
|
+
log.error "#{$!}"
|
51
|
+
raise $!
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def do_login()
|
58
|
+
begin
|
59
|
+
@token = @conf.login(@user, @pass)
|
60
|
+
rescue XMLRPC::FaultException => e
|
61
|
+
log.error "#{e}: #{e.faultString}"
|
62
|
+
raise RemoteAuthenticationException, e
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
|
68
|
+
class RemoteException < Exception
|
69
|
+
def initialize(msg = nil, type = nil)
|
70
|
+
if msg.kind_of? XMLRPC::FaultException
|
71
|
+
msg.faultString =~ /^.*?:\s(.*?):\s(.*)/
|
72
|
+
msg = $2
|
73
|
+
type = $1
|
74
|
+
end
|
75
|
+
|
76
|
+
super(msg)
|
77
|
+
@type = type
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
class RemoteAuthenticationException < RemoteException
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,117 @@
|
|
1
|
+
# Allows for reading and writing a {metadata-list} macro in a page's content.
|
2
|
+
#
|
3
|
+
# For example, if your wiki content is:
|
4
|
+
#
|
5
|
+
# Hello, World!
|
6
|
+
# {metadata-list}
|
7
|
+
# || Foo | Bar |
|
8
|
+
# || Fruit | Orange |
|
9
|
+
# {metadata-list}
|
10
|
+
#
|
11
|
+
# You may do this (assuming that you have the above text stored in a variable
|
12
|
+
# called 'wiki_content'):
|
13
|
+
#
|
14
|
+
# m = Confluence::Metadata.new(wiki_content)
|
15
|
+
# puts m['Foo'] # outputs "Bar"
|
16
|
+
# puts m['Fruit'] # outputs "Orange"
|
17
|
+
#
|
18
|
+
# m['Fruit'] = "Banana"
|
19
|
+
# puts m['Fruit'] # outputs "Banana"
|
20
|
+
# m['Hello'] = "Goodbye"
|
21
|
+
#
|
22
|
+
# ... and your wiki_content now holds:
|
23
|
+
#
|
24
|
+
# Hello, World!
|
25
|
+
# {metadata-list}
|
26
|
+
# || Foo | Bar |
|
27
|
+
# || Fruit | Banana |
|
28
|
+
# || Hello | Goodbye |
|
29
|
+
# {metadata-list}
|
30
|
+
#
|
31
|
+
class Confluence::Metadata
|
32
|
+
include Enumerable
|
33
|
+
|
34
|
+
def initialize(page)
|
35
|
+
raise ArgumentError.new("Argument passed to Confluence::Metadata must be a Confluence::Page") unless page.kind_of? Confluence::Page
|
36
|
+
@page = page
|
37
|
+
end
|
38
|
+
|
39
|
+
def [](metadata_key)
|
40
|
+
extract_metadata_from_content[metadata_key]
|
41
|
+
end
|
42
|
+
|
43
|
+
def []=(metadata_key, value)
|
44
|
+
metadata = extract_metadata_from_content
|
45
|
+
metadata[metadata_key] = value
|
46
|
+
replace_metadata_in_content(metadata)
|
47
|
+
value
|
48
|
+
end
|
49
|
+
|
50
|
+
def each
|
51
|
+
metadata = extract_metadata_from_content
|
52
|
+
metadata.each{|k,v| yield k,v}
|
53
|
+
end
|
54
|
+
|
55
|
+
def include?(key)
|
56
|
+
not self.find{|k,v| k}.nil?
|
57
|
+
end
|
58
|
+
alias_method :has_key?, :include?
|
59
|
+
|
60
|
+
def empty?
|
61
|
+
extract_metadata_from_content.empty?
|
62
|
+
end
|
63
|
+
|
64
|
+
# Merges a hash (or some other Hash-like, Enumerable object) into
|
65
|
+
# this metadata. That is, key-value pairs from the given object will be added
|
66
|
+
# as metadata to this Confluence::Metadata's page, overriding any existing duplicate keys.
|
67
|
+
def merge!(data)
|
68
|
+
data.each do |k,v|
|
69
|
+
self[k] = v
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
def extract_metadata_from_content
|
75
|
+
return {} if @page.content.nil?
|
76
|
+
|
77
|
+
in_body = false
|
78
|
+
metadata = {}
|
79
|
+
@page.content.each do |line|
|
80
|
+
#TODO: handle other kinds of metadata macros
|
81
|
+
in_body = !in_body if line =~ /\{metadata-list\}/
|
82
|
+
|
83
|
+
if in_body
|
84
|
+
metadata[$1] = $2 if line =~ /\|\|\s*(.*?)\s*\|\s*(.*?)\s*\|/
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
return metadata
|
89
|
+
end
|
90
|
+
|
91
|
+
def replace_metadata_in_content(metadata)
|
92
|
+
#TODO: handle cases where there are multiple {metadata-list} macros in a page
|
93
|
+
#TODO: handle other kinds of metadata macros
|
94
|
+
|
95
|
+
macro_name = "{metadata-list}"
|
96
|
+
|
97
|
+
@page.content = "" if @page.content.nil?
|
98
|
+
|
99
|
+
@page.content += "\n#{macro_name}\n\n#{macro_name}" unless @page.content.include? macro_name
|
100
|
+
|
101
|
+
metadata_start = @page.content.index(macro_name) + macro_name.size
|
102
|
+
metadata_end = @page.content.index(macro_name, metadata_start) - 1
|
103
|
+
|
104
|
+
longest_key = 0
|
105
|
+
metadata.each do |key, val|
|
106
|
+
longest_key = key.size if key.size > longest_key
|
107
|
+
end
|
108
|
+
|
109
|
+
metadata_body = ""
|
110
|
+
metadata.each do |key, val|
|
111
|
+
padding = " " * (longest_key - key.size)
|
112
|
+
metadata_body += "|| #{key}#{padding} | #{val} |\n"
|
113
|
+
end
|
114
|
+
|
115
|
+
@page.content[metadata_start..metadata_end] = "\n"+metadata_body
|
116
|
+
end
|
117
|
+
end
|
@@ -0,0 +1,139 @@
|
|
1
|
+
require 'confluence/confluence_remote_data_object'
|
2
|
+
|
3
|
+
# Exposes a vaguely ActiveRecord-like interface for dealing with Confluence::Pages
|
4
|
+
# in Confluence.
|
5
|
+
class Confluence::Page < Confluence::RemoteDataObject
|
6
|
+
|
7
|
+
self.save_method = :storePage
|
8
|
+
self.get_method = :getPage
|
9
|
+
self.destroy_method = :removePage
|
10
|
+
|
11
|
+
self.attr_conversions = {
|
12
|
+
:id => :int,
|
13
|
+
:version => :int,
|
14
|
+
:parentId => :int,
|
15
|
+
:current => :boolean,
|
16
|
+
:homePage => :boolean,
|
17
|
+
:created => :datetime,
|
18
|
+
:modified => :datetime
|
19
|
+
}
|
20
|
+
|
21
|
+
self.readonly_attrs = [:current, :created, :modified, :contentStatus]
|
22
|
+
|
23
|
+
DEFAULT_SPACE = "encore"
|
24
|
+
|
25
|
+
def initialize(data_object = nil)
|
26
|
+
super
|
27
|
+
#self.creator = self.confluence_user unless self.include? :creator
|
28
|
+
end
|
29
|
+
|
30
|
+
def name=(new_name)
|
31
|
+
self.title = new_name
|
32
|
+
end
|
33
|
+
|
34
|
+
def name
|
35
|
+
self.title
|
36
|
+
end
|
37
|
+
|
38
|
+
def content=(new_content)
|
39
|
+
# make sure metadata doesn't get overwritten
|
40
|
+
old_metadata = self.metadata.entries if self.content
|
41
|
+
super
|
42
|
+
self.metadata.merge! old_metadata if old_metadata
|
43
|
+
end
|
44
|
+
|
45
|
+
def load_from_object(data_object)
|
46
|
+
super
|
47
|
+
self.modifier = self.confluence_username unless self.attributes.include? :modifier
|
48
|
+
end
|
49
|
+
|
50
|
+
def metadata
|
51
|
+
Confluence::Metadata.new(self)
|
52
|
+
end
|
53
|
+
|
54
|
+
def parent
|
55
|
+
self.class.find(self.parentId)
|
56
|
+
end
|
57
|
+
|
58
|
+
def to_s
|
59
|
+
self.title
|
60
|
+
end
|
61
|
+
|
62
|
+
def edit_group=(group)
|
63
|
+
encore.setPageEditGroup(self.title, group)
|
64
|
+
end
|
65
|
+
|
66
|
+
def edit_group
|
67
|
+
perm = get_permissions
|
68
|
+
return nil if perm.nil? or perm.empty?
|
69
|
+
perm.each do |p|
|
70
|
+
return p['lockedBy'] if p['lockType'] == 'Edit'
|
71
|
+
end
|
72
|
+
return nil
|
73
|
+
end
|
74
|
+
|
75
|
+
def view_group=(group)
|
76
|
+
encore.setPageViewGroup(self.title, group)
|
77
|
+
end
|
78
|
+
|
79
|
+
def view_group
|
80
|
+
perm = get_permissions
|
81
|
+
return nil if perm.nil? or perm.empty?
|
82
|
+
perm.each do |p|
|
83
|
+
return p['lockedBy'] if p['lockType'] == 'View'
|
84
|
+
end
|
85
|
+
return nil
|
86
|
+
end
|
87
|
+
|
88
|
+
def get_permissions
|
89
|
+
confluence.getPagePermissions(self.id.to_s)
|
90
|
+
end
|
91
|
+
|
92
|
+
### callbacks ###############################################################
|
93
|
+
|
94
|
+
def before_save
|
95
|
+
raise "Cannot save this page because it has no title and/or space." unless self.title and self.space
|
96
|
+
end
|
97
|
+
|
98
|
+
#############################################################################
|
99
|
+
|
100
|
+
### class methods ###########################################################
|
101
|
+
|
102
|
+
def self.find_by_name(name, space = DEFAULT_SPACE)
|
103
|
+
find_by_title(name, space)
|
104
|
+
end
|
105
|
+
|
106
|
+
def self.find_by_title(title, space = DEFAULT_SPACE)
|
107
|
+
begin
|
108
|
+
r = confluence.getPage(space, title)
|
109
|
+
rescue Confluence::RemoteException => e
|
110
|
+
# FIXME: dangerous to be checking based on error string like this...
|
111
|
+
if e.message =~ /does not exist/
|
112
|
+
return nil
|
113
|
+
else
|
114
|
+
raise e
|
115
|
+
end
|
116
|
+
end
|
117
|
+
self.new(r)
|
118
|
+
end
|
119
|
+
|
120
|
+
|
121
|
+
#############################################################################
|
122
|
+
|
123
|
+
protected
|
124
|
+
def self.metadata_accessor(accessor, metadata_key)
|
125
|
+
f = <<-END
|
126
|
+
def #{accessor.to_s}
|
127
|
+
self.metadata['#{metadata_key}']
|
128
|
+
end
|
129
|
+
def #{accessor.to_s}=(val)
|
130
|
+
self.metadata['#{metadata_key}'] = val.gsub("\n", " ")
|
131
|
+
end
|
132
|
+
END
|
133
|
+
module_eval f
|
134
|
+
end
|
135
|
+
|
136
|
+
def reload_newly_created!
|
137
|
+
self.load_from_object(confluence.getPage(self.space, self.title))
|
138
|
+
end
|
139
|
+
end
|
@@ -0,0 +1,108 @@
|
|
1
|
+
require 'confluence/confluence_remote_data_object'
|
2
|
+
|
3
|
+
class Confluence::User < Confluence::RemoteDataObject
|
4
|
+
|
5
|
+
self.save_method = :editUser
|
6
|
+
# TODO: implement :create_method ('addUser')
|
7
|
+
self.get_method = :getUser
|
8
|
+
self.destroy_method = :removeUser
|
9
|
+
|
10
|
+
self.readonly_attrs = ['username', 'name']
|
11
|
+
self.attr_conversions = {}
|
12
|
+
|
13
|
+
@groups
|
14
|
+
|
15
|
+
def id
|
16
|
+
self.username
|
17
|
+
end
|
18
|
+
|
19
|
+
def id=(new_id)
|
20
|
+
self.username = new_id
|
21
|
+
end
|
22
|
+
|
23
|
+
def username
|
24
|
+
self.name
|
25
|
+
end
|
26
|
+
|
27
|
+
def username=(new_username)
|
28
|
+
self.username=(new_username)
|
29
|
+
end
|
30
|
+
|
31
|
+
def groups
|
32
|
+
# groups are cached for the lifetime of the user object or until a group-modifying method is called
|
33
|
+
# FIXME: This is probably a bad idea, since the user's groups may be changed outside of the user object
|
34
|
+
# ... currently it's not a serious problem, since this is unlikely to happen within the object's
|
35
|
+
# short lifetime, but it may be problematic if start storing the user object in a cache or in the
|
36
|
+
# session.
|
37
|
+
@groups ||= confluence.getUserGroups(username)
|
38
|
+
end
|
39
|
+
|
40
|
+
def in_group?(group)
|
41
|
+
groups.include? group
|
42
|
+
end
|
43
|
+
|
44
|
+
def add_to_group(group)
|
45
|
+
@groups = nil # reset cached group list
|
46
|
+
confluence.addUserToGroup(username, group)
|
47
|
+
end
|
48
|
+
|
49
|
+
def remove_from_group(group)
|
50
|
+
@groups = nil # reset cached group list
|
51
|
+
confluence.removeUserFromGroup(username, group)
|
52
|
+
end
|
53
|
+
|
54
|
+
def has_permission?(permtype, page)
|
55
|
+
if permtype == :edit
|
56
|
+
group_or_name = page.edit_group
|
57
|
+
else
|
58
|
+
group_or_name = page.view_group
|
59
|
+
end
|
60
|
+
|
61
|
+
return true if group_or_name.nil?
|
62
|
+
return true if group_or_name == username
|
63
|
+
return in_group?(group_or_name)
|
64
|
+
end
|
65
|
+
|
66
|
+
def to_s
|
67
|
+
self.username
|
68
|
+
end
|
69
|
+
|
70
|
+
def to_wiki
|
71
|
+
"[~#{self.username}]"
|
72
|
+
end
|
73
|
+
|
74
|
+
### class methods #########################################################
|
75
|
+
|
76
|
+
def self.find_by_username(username)
|
77
|
+
find(username)
|
78
|
+
end
|
79
|
+
|
80
|
+
# DEPRECATED: this method is confusing since it could be taken as meaning "find by first/last name"
|
81
|
+
def self.find_by_name(username)
|
82
|
+
find_by_username(username)
|
83
|
+
end
|
84
|
+
|
85
|
+
def self.find_by_email(email)
|
86
|
+
usernames = confluence.getActiveUsers(true)
|
87
|
+
usernames.each do |username|
|
88
|
+
user = find_by_username(username)
|
89
|
+
return user if user.email == email
|
90
|
+
end
|
91
|
+
|
92
|
+
return nil
|
93
|
+
end
|
94
|
+
|
95
|
+
def self.find_all
|
96
|
+
# FIXME: this is really slow... we should probably just look in the confluence database instead
|
97
|
+
usernames = find_all_usernames
|
98
|
+
usernames.collect{|u| find_by_username(u)}
|
99
|
+
end
|
100
|
+
|
101
|
+
def self.find_all_usernames
|
102
|
+
confluence.getActiveUsers(true)
|
103
|
+
end
|
104
|
+
|
105
|
+
class NoSuchUser < Exception
|
106
|
+
end
|
107
|
+
|
108
|
+
end
|
data/lib/nippocf.rb
ADDED
data/lib/nippocf/cli.rb
ADDED
@@ -0,0 +1,139 @@
|
|
1
|
+
require 'nippocf'
|
2
|
+
require 'keychain'
|
3
|
+
require 'io/console'
|
4
|
+
require 'confluence/confluence_connector'
|
5
|
+
require 'redcarpet'
|
6
|
+
require 'tmpdir'
|
7
|
+
require 'nokogiri'
|
8
|
+
require 'optparse'
|
9
|
+
|
10
|
+
if ENV['SOCKS_PROXY']
|
11
|
+
require 'socksify'
|
12
|
+
server, port = ENV['SOCKS_PROXY'].split(':')
|
13
|
+
TCPSocket::socks_server = server
|
14
|
+
TCPSocket::socks_port = port
|
15
|
+
end
|
16
|
+
|
17
|
+
module Nippocf
|
18
|
+
class CLI
|
19
|
+
KEYCHAIN_SERVICE_NAME = 'Nippocf'
|
20
|
+
|
21
|
+
def initialize(argv)
|
22
|
+
@argv = argv
|
23
|
+
parse_options!
|
24
|
+
end
|
25
|
+
|
26
|
+
def run
|
27
|
+
rpc = connect_to_confl
|
28
|
+
|
29
|
+
blog_entries = rpc.getBlogEntries("~#{username}")
|
30
|
+
title = time.strftime('%Y/%m/%d')
|
31
|
+
entry_summary = blog_entries.find {|entry| entry['title'] == title }
|
32
|
+
|
33
|
+
markdown_text = ""
|
34
|
+
|
35
|
+
if entry_summary
|
36
|
+
entry = rpc.getBlogEntry(entry_summary['id'])
|
37
|
+
# entry contains markdown content
|
38
|
+
doc = Nokogiri::HTML(entry['content'])
|
39
|
+
elm = doc.css('div.markdown_source').first
|
40
|
+
markdown_text = elm.text if elm
|
41
|
+
else
|
42
|
+
entry = {
|
43
|
+
"content" => "",
|
44
|
+
"title" => title,
|
45
|
+
"space" => "~#{username}",
|
46
|
+
"author" => username,
|
47
|
+
"publishDate" => time,
|
48
|
+
"permissions" => "0"
|
49
|
+
}
|
50
|
+
end
|
51
|
+
|
52
|
+
Dir.mktmpdir do |dir|
|
53
|
+
filename = File.join(dir, 'editing.md')
|
54
|
+
open(filename, 'w') do |f|
|
55
|
+
f.write markdown_text
|
56
|
+
end
|
57
|
+
editor = ENV['EDITOR'] || 'vim'
|
58
|
+
system "#{editor} #{filename}"
|
59
|
+
markdown_text = File.read(filename)
|
60
|
+
end
|
61
|
+
|
62
|
+
markdown = Redcarpet::Markdown.new(Redcarpet::Render::HTML, :autolink => true, :space_after_headers => true)
|
63
|
+
entry['content'] = markdown.render(markdown_text)
|
64
|
+
entry['content'] << "<div class=\"markdown_source\" style=\"display:none\">#{markdown_text}</div>"
|
65
|
+
rpc.storeBlogEntry(entry)
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
def password_for_user(username)
|
70
|
+
keychain = Keychain.generic_passwords.where(
|
71
|
+
service: KEYCHAIN_SERVICE_NAME,
|
72
|
+
account: username).first
|
73
|
+
keychain && keychain.password
|
74
|
+
end
|
75
|
+
|
76
|
+
def set_password_for_user(username)
|
77
|
+
print "Password for #{username}: "
|
78
|
+
password = STDIN.noecho(&:gets).chomp
|
79
|
+
puts
|
80
|
+
Keychain.generic_passwords.create(
|
81
|
+
service: KEYCHAIN_SERVICE_NAME,
|
82
|
+
password: password,
|
83
|
+
account: username)
|
84
|
+
password
|
85
|
+
end
|
86
|
+
|
87
|
+
def connect_to_confl
|
88
|
+
password = password_for_user(username)
|
89
|
+
unless password
|
90
|
+
password = set_password_for_user(username)
|
91
|
+
end
|
92
|
+
connector = Confluence::Connector.new(
|
93
|
+
url: ENV['CONFL_URL'],
|
94
|
+
username: username,
|
95
|
+
password: password
|
96
|
+
)
|
97
|
+
connector.connect('confluence2').tap do |rpc|
|
98
|
+
unless @debug
|
99
|
+
rpc.log = Logger.new(StringIO.new)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def username
|
105
|
+
ENV['CONFL_USERNAME']
|
106
|
+
end
|
107
|
+
|
108
|
+
def time
|
109
|
+
now = Time.now
|
110
|
+
year = now.year
|
111
|
+
month = now.month
|
112
|
+
day = now.day
|
113
|
+
|
114
|
+
if @argv.size >= 1
|
115
|
+
date_str = @argv.first
|
116
|
+
elms = date_str.split('/')
|
117
|
+
case elms.size
|
118
|
+
when 1
|
119
|
+
day, = elms
|
120
|
+
when 2
|
121
|
+
month, day = elms
|
122
|
+
when 3
|
123
|
+
year, month, day = elms
|
124
|
+
else
|
125
|
+
raise "Invalid arg"
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
Time.local(year, month, day)
|
130
|
+
end
|
131
|
+
|
132
|
+
def parse_options!
|
133
|
+
opt = OptionParser.new
|
134
|
+
opt.on('--debug') { @debug = true }
|
135
|
+
opt.parse!(@argv)
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
data/nippocf.gemspec
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'nippocf/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "nippocf"
|
8
|
+
spec.version = Nippocf::VERSION
|
9
|
+
spec.authors = ["Ryota Arai"]
|
10
|
+
spec.email = ["ryota.arai@gree.net"]
|
11
|
+
spec.summary = "Write nippo (daily report in Japanese) on Atlassian Confluence in Markdown"
|
12
|
+
spec.homepage = "https://github.com/ryotarai/nippocf"
|
13
|
+
spec.license = "MIT"
|
14
|
+
|
15
|
+
spec.files = `git ls-files`.split($/)
|
16
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
17
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
18
|
+
spec.require_paths = ["lib"]
|
19
|
+
|
20
|
+
spec.add_dependency "redcarpet"
|
21
|
+
spec.add_dependency "nokogiri"
|
22
|
+
spec.add_dependency "ruby-keychain"
|
23
|
+
spec.add_dependency "socksify"
|
24
|
+
|
25
|
+
spec.add_development_dependency "bundler", "~> 1.5"
|
26
|
+
spec.add_development_dependency "rake"
|
27
|
+
end
|
metadata
ADDED
@@ -0,0 +1,145 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: nippocf
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Ryota Arai
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2014-02-01 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: redcarpet
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: nokogiri
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: ruby-keychain
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: socksify
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: bundler
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '1.5'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '1.5'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rake
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
description:
|
98
|
+
email:
|
99
|
+
- ryota.arai@gree.net
|
100
|
+
executables:
|
101
|
+
- nippocf
|
102
|
+
extensions: []
|
103
|
+
extra_rdoc_files: []
|
104
|
+
files:
|
105
|
+
- ".gitignore"
|
106
|
+
- Gemfile
|
107
|
+
- LICENSE.txt
|
108
|
+
- README.md
|
109
|
+
- Rakefile
|
110
|
+
- bin/nippocf
|
111
|
+
- lib/confluence/confluence_connector.rb
|
112
|
+
- lib/confluence/confluence_remote_data_object.rb
|
113
|
+
- lib/confluence/confluence_rpc.rb
|
114
|
+
- lib/confluence/metadata.rb
|
115
|
+
- lib/confluence/page.rb
|
116
|
+
- lib/confluence/user.rb
|
117
|
+
- lib/nippocf.rb
|
118
|
+
- lib/nippocf/cli.rb
|
119
|
+
- lib/nippocf/version.rb
|
120
|
+
- nippocf.gemspec
|
121
|
+
homepage: https://github.com/ryotarai/nippocf
|
122
|
+
licenses:
|
123
|
+
- MIT
|
124
|
+
metadata: {}
|
125
|
+
post_install_message:
|
126
|
+
rdoc_options: []
|
127
|
+
require_paths:
|
128
|
+
- lib
|
129
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
130
|
+
requirements:
|
131
|
+
- - ">="
|
132
|
+
- !ruby/object:Gem::Version
|
133
|
+
version: '0'
|
134
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - ">="
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '0'
|
139
|
+
requirements: []
|
140
|
+
rubyforge_project:
|
141
|
+
rubygems_version: 2.2.0
|
142
|
+
signing_key:
|
143
|
+
specification_version: 4
|
144
|
+
summary: Write nippo (daily report in Japanese) on Atlassian Confluence in Markdown
|
145
|
+
test_files: []
|