mingle-link-fixer 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 3fbe569bf3bcac0083625a2c51097cd37d96f3fc
4
+ data.tar.gz: 9176dc5401d134f6000c1d99d3bb86339564ec04
5
+ SHA512:
6
+ metadata.gz: d127e2b6d7de385d1ba32eede46b059361d240faae99d9eeee88f02b352ad1fd04f5099d11400283b026b746145a35b5d6fe318e222f8a40c12922dc123529b1
7
+ data.tar.gz: ccf029ae581e2a83c4f19d99ca5f3fc898d60d93250eddceeb101cfa78c71efddb69cce0201d5f275753ff3d935a4b2491a94a1d732c9cae5dced75367e30195
data/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ Copyright 2014 ThoughtWorks Inc.
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
@@ -0,0 +1,33 @@
1
+ Mingle Attachment Link Fixer
2
+ ============================
3
+
4
+ If you're like many [Mingle](http://getmingle.io) users and you wanted to add link to an attachment, then you probably just right-clicked the attachment at the bottom of the card and pasted that into the link box.
5
+
6
+ Which is all fine and dandy, until you move Mingle servers, move from on-premise to Mingle SaaS, or recover your project from a data backup. In this case, the attachment IDs will be different. All the links will be broken!!
7
+
8
+ This script is designed to fix this problem. If you have your export file with valid attachments inside, then this tool can go through your cards and magically restore your links. It will use the archaic (but correct and guaranteed not to break) syntax:
9
+
10
+ [[Your link text|#123/my-file.ext]]
11
+
12
+ What's that, you say? That's wiki markup? Well, yes, it is. We chose to stick with the format our users were used to rather than invent a way to put that into HTML links.
13
+
14
+ Usage
15
+ -----
16
+
17
+ You'll need Ruby 2.0+
18
+
19
+ > gem install mingle-link-fixer
20
+
21
+ > mingle-link-fixer http://mingle.your.org/projects/iphone_app /path/containing/extracted/good/export
22
+
23
+ You can set the environment variables VERBOSE and DRY_RUN to true if you want more information or want to see what will happen when it runs, respectively.
24
+
25
+
26
+ Contributors / Bugs / &c.
27
+ -------------------------
28
+
29
+ Please open a GitHub issue and we'll get in touch.
30
+
31
+ Snap CI build status:
32
+
33
+ [![Build Status](https://snap-ci.com/ThoughtWorksStudios/mingle-link-fixer/branch/master/build_image)](https://snap-ci.com/ThoughtWorksStudios/mingle-link-fixer/branch/master)
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env ruby
2
+ require 'highline/import'
3
+ require 'uri'
4
+ require_relative '../lib/mingle_link_fixer'
5
+
6
+ def my_simple_name
7
+ $0.split('/')[-1]
8
+ end
9
+
10
+ def die_with_usage
11
+ puts(<<-USAGE.strip)
12
+ usage: #{my_simple_name} PROJECT_URL OLD_PROJECT_EXPORT
13
+ example: #{my_simple_name} http://mingle.your.org/projects/plan_a /path/to/extracted/proj/export
14
+ USAGE
15
+ exit -1
16
+ end
17
+
18
+ die_with_usage if ARGV[0].nil? || ARGV[0].empty?
19
+ project_uri = URI.parse(ARGV[0])
20
+
21
+ old_project_export_folder = if ARGV[1].nil? || ARGV[1].empty?
22
+ die_with_usage
23
+ elsif Dir[File.join(ARGV[1], "*.yml")].count > 0
24
+ ARGV[1]
25
+ else
26
+ puts "Could find attachments yml files in #{ARGV[1]}"
27
+ exit -1
28
+ end
29
+
30
+ username = ENV['MINGLE_USERNAME'] || ask("enter username for #{project_uri.hostname}")
31
+ password = ENV['MINGLE_PASSWORD'] || ask("enter password for #{username}: ") { |prompt| prompt.echo = false }
32
+
33
+ fixer = Mingle::LinkFixer.new(project_url: project_uri.to_s,
34
+ historical_attachments_folder: old_project_export_folder,
35
+ username: username,
36
+ password: password)
37
+
38
+ fixer.fix(dry_run: ENV['DRY_RUN'] == 'true', starting_card: ENV['STARTING_CARD'], limit: ENV['LIMIT'])
@@ -0,0 +1,44 @@
1
+ require 'nokogiri'
2
+ require 'cgi'
3
+
4
+ require_relative 'logging'
5
+
6
+ module Mingle
7
+
8
+ class API
9
+ include Logging
10
+
11
+ def initialize(http_client)
12
+ @http_client = http_client
13
+ end
14
+
15
+ def get_card(number)
16
+ @http_client.get("/cards/#{number}.xml").body
17
+ end
18
+
19
+ def get_attachments_on(card_number)
20
+ @http_client.get("/cards/#{card_number}/attachments.xml").body
21
+ end
22
+
23
+ def save_card(card)
24
+ @http_client.put("/cards/#{card.number}.xml", body: card.to_xml, 'Content-Type' => 'text/xml')
25
+ end
26
+
27
+ def execute_mql(mql)
28
+ response = @http_client.get('/cards/execute_mql.xml', mql: mql)
29
+ logger.debug(response.body)
30
+ Nokogiri::XML.parse(response.body).search('result').inject([]) do |results, result|
31
+ result_hash = result.children.inject({}) do |hash, child|
32
+ unless Nokogiri::XML::Text === child
33
+ hash[child.name] = child.text
34
+ end
35
+ hash
36
+ end
37
+ results << result_hash
38
+ end
39
+ end
40
+
41
+
42
+ end
43
+
44
+ end
@@ -0,0 +1,27 @@
1
+ require 'nokogiri'
2
+
3
+ module Mingle
4
+ class Attachment
5
+
6
+ attr_reader :filename
7
+
8
+ def initialize(filename)
9
+ @filename = filename
10
+ end
11
+
12
+ def self.api=(api)
13
+ @@api = api
14
+ end
15
+
16
+ def self.find_all_by_card_number(number)
17
+ [].tap do |attachments|
18
+ xml = @@api.get_attachments_on(number)
19
+ document = Nokogiri::XML.parse(xml)
20
+ document.xpath('./attachments/attachment').each do |element|
21
+ filename = element.xpath('./file_name').text
22
+ attachments << Attachment.new(filename)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,43 @@
1
+ require_relative 'logging'
2
+ require_relative 'card'
3
+ require_relative 'historical_attachments'
4
+ require_relative 'attachment'
5
+
6
+ module Mingle
7
+ class AttachmentLink
8
+ include Logging
9
+
10
+ def initialize(element)
11
+ @element = element
12
+ end
13
+
14
+ def rewrite(card, historical_attachments)
15
+ href = @element['href']
16
+ old_attachment_id = if href =~ /projects.*\/attachments\/(\d+)/
17
+ $1
18
+ elsif href =~ /attachments\/[0-9a-f]+\/(\d+)/
19
+ $1
20
+ end
21
+ old_attachment = historical_attachments.find_by_id(old_attachment_id)
22
+ raise "Could not find historical attachment based on #{old_attachment_id}" unless old_attachment
23
+ new_attachment = card.attachments.find { |attachment| attachment.filename == old_attachment.filename }
24
+ if new_attachment
25
+ "[[#{@element.text}|##{card.number}/#{new_attachment.filename}]]"
26
+ else
27
+ raise "Could not find matching attachmen for #{old_attachment.filename} in #{card.attachments.map(&:filename)}"
28
+ end
29
+
30
+ end
31
+
32
+ def rewrite!(card, historical_attachments)
33
+ new_content = rewrite(card, historical_attachments)
34
+ logger.debug "replacing #{@element.to_html} with #{new_content}"
35
+ @element.replace(Nokogiri::XML::Text.new(new_content, @element.document))
36
+ nil
37
+ rescue => e
38
+ raise "Could not replace #{@element} because: #{e.message}"
39
+ end
40
+
41
+
42
+ end
43
+ end
@@ -0,0 +1,27 @@
1
+ require 'nokogiri'
2
+ require_relative 'attachment_link'
3
+
4
+ module Mingle
5
+ class AttachmentLinkFinder
6
+
7
+ attr_reader :attachment_links, :card_description_document
8
+
9
+ def initialize(description)
10
+ @attachment_links = find_links(description)
11
+ end
12
+
13
+ private
14
+
15
+ def find_links(html)
16
+ @card_description_document = Nokogiri::HTML.parse(html)
17
+ @card_description_document.search('a').inject([]) do |memo, anchor|
18
+ href = anchor['href']
19
+ if href =~ /attachments/
20
+ memo << AttachmentLink.new(anchor)
21
+ end
22
+ memo
23
+ end
24
+ end
25
+
26
+ end
27
+ end
@@ -0,0 +1,82 @@
1
+ require 'nokogiri'
2
+ require_relative 'api'
3
+
4
+ module Mingle
5
+ class Card
6
+
7
+ def self.all(options={})
8
+ options = {starting_from: '1'}.merge(options)
9
+ Logging.debug "Mingle API => #{@@api.inspect}"
10
+ starting_card_number = options[:starting_with].to_i
11
+ max_card_number = unless options[:limit]
12
+ mql = "SELECT MAX(number)"
13
+ results = @@api.execute_mql(mql)
14
+ Logging.debug "#{mql}: #{results.inspect}"
15
+ results[0]['max_number'].to_i
16
+ else
17
+ starting_card_number + options[:limit].to_i
18
+ end
19
+ Enumerator.new do |yielder|
20
+ (starting_card_number..max_card_number).to_a.each do |number|
21
+ begin
22
+ yielder.yield Card.find_by_number(number)
23
+ rescue => e
24
+ Logging.error "Unable to fetch card ##{number} due to #{e.message}"
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ def self.find_by_number(number)
31
+ Logging.debug("fetching card ##{number}")
32
+ card_xml = @@api.get_card(number)
33
+ Card.new(card_xml)
34
+ end
35
+
36
+ def self.api=(api)
37
+ @@api = api
38
+ end
39
+
40
+ attr_accessor :description, :number, :name
41
+
42
+ def initialize(xml)
43
+ document = Nokogiri::XML.parse(xml)
44
+ @description = document.xpath('./card/description').text
45
+ @number = document.xpath('./card/number').text.to_i
46
+ @name = document.xpath('./card/name').text
47
+ end
48
+
49
+ def description=(desc)
50
+ @description = remove_enclosing_html_doc_and_body(desc)
51
+ end
52
+
53
+ def attachments
54
+ @attachments ||= Attachment.find_all_by_card_number(number)
55
+ end
56
+
57
+ def save!
58
+ @@api.save_card(self)
59
+ end
60
+
61
+ def to_xml
62
+ Nokogiri::XML::Builder.new { |xml|
63
+ xml.card {
64
+ xml.description(@description)
65
+ }
66
+ }.to_xml
67
+ end
68
+
69
+ private
70
+
71
+ DOCTYPE_REGEX = /^<!DOCTYPE html PUBLIC "-\/\/W3C\/\/DTD HTML 4.0 Transitional\/\/EN" "http:\/\/www.w3.org\/TR\/REC-html40\/loose.dtd\">/
72
+
73
+ def remove_enclosing_html_doc_and_body(html)
74
+ html.gsub(DOCTYPE_REGEX, '').strip
75
+ .gsub(/<html>/, '').strip
76
+ .gsub(/<body>/, '').strip
77
+ .gsub(/<\/html>$/, '').strip
78
+ .gsub(/<\/body>$/, '').strip
79
+ end
80
+
81
+ end
82
+ end
@@ -0,0 +1,25 @@
1
+ require 'yaml'
2
+ require_relative 'attachment'
3
+
4
+ module Mingle
5
+ class HistoricalAttachments
6
+
7
+ def initialize(path)
8
+ @path = path
9
+ @attachments = []
10
+ Dir[File.join(@path, 'attachments_*.yml')].each do |file|
11
+ @attachments += YAML.load_file(file)
12
+ end
13
+ end
14
+
15
+ def size
16
+ @attachments.size
17
+ end
18
+
19
+ def find_by_id(id)
20
+ attachment = @attachments.find { |attachment| attachment['id'].to_s == id.to_s }
21
+ Attachment.new(attachment['file']) if attachment
22
+ end
23
+
24
+ end
25
+ end
@@ -0,0 +1,58 @@
1
+ require 'net/http'
2
+ require 'cgi'
3
+
4
+ module Mingle
5
+ class HttpClient
6
+ include Logging
7
+
8
+ attr_accessor :base_url
9
+
10
+ def initialize(username, password)
11
+ @username = username
12
+ @password = password
13
+ end
14
+
15
+ def get(path, params={})
16
+ url = File.join(base_url, path + to_url(params))
17
+ logger.debug "HTTP GET #{url}"
18
+ process(Net::HTTP::Get, url)
19
+ end
20
+
21
+ def put(path, params={})
22
+ url = url = File.join(base_url, path)
23
+ body = params.delete :body
24
+ process(Net::HTTP::Put, url, params, body)
25
+ end
26
+
27
+ private
28
+
29
+ def to_url(params)
30
+ params.inject([]) do |memo, pair|
31
+ memo.tap do |memo|
32
+ key, value = *pair
33
+ memo << "#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}"
34
+ end
35
+ end.join("&").tap { |params| return "?#{params}" unless params.empty? }
36
+ end
37
+
38
+ def process(request_class, url, headers={}, body=nil)
39
+ uri = URI.parse(url)
40
+ http = Net::HTTP.new(uri.host, uri.port)
41
+ http.use_ssl = true if uri.scheme == 'https'
42
+
43
+ request = request_class.new(uri.request_uri)
44
+ request.basic_auth @username, @password
45
+ request.body = body if body.tap { logger.debug("HTTP body: #{body}") if body }
46
+ headers.each do |key, value|
47
+ request[key] = value
48
+ end
49
+
50
+ response = http.request(request)
51
+
52
+ response.tap do
53
+ raise "Unexpected response of #{response.code} to #{url}" unless response.code.to_i >= 200 && response.code.to_i < 400
54
+ end
55
+ end
56
+
57
+ end
58
+ end
@@ -0,0 +1,26 @@
1
+ require 'logger'
2
+
3
+ module Mingle
4
+ module Logging
5
+
6
+ VERBOSE = (ENV['VERBOSE'] == 'true')
7
+ LOGGER = Logger.new(STDOUT).tap { |l| l.level = VERBOSE ? Logger::DEBUG : Logger::INFO }
8
+
9
+ def self.info(msg)
10
+ LOGGER.info(msg)
11
+ end
12
+
13
+ def self.debug(msg)
14
+ LOGGER.debug(msg)
15
+ end
16
+
17
+ def self.error(msg)
18
+ LOGGER.error(msg)
19
+ end
20
+
21
+ def logger
22
+ LOGGER
23
+ end
24
+
25
+ end
26
+ end
@@ -0,0 +1,42 @@
1
+ module Mingle
2
+ class Stats
3
+
4
+ attr_accessor :total_cards_checked, :cards_without_attachments, :cards_without_links,
5
+ :cards_fixed, :problematic_cards
6
+
7
+ def initialize
8
+ @total_cards_checked = 0
9
+ @cards_without_attachments = 0
10
+ @cards_without_links = 0
11
+ @problematic_cards = {}
12
+ @cards_fixed = 0
13
+ @start = Time.now
14
+ end
15
+
16
+ def duration_in_seconds
17
+ Time.now - @start
18
+ end
19
+
20
+ SPACER = "=" * 80
21
+
22
+ def to_pretty_string
23
+ %{
24
+ #{SPACER}
25
+ SUMMARY
26
+
27
+ Completed in #{duration_in_seconds} sec
28
+
29
+ Total Cards Checked: #{total_cards_checked}
30
+ Cards Without Attachments: #{cards_without_attachments}
31
+ Cards Without Fixable Links: #{cards_without_links}
32
+
33
+ Problematic Cards: #{problematic_cards.size}
34
+ #{'(specific errors can be seen if you set VERBOSE environment variable)' if problematic_cards.any?}
35
+
36
+ Fixed Cards: #{cards_fixed}
37
+ #{SPACER}
38
+ }
39
+ end
40
+
41
+ end
42
+ end
@@ -0,0 +1,70 @@
1
+ require 'logger'
2
+
3
+ require_relative 'mingle/logging'
4
+ require_relative 'mingle/stats'
5
+ require_relative 'mingle/http_client'
6
+ require_relative 'mingle/api'
7
+ require_relative 'mingle/card'
8
+ require_relative 'mingle/historical_attachments'
9
+ require_relative 'mingle/attachment_link_finder'
10
+
11
+ module Mingle
12
+ class LinkFixer
13
+ include Logging
14
+
15
+ def initialize(options)
16
+ @http_client = HttpClient.new(options[:username], options[:password])
17
+ @http_client.base_url = options[:project_url].gsub('/projects/', '/api/v2/projects/')
18
+ @api = API.new(@http_client)
19
+ @historical_attachments = HistoricalAttachments.new(options[:historical_attachments_folder])
20
+ Card.api = Attachment.api = @api
21
+ end
22
+
23
+ def fix(options={})
24
+ stats = Stats.new
25
+ options = {dry_run: false, starting_card: '1'}.merge(options)
26
+ logger.info "running #{self.class} (dry_run: #{options[:dry_run]}, verbose_logging: #{VERBOSE})"
27
+ Card.all(starting_with: options[:starting_card], limit: options[:limit]).each do |card|
28
+ stats.total_cards_checked += 1
29
+ begin
30
+ if card.attachments.empty?
31
+ logger.debug "skipping Card ##{card.number} because it has no attachments."
32
+ stats.cards_without_attachments += 1
33
+ next
34
+ end
35
+ finder = AttachmentLinkFinder.new(card.description)
36
+
37
+ if finder.attachment_links.empty?
38
+ logger.info "no attachment links present"
39
+ stats.cards_without_links += 1
40
+ next
41
+ end
42
+
43
+ logger.info "fixing #{finder.attachment_links.size} links"
44
+
45
+ finder.attachment_links.each do |attachment_link|
46
+ attachment_link.rewrite!(card, @historical_attachments)
47
+ end
48
+
49
+ html_document = finder.card_description_document
50
+ card.description = html_document.to_html
51
+
52
+ if options[:dry_run]
53
+ logger.info("(skipped saving card because dry_run is enabled)")
54
+ else
55
+ card.save!
56
+ stats.cards_fixed += 1
57
+ end
58
+
59
+ rescue => e
60
+ logger.error "Unable to fix Card ##{card.number} because of error: #{e.message}"
61
+ stats.cards_with_problematic_links[card.number] = e.message
62
+ logger.debug e.backtrace.join("\n")
63
+ end
64
+ end
65
+
66
+ logger.info stats.to_pretty_string
67
+ end
68
+
69
+ end
70
+ end
metadata ADDED
@@ -0,0 +1,58 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mingle-link-fixer
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Bill DePhillips
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-10-01 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: fixes inline attachment links when a project is relocated
14
+ email:
15
+ - bill.dephillips@gmail.com
16
+ executables:
17
+ - mingle-link-fixer
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - lib/mingle_link_fixer.rb
22
+ - lib/mingle/logging.rb
23
+ - lib/mingle/http_client.rb
24
+ - lib/mingle/api.rb
25
+ - lib/mingle/attachment.rb
26
+ - lib/mingle/historical_attachments.rb
27
+ - lib/mingle/attachment_link.rb
28
+ - lib/mingle/attachment_link_finder.rb
29
+ - lib/mingle/card.rb
30
+ - lib/mingle/stats.rb
31
+ - LICENSE
32
+ - README.md
33
+ - bin/mingle-link-fixer
34
+ homepage: https://github.com/thoughtworksstudios/mingle-link-fixer
35
+ licenses:
36
+ - Apache
37
+ metadata: {}
38
+ post_install_message:
39
+ rdoc_options: []
40
+ require_paths:
41
+ - lib
42
+ required_ruby_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - '>='
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ required_rubygems_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - '>='
50
+ - !ruby/object:Gem::Version
51
+ version: '0'
52
+ requirements: []
53
+ rubyforge_project:
54
+ rubygems_version: 2.0.14
55
+ signing_key:
56
+ specification_version: 4
57
+ summary: fixes attachment links if you relocate a project
58
+ test_files: []