├── README.md └── quiver_import.rb /README.md: -------------------------------------------------------------------------------- 1 | ## A humble Ruby script to convert markdown files to [Quiver](http://happenapps.com/#quiver) notes. 2 | 3 | ### Setup 4 | 1. Get `quiver_import.rb` 5 | 2. All `.md` files in a directory will be packaged in one Quiver notebook 6 | - Relative paths to images will be respected. 7 | - Code blocks may have no language, or [one of these abbreviations](https://github.com/HappenApps/Quiver/wiki/Syntax-Highlighting-Supported-Languages). Other languages won't break the import but Quiver won't understand and the note will look truncated/broken until you manually give each of these blocks a valid language. 8 | 3. Run like this: `ruby quiver_import.rb .qvnotebook <'Notebook Name'>` 9 | 4. From within Quiver, select `File > Import Notebook` and select your .qvnotebook directory 10 | 11 | ### Tags and Titles 12 | The script will attempt to parse the title and tags from the first and second lines 13 | of the file, respectively. 14 | 15 | It expects something like this on the first two lines: 16 | 17 | ```md 18 | # An h1 for the title! 19 | [bracketed, tags, comma | pipe | or whitespace separated] 20 | ``` 21 | 22 | If either parse succeeds, the first two lines of the note are omitted from the body of the final quiver note. 23 | 24 | There is definitely room for this to be more sophisticated. 25 | 26 | ### Questions/Problems? 27 | [Reach out!](https://github.com/prurph/markdown-to-quiver/issues) 28 | -------------------------------------------------------------------------------- /quiver_import.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'securerandom' 3 | require 'uri' 4 | 5 | # USAGE: ruby quiver_import.rb /folder/containing/md/files/ /export/location.qvnotebook 'Notebook Name' 6 | # - Takes all the .md files in the first argument and puts them in a directory 7 | # in the second argument (add .qvnotebook to the name for easy importing) 8 | # - Each .md file will be a separate note 9 | # - Images are allowed provided the paths specified are relative to the .md file 10 | # that references them. TODO: support URL images by just passing the markdown 11 | # through. 12 | 13 | class CodeBlock 14 | attr_accessor :language, :data 15 | 16 | def initialize(data, language = 'text') 17 | @language = language 18 | @data = data 19 | end 20 | 21 | def to_json(*a) 22 | { type: 'code', language: language, data: data }.to_json(*a) 23 | end 24 | end 25 | 26 | class MarkdownBlock 27 | attr_accessor :data 28 | 29 | def initialize(data) 30 | @data = data 31 | end 32 | 33 | def to_json(*a) 34 | { type: 'markdown', data: data }.to_json(*a) 35 | end 36 | end 37 | 38 | module QuiverImport 39 | class Import 40 | @@LANG_MAP = { 41 | 'bash' => 'sh' 42 | } 43 | 44 | attr_accessor :blocks, :codeblock, :mdblock, :inside_code 45 | attr_reader :input_file, :output_dir, :title, :tags, :has_title_and_tags 46 | 47 | def initialize(input_file, output_dir) 48 | @blocks = [] 49 | @codeblock = CodeBlock.new('') 50 | @mdblock = MarkdownBlock.new('') 51 | @input_file = input_file 52 | @output_dir = output_dir 53 | @inside_code = false 54 | @title, @tags, @has_title_and_tags = Import.parse_title_and_tags(input_file) 55 | end 56 | 57 | def self.parse_title_and_tags(input_file) 58 | header = File.foreach(input_file).first(2) 59 | title_match = header[0].match(/#\s*(.+)/) 60 | tag_match = header[1].match(/(?<=\[)(.+)(?=\])/) 61 | [ 62 | title_match ? title_match[1] : input_file, 63 | tag_match ? tag_match[1].split(/[\s,\|]/).reject(&:empty?) : [], 64 | !title_match.nil? || !tag_match.nil? 65 | ] 66 | end 67 | 68 | def process_code_boundary(boundary_match) 69 | add_and_reset_blocks 70 | @inside_code = !boundary_match[:language].nil? 71 | if inside_code 72 | @codeblock.language = 73 | @@LANG_MAP[boundary_match[:language]] || boundary_match[:language] 74 | end 75 | end 76 | 77 | def process_code_line(line) 78 | @codeblock.data = @codeblock.data + line 79 | end 80 | 81 | def process_md_line(line) 82 | if line =~ /!\[(?.*)\]\((?\S+)( "(?.*)")?\)/ 83 | line = process_img_line(line) 84 | end 85 | @mdblock.data = @mdblock.data + line 86 | end 87 | 88 | # TODO: cache images and don't recopy them if they already exist in the destination 89 | def process_img_line(line) 90 | img_match = line.scan(/!\[(?<alt_text>.*)\]\((?<src>\S+)( "(?<title>.*)")?\)/) 91 | 92 | # Pass http/https directly through (don't bother downloading them) 93 | # For local files, copy them to /resources and link with quiver-image-url 94 | img_match 95 | .reject {|alt_text, src, title| URI::regexp(['http', 'https']) =~ src} 96 | .each do |alt_text, src, title| 97 | src_file = File.expand_path(src, File.dirname(@input_file)) 98 | raise "Couldn't find image at: #{src_file}" unless File.exist?(src_file) 99 | dest = img_to_resources(src_file) 100 | line.sub!(src, dest) 101 | end 102 | line 103 | end 104 | 105 | def img_to_resources(src) 106 | system 'mkdir', '-p', "#{@output_dir}/resources" 107 | src_extension = File.extname(src) 108 | dest_filename = "#{SecureRandom.uuid.upcase}#{src_extension}" 109 | system 'cp', "#{src}", "#{@output_dir}/resources/#{dest_filename}" 110 | "quiver-image-url/#{dest_filename}" 111 | end 112 | 113 | def run(title_and_tags = false) 114 | iter = @has_title_and_tags ? File.foreach(@input_file).drop(2) : File.foreach(@input_file) 115 | iter.each do |line| 116 | if (code_boundary = line.match(/```(?<language>\S+)?/)) 117 | process_code_boundary(code_boundary) 118 | elsif @inside_code 119 | process_code_line(line) 120 | else 121 | process_md_line(line) 122 | end 123 | end 124 | 125 | add_and_reset_blocks 126 | clean_blocks 127 | 128 | system 'mkdir', '-p', "#{@output_dir}" 129 | IO.write("#{@output_dir}/content.json", content_json) 130 | IO.write("#{@output_dir}/meta.json", meta_json) 131 | end 132 | end 133 | 134 | 135 | private 136 | def clean_blocks 137 | @blocks.each_with_index do |block, idx| 138 | block.data = block.data.strip 139 | blocks.slice!(idx) if block.data.empty? 140 | end 141 | end 142 | 143 | def add_and_reset_blocks 144 | [@codeblock, @mdblock].each do |block| 145 | @blocks.push block unless block.data.empty? 146 | end 147 | 148 | @codeblock = CodeBlock.new('') 149 | @mdblock = MarkdownBlock.new('') 150 | end 151 | 152 | def content_json(*a) 153 | { 154 | title: (@title || @input_file), 155 | cells: @blocks 156 | }.to_json(*a) 157 | end 158 | 159 | def meta_json 160 | now = Time.now.utc.to_i 161 | { 162 | created_at: now, 163 | tags: @tags, 164 | title: @title, 165 | updated_at: now, 166 | uuid: SecureRandom.uuid.upcase 167 | }.to_json 168 | end 169 | end 170 | 171 | include QuiverImport 172 | input_dir, output_dir, notebook_name = ARGV 173 | 174 | Dir.glob("#{input_dir}/*.md").each do |note| 175 | note_output_dir = "#{output_dir}/#{SecureRandom.uuid.upcase}.qvnote" 176 | Import.new(note, note_output_dir).run 177 | end 178 | 179 | notebook_json = { 180 | name: "#{notebook_name}", 181 | uuid: SecureRandom.uuid.upcase 182 | }.to_json 183 | 184 | IO.write("#{output_dir}/meta.json", notebook_json) 185 | --------------------------------------------------------------------------------