Skip to content

Instantly share code, notes, and snippets.

@ttscoff
Last active December 19, 2022 22:43
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save ttscoff/cd2a6c17964cccfb6665 to your computer and use it in GitHub Desktop.
Save ttscoff/cd2a6c17964cccfb6665 to your computer and use it in GitHub Desktop.
Store Markdown exports (as well as PDF and other formats) of all MindMeister maps in an account. See https://brettterpstra.com/2014/05/27/mirror-your-mindmeister-maps-to-nvalt/ for usage
#!/usr/bin/env ruby
require 'digest/md5'
require 'uri'
require 'net/http'
require 'yaml'
require 'rexml/document'
require 'fileutils'
require 'time'
require 'open-uri'
require 'cgi'
$host = "www.mindmeister.com"
$config_file = File.expand_path("~/.mindmeister2md")
def load_config
File.open($config_file) { |yf| YAML::load(yf) }
end
def dump_config (config)
File.open($config_file, 'w') { |yf| YAML::dump(config, yf) }
end
def rest_call(param)
url = URI::HTTP.build({:host => $host, :path => "/services/rest", :query => param})
Net::HTTP.get_response(url).body
end
def auth_valid? (param, secret)
valparam = param.merge({"method" => "mm.auth.checkToken"})
valbody = rest_call(api_sig(valparam, secret))
REXML::Document.new(valbody).elements["rsp"].attributes["stat"] == "ok"
end
def join_param (param)
param.sort.map { |key, val|
"#{key}=#{val}"
}.join("&")
end
def api_sig (param, secret)
URI.escape(join_param(param) +
"&api_sig=" +
Digest::MD5.hexdigest(secret + param.sort.join))
end
class Mindmap
attr_accessor :key, :title, :modified
def initialize
@key = nil
@title = nil
@modified = nil
end
def initialize(key, title, modified)
@key = key
@title = title
@modified = modified
end
end
class String
# Removes HTML tags from a string. Allows you to specify some tags to be kept.
def strip_html( allowed = [] )
re = if allowed.any?
Regexp.new(
%(<(?!(\\s|\\/)*(#{
allowed.map {|tag| Regexp.escape( tag )}.join( "|" )
})( |>|\\/|'|"|<|\\s*\\z))[^>]*(>+|\\s*\\z)),
Regexp::IGNORECASE | Regexp::MULTILINE
)
else
/<[^>]*(>+|\s*\z)/m
end
gsub(re,'')
end
end
class Idea
attr_accessor :key, :title, :link, :note, :image, :children, :start, :due
def initialize
@key = nil
@title = nil
@note = nil
@link = nil
@image = nil
@due = nil
@start = nil
@children = []
end
def initialize(key, title, link, note, image, task=nil)
@key = key
@title = title
@link = link
@note = note
@image = image
@children = []
unless task.nil?
@start = task.key?("start") ? task['start'] : nil
@due = task.key?("due") ? task['due'] : nil
end
end
end
def authenticate (api_key, secret)
# first we have to get the frob
froparam = {"api_key" => api_key, "method" => "mm.auth.getFrob"}
frobbody = rest_call(api_sig(froparam, secret))
frob = REXML::Document.new(frobbody).elements["rsp/frob"].text
# next the user has to authenticate this API key
authparam = {"api_key" => api_key, "perms" => "read", "frob" => frob}
authurl = URI::HTTPS.build({:host => $host, :path => "/services/auth", :query => api_sig(authparam, secret)})
STDERR.puts "Opening browser for authentication."
STDERR.puts authurl
`open "#{authurl.to_s}"`
STDERR.puts "Press ENTER after you have successfully authenticated."
STDIN.gets
# now we actually get the auth data
authparam = {"api_key" => api_key, "method" => "mm.auth.getToken", "frob" => frob}
authbody = rest_call(api_sig(authparam, secret))
auth = REXML::Document.new(authbody).elements["rsp/auth"]
{
"token" => auth.elements["token"].text,
"username" => auth.elements["user"].attributes["username"],
"userid" => auth.elements["user"].attributes["id"],
"fullname" => auth.elements["user"].attributes["fullname"],
"email" => auth.elements["user"].attributes["email"],
}
end
def get_date(target)
content = IO.read(target).force_encoding('utf-8')
updated = content.match(/^updated: (.*?)$/i)
return updated ? Time.parse(updated[1]) : false
end
def parse_exports(mapbody, formats)
doc, posts = REXML::Document.new(mapbody)
mm_url = {}
formats.each {|fmt|
doc.elements.each("rsp/export") do |p|
mm_url[fmt] = p.elements[fmt].text
end
}
return mm_url
end
def parse_map(mapbody)
d = Hash.new()
root = nil
doc, posts = REXML::Document.new(mapbody)
doc.elements.each("rsp/ideas/idea") do |p|
id = p.elements["id"].text
title = p.elements["title"].text.gsub(/\n+/,' ')
link = p.elements["link"].text unless p.elements["link"].text.nil?
note = p.elements["note"].text.strip_html(['a']) unless p.elements["note"].text.nil?
unless p.elements["image"].elements["url"].nil?
image = p.elements["image"].elements["url"].text unless p.elements["image"].elements["url"].text.nil?
if image && image =~ /^\//
image = "http://mindmeister.com" + image
end
end
task = nil
if p.elements["task"].has_elements?
node_task = p.elements["task"].elements
task = {}
task["start"] = node_task["from"].text.nil? ? nil : Time.parse(node_task["from"].text).strftime('%Y-%m-%d %H:%M')
task["due"] = node_task["due"].text.nil? ? nil : Time.parse(node_task["due"].text).strftime('%Y-%m-%d %H:%M')
end
i = Idea.new(id, title, link, note, image, task)
parent = p.elements["parent"].text
if parent.nil?
root = i
else
d[parent].children << i
end
d[id] = i
end
[d, root]
end
def print_level (node, level=0, io=STDOUT)
indent_char = $tab_indent ? "\t" : " "
subindent = level < $list_level ? "" : indent_char * ((level-$list_level+1)*$indent)
title = node.title.gsub(/\\n/," ")
title = title.gsub(/\\r/,' ').gsub(/\\'/,"'").gsub(/\s?style="[^"]*?"/,'').gsub(/([#*])/,'\\\\\1')
link = false
maplink = ""
unless node.link.nil?
if node.link =~ /^topic:(\d+)$/i
maplink = " -> [[#{title}.#{$1}]]"
link = false
else
link = node.link
end
end
title = link ? "[#{title}](#{node.link})" : title
title += maplink
unless $taskpaper
title = node.note.nil? ? title : "#{title}\n\n#{subindent}#{node.note.gsub(/\s?style="[^"]*?"/,'').gsub(/\\n/, "\n\n#{subindent}")}"
image = link ? "[![](#{node.image})](#{node.link})" : "![](#{node.image})"
title = node.image.nil? ? title : "#{title}\n\n#{subindent}#{image}\n\n"
else
title += node.start.nil? ? "" : " @start(#{node.start})"
title += node.due.nil? ? "" : " @due(#{node.due})"
noteindent = level < $list_level ? "" : "\t" * (level-$list_level+3)
title = node.note.nil? ? title : "#{title}\n#{noteindent}#{node.note.gsub(/\s?style="[^"]*?"/,'').gsub(/\\n/, "\n#{noteindent}")}"
end
if level < $list_level
if $taskpaper
io.print "\t" * level
io.puts "#{title}:"
else
io.print "#" * (level+1)
io.puts " #{title}\n\n"
end
else
if $taskpaper
io.print "\t" * ((level-$list_level) + 2)
io.puts "- #{title}"
else
io.print indent_char * ((level-$list_level)*$indent)
io.puts "#{$bullet_char} #{title}"
end
end
node.children.each { |n|
print_level(n, level + 1, io)
}
if level <= $list_level-1
io.puts
end
end
# if the configuration file doesn't exist, create it with default values
if !File.exists? $config_file
dump_config( {
"api_key" => nil,
"secret" => nil,
"list_level" => 2,
"indent" => 4,
"markdown_storage_folder" => "",
"export_storage_folder" => "",
"export_formats" => {'mindmanager' => 'mmap', 'pdf' => 'pdf'}
} )
STDERR.puts "You need to update the configuration file #{$config_file}."
STDERR.puts
STDERR.puts "You can apply for an API key here: https://www.mindmeister.com/account/api/"
STDERR.puts
exit 1
end
# load our configuration file
config = load_config
# assert we have api_key and secret
if !config.key? "api_key" or !config.key? "secret"
STDERR.puts "ERROR: api_key or secret not in configuration file!"
STDERR.puts "Adding keys to configuration; please update accordingly."
STDERR.puts
STDERR.puts "You can apply for an API key here: https://www.mindmeister.com/account/api/"
STDERR.puts
if !config.key? "api_key"
config["api_key"] = ""
end
if !config.key? "secret"
config["secret"] = ""
end
dump_config( config )
exit 1
end
if !config.key? "markdown_storage_folder" or !config.key? "export_storage_folder"
STDERR.puts "ERROR: Storage folders not configured"
STDERR.puts "Adding keys to configuration; please update accordingly."
STDERR.puts
STDERR.puts "The config file is located at #{$config_file}"
config["markdown_storage_folder"] = "" if !config.key? "markdown_storage_folder"
config["export_storage_folder"] = "" if !config.key? "export_storage_folder"
config["export_formats"] = {'mindmanager' => 'mmap', 'pdf' => 'pdf'} unless config.key? "export_formats"
dump_config( config )
exit 1
else
if config["markdown_storage_folder"] == "" || config["export_storage_folder"] == ""
STDERR.puts "ERROR: Storage folders are not configured"
STDERR.puts "Update #{$config_file} accordingly."
dump_config( config )
exit 1
else
markdown_storage_folder = File.expand_path(config["markdown_storage_folder"])
export_storage_folder = File.expand_path(config["export_storage_folder"])
[ markdown_storage_folder, export_storage_folder ].each {|folder|
unless File.exists?(folder)
FileUtils.mkdir_p folder
end
}
end
end
# assert that api_key and secret have values
if not config["api_key"] or not config["secret"]
STDERR.puts "api_key or secret are missing. Please update #{$config_file}."
exit 1
end
param = {"api_key" => config["api_key"]}
secret = config["secret"]
if !config.key? "auth"
config["auth"] = authenticate(config["api_key"], secret)
dump_config( config )
end
if !config.key? "indent"
config["indent"] = 4
config["tab_indent"] = false
dump_config( config )
end
$tab_indent = config["tab_indent"]
$indent = $tab_indent ? 1 : config["indent"]
$bullet_char = config.key?("bullet_char") ? config["bullet_char"] : "-"
$taskpaper = false
if !config.key? "list_level"
config["list_level"] = 2
dump_config( config )
end
$list_level = config["list_level"]
param.update({"auth_token" => config["auth"]["token"]})
if !auth_valid?(param, secret)
config["auth"] = authenticate(config["api_key"], secret)
dump_config(config)
param.update({"auth_token" => config["auth"]["token"]})
end
menu = []
mapbyid = {}
mapbyname = {}
listparam = param.merge({"method" => "mm.maps.getList"})
listbody = rest_call(api_sig(listparam, secret))
STDERR.puts("fetching maps...")
REXML::Document.new(listbody).elements.each("rsp/maps/map") { |e|
map = Mindmap.new(e.attributes["id"],
e.attributes["title"],
e.attributes["modified"])
menu << map
mapbyid[map.key] = map
mapbyname[map.title.downcase] = map
}
menu.each { |map|
should_update = false
title = "#{map.title.gsub(/[\n\r\/#'".!?]+/,' ').squeeze(' ').strip}.#{map.key}"
if map.title.strip =~ /\.taskpaper$/
ext = ".taskpaper"
$tab_indent = true
$taskpaper = true
else
ext = ".md"
end
target = File.join(markdown_storage_folder, "#{title}#{ext}")
if File.exists?( target )
last_updated = get_date(target)
should_update = last_updated ? last_updated < Time.parse(map.modified) : true
else
should_update = true
end
if should_update
mapparam = param.merge({"method" => "mm.maps.getMap", "map_id" => map.key})
content = rest_call(api_sig(mapparam, secret))
if content
d, root = parse_map(content)
$stderr.puts("Writing #{target}")
io = File.open(target, 'w')
io.puts "Title: #{map.title}"
io.puts "ID: #{map.key}"
io.puts "Updated: #{map.modified}\n\n"
io.puts("<https://www.mindmeister.com/#{map.key}>\n\n")
print_level(root, 0, io)
io.puts "\n@taskpaper" if $taskpaper
io.close
end
unless export_formats.empty?
mapparam = param.merge({"method" => "mm.maps.export", "map_id" => map.key})
content = rest_call(api_sig(mapparam, secret))
if content
mm_url = parse_exports(content,export_formats.keys)
unless mm_url.empty?
mm_url.each {|k,v|
STDERR.puts "Saving #{k} format"
export_target = File.join(export_storage_folder, "#{title}.#{export_formats[k]}")
url, query = v.split('?')
params = {}
CGI::parse(query).each {|q, val| params[q] = val[0] }
expparam = param.merge(params)
url = "#{url}?#{api_sig(expparam, secret)}"
File.open(export_target, "wb") do |saved_file|
open(url, "rb") do |read_file|
saved_file.write(read_file.read)
end
end
}
end
end
end
else
$stderr.puts("#{File.basename(target)} is up to date")
end
}
@amrinur
Copy link

amrinur commented Dec 8, 2022

how to use?

@ttscoff
Copy link
Author

ttscoff commented Dec 19, 2022

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment