trellodon 0.1.0

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
+ SHA256:
3
+ metadata.gz: bf1b8715b6cbad2c1bd3ab4fce72198a442380cf4f2d7bb39c55928ef72b032f
4
+ data.tar.gz: 2caa2a3a24fe463cc1cb730a3aad68928323ec0fea04fea8e3a0f10ebf051ea7
5
+ SHA512:
6
+ metadata.gz: b50b3279f7a9e787b2f2e718109902a9cda88729f7d7b330aa5db9db944f913af33d6f26fab7b1f6c4d2866a1241d62d99c4dbe989ea468499bcaaa4908a153f
7
+ data.tar.gz: 52d86ebd0b43ad0b064facbfb66332623422b2c688eff732188b7ea2ce8baba2842a331a941e1aafb410f114c31aef3ec80f7588b7f6a07808070019f9eb12ba
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2022-04-07
4
+
5
+ - Initial release
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 Evil Martians
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # trellodon
2
+
3
+ Dump Trello boards to your file system
data/bin/console ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "trellodon"
6
+ require "irb"
7
+
8
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
data/bin/trellodon ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "trellodon/cli"
5
+
6
+ Trellodon::CLI.new.run
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "trello"
4
+
5
+ require "trellodon/entities"
6
+
7
+ module Trellodon
8
+ class APIClient
9
+ def initialize(api_key:, api_token:, logger: Config.logger)
10
+ @logger = logger
11
+ @client = Trello::Client.new(
12
+ developer_public_key: api_key,
13
+ member_token: api_token
14
+ )
15
+ end
16
+
17
+ def fetch_board(board_id)
18
+ retrying do
19
+ board = @client.find(:boards, board_id)
20
+
21
+ Board.new(
22
+ id: board.id,
23
+ short_id: board.short_url.chars.last(8).join,
24
+ name: board.name,
25
+ lists: lists_from(board),
26
+ card_ids: @client.find_many(Trello::Card, "/boards/#{board_id}/cards", fields: "id").map(&:id),
27
+ last_activity_date: board.last_activity_date
28
+ )
29
+ end
30
+ end
31
+
32
+ def fetch_card(card_id)
33
+ retrying do
34
+ card = @client.find(:cards, card_id)
35
+
36
+ Card.new(
37
+ id: card.id,
38
+ short_id: card.short_url.chars.last(8).join,
39
+ name: card.name,
40
+ desc: card.desc,
41
+ labels: card.labels.map(&:name),
42
+ list_id: card.list_id,
43
+ comments: comments_from(card),
44
+ attachments: attachments_from(card),
45
+ last_activity_date: card.last_activity_date
46
+ )
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :logger
53
+
54
+ def retrying(&block)
55
+ attempt = 0
56
+ begin
57
+ block.call
58
+ rescue Trello::Error => err
59
+ raise unless err.status_code == 429
60
+
61
+ attempt += 1
62
+
63
+ cooldown = 2**attempt + rand(2**attempt) - 2**(attempt - 1)
64
+
65
+ logger.warn "API limit exceeded, cool down for #{cooldown}s"
66
+
67
+ sleep cooldown
68
+
69
+ retry
70
+ end
71
+ end
72
+
73
+ def comments_from(card)
74
+ card.comments.map do |comment|
75
+ Comment.new(
76
+ text: comment.data["text"],
77
+ date: comment.date,
78
+ creator_id: comment.creator_id
79
+ )
80
+ end
81
+ end
82
+
83
+ def attachments_from(card)
84
+ card.attachments.map do |attach|
85
+ Attachment.new(
86
+ file_name: attach.file_name,
87
+ mime_type: attach.mime_type,
88
+ bytes: attach.bytes,
89
+ url: attach.url
90
+ )
91
+ end
92
+ end
93
+
94
+ def lists_from(board)
95
+ board.lists.map do |list|
96
+ List.new(
97
+ id: list.id,
98
+ name: list.name,
99
+ short_id: board.short_url.chars.last(8).join
100
+ )
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "trellodon/api_client"
4
+ require "trellodon/config"
5
+
6
+ module Trellodon
7
+ class APIExecutor
8
+ BOARD_ID_REGEX = /\/b\/([^\/]+)\//
9
+ private_constant :BOARD_ID_REGEX
10
+
11
+ def initialize(api_key:, api_token:, scheduler:, formatter:, logger: Config.logger)
12
+ @api_key = api_key
13
+ @api_token = api_token
14
+ @formatter = formatter
15
+ @logger = logger
16
+ @scheduler = scheduler
17
+ check_credentials!
18
+
19
+ @api_client = APIClient.new(api_key: @api_key, api_token: @api_token)
20
+ end
21
+
22
+ def download(board_pointer)
23
+ extract_board_id(board_pointer)
24
+
25
+ startup_time = Time.now
26
+ logger.debug "Fetching board 🚀️️"
27
+ board = scheduler.post do
28
+ api_client.fetch_board(board_id).tap { formatter.board_added(_1) }
29
+ end.value
30
+
31
+ logger.debug "Fetching cards in board with comments and attachments 🐢"
32
+ board.card_ids.map do |card_id|
33
+ scheduler.post do
34
+ api_client.fetch_card(card_id).tap { formatter.card_added(_1) }
35
+ end
36
+ end.map(&:value)
37
+
38
+ formatter.finish
39
+ logger.debug "All Trello API requests finished in #{(Time.now - startup_time).to_i} seconds ⌛"
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :api_client, :board_id, :logger, :scheduler, :formatter
45
+
46
+ def check_credentials!
47
+ return if @api_key.to_s.size.positive? && @api_token.to_s.size.positive?
48
+
49
+ raise ArgumentError, "Missing credentials. Please fill out both api_key, api_token first."
50
+ end
51
+
52
+ def extract_board_id(board_pointer)
53
+ @board_id = if URI::DEFAULT_PARSER.make_regexp.match?(board_pointer)
54
+ parse_board_url(board_pointer)
55
+ else
56
+ board_pointer
57
+ end
58
+ end
59
+
60
+ def parse_board_url(board_url)
61
+ rel_path = URI.parse(board_url).request_uri
62
+ match_data = rel_path.match(BOARD_ID_REGEX)
63
+ raise ArgumentError, "Wrong trello board url" if match_data.nil? || match_data.size != 2
64
+
65
+ match_data[1]
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "trellodon"
4
+
5
+ require "tty-prompt"
6
+
7
+ module Trellodon
8
+ class CLI
9
+ class Prompt
10
+ def initialize
11
+ @prompt = TTY::Prompt.new
12
+ end
13
+
14
+ def ask_api_key
15
+ @prompt.mask(
16
+ "Provide your Developer API Key (see https://trello.com/app-key):"
17
+ ) { |q| q.required true }
18
+ end
19
+
20
+ def ask_api_token
21
+ @prompt.mask(
22
+ "Provide your API token:"
23
+ ) { |q| q.required true }
24
+ end
25
+
26
+ def ask_board
27
+ @prompt.ask("Which board would you like to dump? (URL or ID)") do |q|
28
+ q.required true
29
+ end
30
+ end
31
+
32
+ def ask_folder
33
+ @prompt.ask("Destination folder?", default: "./")
34
+ end
35
+ end
36
+
37
+ class Config < Trellodon::Config
38
+ attr_config :board, :out
39
+
40
+ ignore_options :api_key, :api_token
41
+
42
+ describe_options(
43
+ board: "Board URL or ID",
44
+ out: "Destination folder path"
45
+ )
46
+
47
+ extend_options do |opts, _|
48
+ opts.banner = "Usage: trellodon dump\n"\
49
+ "Options:\n"
50
+
51
+ opts.on_tail("-v", "--version", "Print current version") do
52
+ puts Trellodon::VERSION
53
+ exit
54
+ end
55
+
56
+ opts.on_tail("-h", "--help", "Print help") do
57
+ puts opts
58
+ exit
59
+ end
60
+ end
61
+
62
+ private def prompt
63
+ @prompt ||= Prompt.new
64
+ end
65
+
66
+ def api_key
67
+ super || (self.api_key = prompt.ask_api_key)
68
+ end
69
+
70
+ def api_token
71
+ super || (self.api_token = prompt.ask_api_token)
72
+ end
73
+
74
+ def board
75
+ super || (self.board = prompt.ask_board)
76
+ end
77
+
78
+ def out
79
+ super || (self.out = prompt.ask_folder)
80
+ end
81
+ end
82
+
83
+ def initialize
84
+ @config = Config.new
85
+ end
86
+
87
+ def run
88
+ check_command!
89
+
90
+ executor = Trellodon::APIExecutor.new(
91
+ api_key: config.api_key,
92
+ api_token: config.api_token,
93
+ formatter: Trellodon::Formatters::Markdown.new(output_dir: config.out),
94
+ scheduler: Schedulers::Threaded.new
95
+ )
96
+ executor.download(config.board)
97
+ end
98
+
99
+ private
100
+
101
+ attr_reader :config
102
+
103
+ def check_command!
104
+ command, = config.option_parser.permute!(ARGV)
105
+ unless command
106
+ puts config.option_parser.help
107
+ exit
108
+ end
109
+
110
+ return if command == "dump"
111
+
112
+ raise "Unknown command: #{command}. Available commands: dump"
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "anyway_config"
4
+ require "logger"
5
+
6
+ module Trellodon
7
+ class Config < Anyway::Config
8
+ config_name :trellodon
9
+
10
+ attr_config :api_token, :api_key
11
+
12
+ def self.logger
13
+ Logger.new($stdout)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trellodon
4
+ Attachment = Struct.new("Attachment", :file_name, :mime_type, :bytes, :url, keyword_init: true)
5
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trellodon
4
+ Board = Struct.new("Board", :id, :short_id, :name, :card_ids, :lists, :last_activity_date, keyword_init: true) do
5
+ def get_list(list_id)
6
+ return nil if lists.nil?
7
+ lists.find { |list| list.id == list_id }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trellodon
4
+ Card = Struct.new("Card", :id, :short_id, :name, :desc, :list_id, :labels, :comments, :attachments, :last_activity_date, keyword_init: true)
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trellodon
4
+ Comment = Struct.new("Comment", :text, :date, :creator_id, keyword_init: true)
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trellodon
4
+ List = Struct.new("List", :id, :short_id, :name, keyword_init: true)
5
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "trellodon/entities/board"
4
+ require "trellodon/entities/card"
5
+ require "trellodon/entities/list"
6
+ require "trellodon/entities/comment"
7
+ require "trellodon/entities/attachment"
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trellodon
4
+ module Formatters
5
+ class Base
6
+ attr_reader :board, :logger
7
+
8
+ def initialize(logger: Config.logger)
9
+ @logger = logger
10
+ end
11
+
12
+ def board_added(board)
13
+ @board = board
14
+ end
15
+
16
+ def card_added(card)
17
+ raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'"
18
+ end
19
+
20
+ def finish
21
+ raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open-uri"
4
+ require "fileutils"
5
+ require "trellodon/formatters/base"
6
+
7
+ module Trellodon
8
+ module Formatters
9
+ class Markdown < Base
10
+ attr_reader :output_dir
11
+
12
+ def initialize(output_dir:, **opts)
13
+ super(**opts)
14
+ @output_dir = output_dir
15
+ end
16
+
17
+ def card_added(card)
18
+ card_mdfile = File.join(card_path(card), "index.md")
19
+ raise "File #{card_mdfile} already exists" if File.exist?(card_mdfile)
20
+
21
+ File.write(card_mdfile, format_card(card))
22
+ download_attachments(card)
23
+ end
24
+
25
+ def finish
26
+ logger.info "Markdown dump is here: #{@output_dir}"
27
+ end
28
+
29
+ private
30
+
31
+ def card_path(card)
32
+ raise "Board is undefined" if @board.nil?
33
+ raise "List id is undefined" if card.list_id.nil? || card.list_id.empty?
34
+ list = @board.get_list(card.list_id)
35
+ raise "List #{card.list_id} is not found" if list.nil?
36
+
37
+ card_dir = File.join(@output_dir,
38
+ "#{board.name} [#{board.short_id}]",
39
+ "#{list.name} [#{list.short_id}]",
40
+ "#{card.name} [#{card.short_id}]")
41
+ FileUtils.mkdir_p(card_dir) unless File.directory?(card_dir)
42
+ card_dir
43
+ end
44
+
45
+ def card_attachments_path(card)
46
+ attachments_dir = File.join(card_path(card), "attachments")
47
+ FileUtils.mkdir_p(attachments_dir) unless File.directory?(attachments_dir)
48
+ attachments_dir
49
+ end
50
+
51
+ def download_attachments(card)
52
+ return # FIXME: 401 Unauthorized ???
53
+ return if card.attachments.nil? || card.attachments.empty? # rubocop:disable Lint/UnreachableCode
54
+ attachments_path = card_attachments_path(card)
55
+ card.attachments.each do |att|
56
+ IO.copy_stream(URI.parse(att.url).open, File.join(attachments_path, att.file_name))
57
+ end
58
+ end
59
+
60
+ def format_card(card)
61
+ create_card_header(card) +
62
+ create_card_title(card) +
63
+ create_card_description(card) +
64
+ create_card_comments(card)
65
+ end
66
+
67
+ def create_card_header(card)
68
+ <<~EOS
69
+ ---
70
+ title: #{card.name}
71
+ last_updated_at: #{card.last_activity_date}
72
+ labels: #{create_card_labels(card)}
73
+ ---
74
+ EOS
75
+ end
76
+
77
+ def create_card_title(card)
78
+ <<~EOS
79
+
80
+ # #{card.name}
81
+ EOS
82
+ end
83
+
84
+ def create_card_description(card)
85
+ <<~EOS
86
+
87
+ ## Description
88
+ #{card.desc}
89
+ EOS
90
+ end
91
+
92
+ def create_card_labels(card)
93
+ return "" if card.labels.nil? || card.labels.empty?
94
+ card.labels.map { |label| "\n - " + label }.reduce(:+)
95
+ end
96
+
97
+ def create_card_comments(card)
98
+ return "" if card.comments.nil? || card.comments.empty?
99
+ <<~EOS
100
+
101
+ ## Comments
102
+ #{card.comments.map { |comment| create_card_comment(comment) }.reduce(:+)}
103
+ EOS
104
+ end
105
+
106
+ def create_card_comment(comment)
107
+ <<~EOS
108
+
109
+ **#{comment.creator_id} @ #{comment.date}**
110
+ #{comment.text}
111
+ EOS
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "trellodon/formatters/base"
4
+ require "trellodon/formatters/markdown"
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trellodon
4
+ module Schedulers
5
+ class Base
6
+ def post
7
+ raise NotImplementedError
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "trellodon/schedulers/base"
4
+
5
+ module Trellodon
6
+ module Schedulers
7
+ class Inline < Base
8
+ Result = Struct.new(:value)
9
+
10
+ def post(&block)
11
+ Result.new(block.call)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "trellodon/schedulers/base"
4
+
5
+ module Trellodon
6
+ module Schedulers
7
+ class Threaded < Base
8
+ def post(&block)
9
+ Thread.new(&block)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "trellodon/schedulers/base"
4
+ require "trellodon/schedulers/inline"
5
+ require "trellodon/schedulers/threaded"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trellodon
4
+ VERSION = "0.1.0"
5
+ end
data/lib/trellodon.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "trellodon/version"
4
+ require "trellodon/config"
5
+ require "trellodon/api_executor"
6
+
7
+ require "trellodon/entities"
8
+ require "trellodon/schedulers"
9
+ require "trellodon/formatters"
data/sig/trellodon.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Trellodon
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,118 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: trellodon
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - fargelus
8
+ - rinasergeeva
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2022-04-08 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: anyway_config
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '0'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '0'
28
+ - !ruby/object:Gem::Dependency
29
+ name: ruby-trello
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: '0'
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '0'
42
+ - !ruby/object:Gem::Dependency
43
+ name: tty-prompt
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ type: :runtime
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ description: "\n The main purpose of Trellodon is to make it possible to backup
57
+ Trello boards to file system in a human-readable form (e.g., Markdown files).\n
58
+ \ "
59
+ email:
60
+ - ddraudred@gmail.com
61
+ - catherine.sergeeva@gmail.com
62
+ executables:
63
+ - trellodon
64
+ extensions: []
65
+ extra_rdoc_files: []
66
+ files:
67
+ - CHANGELOG.md
68
+ - LICENSE
69
+ - README.md
70
+ - bin/console
71
+ - bin/setup
72
+ - bin/trellodon
73
+ - lib/trellodon.rb
74
+ - lib/trellodon/api_client.rb
75
+ - lib/trellodon/api_executor.rb
76
+ - lib/trellodon/cli.rb
77
+ - lib/trellodon/config.rb
78
+ - lib/trellodon/entities.rb
79
+ - lib/trellodon/entities/attachment.rb
80
+ - lib/trellodon/entities/board.rb
81
+ - lib/trellodon/entities/card.rb
82
+ - lib/trellodon/entities/comment.rb
83
+ - lib/trellodon/entities/list.rb
84
+ - lib/trellodon/formatters.rb
85
+ - lib/trellodon/formatters/base.rb
86
+ - lib/trellodon/formatters/markdown.rb
87
+ - lib/trellodon/schedulers.rb
88
+ - lib/trellodon/schedulers/base.rb
89
+ - lib/trellodon/schedulers/inline.rb
90
+ - lib/trellodon/schedulers/threaded.rb
91
+ - lib/trellodon/version.rb
92
+ - sig/trellodon.rbs
93
+ homepage: https://github.com/evilmartians/trellodon
94
+ licenses: []
95
+ metadata:
96
+ homepage_uri: https://github.com/evilmartians/trellodon
97
+ source_code_uri: https://github.com/evilmartians/trellodon
98
+ changelog_uri: https://github.com/evilmartians/trellodon/CHANGELOG.md
99
+ post_install_message:
100
+ rdoc_options: []
101
+ require_paths:
102
+ - lib
103
+ required_ruby_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: 2.6.0
108
+ required_rubygems_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ requirements: []
114
+ rubygems_version: 3.3.7
115
+ signing_key:
116
+ specification_version: 4
117
+ summary: 'Trellodon: dump Trello boards to your file system'
118
+ test_files: []