Last active
December 19, 2022 22:43
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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.link})" : "" | |
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 | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
https://brettterpstra.com/2014/05/27/mirror-your-mindmeister-maps-to-nvalt/