Last active
October 29, 2019 08:23
-
-
Save DivineDominion/b11982ae1ef555720000b362fe17d7af to your computer and use it in GitHub Desktop.
Extract note links from a source note
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 | |
# Avoid all the script configuration and use this convenience script instead! | |
# | |
# 1) Put it into the same folder, | |
# 2) run the script: `ruby _execute.rb PATH/TO/THE_NOTE.txt` | |
################## | |
# Configure here # | |
# Depth of Zettels | |
DEPTH=10 | |
# Config ends # | |
################## | |
if ARGV.length < 1 | |
STDERR.puts "Path to note required as argument to this script" | |
exit 1 | |
end | |
ZETTEL_PATH = ARGV[0] | |
FOLDER = File.expand_path(File.dirname(__FILE__)) | |
SCRIPT = File.join(FOLDER, "extract_associated_zettel.rb") | |
require 'rbconfig' | |
RUBY = File.join(RbConfig::CONFIG['bindir'], RbConfig::CONFIG['ruby_install_name']) | |
require 'date' | |
# e.g. "20191015172917", ID to the second | |
OUTPUT_PREFIX = DateTime.now.iso8601[0...19].gsub(/[-T:]/, '') | |
DOT_FILE = File.join(FOLDER, OUTPUT_PREFIX + ".dot") | |
PNG_FILE = File.join(FOLDER, OUTPUT_PREFIX + ".png") | |
unless true == system(RUBY, SCRIPT, "--depth", DEPTH.to_s, "--output", DOT_FILE, ZETTEL_PATH) | |
STDERR.puts "Dot generation failed" | |
exit 1 | |
end | |
unless true == system("neato", "-Tpng", "-o#{PNG_FILE}", DOT_FILE) | |
STDERR.puts "PNG creation from dotfile #{DOT_FILE} failed" | |
exit 1 | |
end | |
STDOUT.puts "Finished: #{PNG_FILE}" |
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 | |
# Usage: extract_associated_zettel.rb [options] FILE | |
# -d, --depth [DEPTH] How many levels to traverse before aborting. Default is 10. | |
# -o, --output [OUTPUT] OUTPUT file to store the graphviz dot output instead of stdout | |
# -v, --verbose Print debug info to stdout. Only works with -o/--output to re-route result | |
# -h, --help Prints this help | |
require "optparse" | |
module Launch | |
REQUIRED_OPTIONS = [] | |
def self.options | |
options = {} | |
parser = OptionParser.new do |o| | |
o.banner = "Usage: #{__FILE__} [options] FILE" | |
options[:depth] = 10 | |
o.on("-d", "--depth [DEPTH]", "How many levels to traverse before aborting. Default is #{options[:depth]}.") do |val| | |
options[:depth] = unless val.nil? then val.to_i else options[:depth] end | |
end | |
options[:output] = nil | |
o.on("-o", "--output [OUTPUT]", "OUTPUT file to store the graphviz dot output instead of stdout") do |output| | |
raise "OUTPUT value missing" if output.nil? | |
path = File.expand_path(output) | |
raise "Directory for #{path} does not exist." unless File.exists?(File.dirname(path)) | |
options[:output] = path | |
end | |
options[:verbose] = false | |
o.on_tail("-v", "--verbose", "Print debug info to stdout. Only works with -o/--output to re-route result") do | |
options[:verbose] = true | |
end | |
o.on_tail("-h", "--help", "Prints this help") do | |
puts o | |
exit | |
end | |
end | |
parser.parse! | |
options[:verbose] = false if options[:verbose] && options[:output].nil? | |
# Show standard error when required keys are missing. OptionParser should do this on its own, but apparently doesn't. | |
missing_required = REQUIRED_OPTIONS - options.keys | |
raise OptionParser::MissingArgument.new(missing_required) unless missing_required.empty? | |
return options | |
end | |
end | |
class Archive | |
def initialize(directory_path) | |
@directory_path = directory_path | |
end | |
def text_file(filename) | |
TextFile.new(File.join(@directory_path, filename)) | |
end | |
end | |
class TextFile | |
attr_reader :path | |
def initialize(path) | |
@path = path | |
end | |
def ==(other) | |
self.path == other.path | |
end | |
def filename | |
File.basename(@path) | |
end | |
def directory_path | |
File.dirname(@path) | |
end | |
def archive | |
Archive.new(directory_path) | |
end | |
def contents | |
@contents ||= read_contents | |
end | |
def read_contents | |
File.read(@path) | |
end | |
def zettel | |
@zettel ||= Zettel.new(self.contents) | |
end | |
end | |
class Zettel | |
def initialize(contents) | |
@contents = contents | |
end | |
def link_targets | |
@link_targets ||= @contents.all_matches(/\[\[(.+?)\]\]/).flatten | |
end | |
end | |
class String | |
def all_matches(regexp) | |
rest = self | |
result = [] | |
while m = rest.match(regexp) | |
result << m.captures | |
rest = m.post_match | |
end | |
return result | |
end | |
end | |
class KnownTextFiles | |
def initialize(elements = []) | |
# in case you don't provide an array, wrap it in one; if you do, flatten the result | |
@all = [elements].flatten | |
end | |
def all | |
@all | |
end | |
def include?(text_file) | |
all.include?(text_file) | |
end | |
def <<(text_file) | |
return if all.include?(text_file) | |
all << text_file | |
end | |
end | |
class Dir | |
def self.all_files(path) | |
# Ships with Ruby 2.6 but not the built-in macOS Ruby | |
if Dir.respond_to?(:children) | |
return Dir.children(path) | |
end | |
Dir.chdir(path) do | |
Dir.glob("*") | |
end | |
end | |
end | |
class ArchiveCrawler | |
attr_reader :all_files | |
def initialize(directory_path) | |
@directory_path = directory_path | |
@all_files ||= Dir.all_files(@directory_path) | |
end | |
def best_match(link) | |
link = link.downcase | |
result = @all_files.select { |elem| elem.downcase.start_with?(link) }.sort.first | |
result = result || @all_files.select { |elem| elem.downcase.include?(link) }.sort.first | |
return result | |
end | |
def crawl(text_file, callback, options) | |
@crawled_filenames = KnownTextFiles.new(text_file) | |
_crawl(text_file, callback, options[:verbose], level=1, options[:depth]) | |
@crawled_filenames = nil | |
end | |
private | |
def _crawl(source, callback, verbose, level, max_level) | |
return if level > max_level | |
_i = indent(level) | |
source.zettel.link_targets.each do |link| | |
unless (filename = best_match(link)).nil? | |
target = source.archive.text_file(filename) | |
callback.call(source, target) | |
unless @crawled_filenames.include?(target) | |
puts "#{_i}#{filename}" if verbose | |
@crawled_filenames << target | |
_crawl(target, callback, verbose, level + 1, max_level) | |
else | |
puts "#{_i}#{filename} (duplicate contents skipped)" if verbose | |
end | |
else | |
puts "#{_i}not found: `#{link}`" if verbose | |
end | |
end | |
end | |
def indent(level) | |
indentation = " "*level | |
end | |
end | |
module Graphviz | |
Node = Struct.new(:label, :name) do | |
MAX_LINE_LENGTH = 15 | |
def definition | |
lines = [[]] | |
label.split(" ").each do |word| | |
lines.last << word | |
if lines.last.join(" ").length >= MAX_LINE_LENGTH | |
lines << [] | |
end | |
end | |
label = lines.map { |l| l.join(" ") }.join("\\n").rstrip | |
%Q{#{name}[label="#{label}"];} | |
end | |
end | |
Connection = Struct.new(:from, :to) do | |
def output | |
"#{from} -> #{to};" | |
end | |
end | |
class Diagram | |
def add_connection(from, to) | |
@connections ||= [] | |
@connections << Connection.new(node(from), node(to)) | |
end | |
def node(label) | |
@nodes ||= {} | |
@nodes[label] ||= _node_name(@nodes.count) | |
end | |
def _node_name(i) | |
i | |
end | |
#def _node_names | |
# @_node_names ||= ("a".."zzzz").to_a | |
#end | |
def options | |
{ rankdir: "LR", | |
overlap: "false", | |
splines: "false" } | |
end | |
def output | |
lines = [] | |
lines << "digraph {" | |
options.each do |k, v| | |
lines << " #{k.to_s}=#{v.to_s};" | |
end | |
lines << " node [shape=box];" | |
# Node label definitions | |
@nodes.each do |label, name| | |
node = Node.new(label, name) | |
lines << " " + node.definition | |
end | |
# Node connections | |
@connections.each do |conn| | |
lines << " " + conn.output | |
end | |
lines << "}" | |
end | |
end | |
end | |
options = Launch.options | |
file = ARGV.first # Remaining unparsed arguments | |
if file.nil? | |
STDERR.puts "Missing input FILE" | |
exit 1 | |
end | |
file = File.expand_path(file) | |
unless File.exists?(file) | |
STDERR.puts "Input file does not exist: #{file}" | |
exit 1 | |
end | |
def write_out(options) | |
if options[:output] | |
File.open(options[:output], "w") do |file| | |
yield file | |
end | |
else | |
yield STDOUT | |
end | |
end | |
origin = TextFile.new(file) | |
verbose = options[:verbose] | |
search_dir = origin.directory_path | |
puts "Searching in `#{search_dir}`, starting with:\n#{origin.filename}" if verbose | |
if origin.zettel.link_targets.empty? | |
write_out(options) do |out| | |
out.puts "digraph {}" | |
end | |
exit | |
end | |
diagram = Graphviz::Diagram.new | |
crawler = ArchiveCrawler.new(search_dir) | |
crawler.crawl( | |
origin, | |
->(source_file, target_file) { diagram.add_connection(source_file.filename, target_file.filename) }, | |
options) | |
write_out(options) do |outstr| | |
outstr.puts diagram.output | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
If you have graphviz installed, you can process the output of this program via
dot
orneato
or whatever layout engine you like: