dle 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/dle.rb ADDED
@@ -0,0 +1,24 @@
1
+ require "pathname"
2
+ require "yaml"
3
+ require "find"
4
+ require "optparse"
5
+ require "securerandom"
6
+
7
+ require "pry"
8
+ require "active_support/core_ext/object/blank"
9
+ require "active_support/core_ext/object/try"
10
+
11
+ require "banana/logger"
12
+ require "dle/version"
13
+ require "dle/helpers"
14
+ require "dle/dl_file"
15
+ require "dle/filesystem/destructive"
16
+ require "dle/filesystem/softnode"
17
+ require "dle/filesystem/node"
18
+ require "dle/filesystem"
19
+ require "dle/application/dispatch"
20
+ require "dle/application"
21
+
22
+ module Dle
23
+ ROOT = Pathname.new(File.expand_path("../..", __FILE__))
24
+ end
@@ -0,0 +1,96 @@
1
+ module Dle
2
+ # Logger Singleton
3
+ MAIN_THREAD = ::Thread.main
4
+ def MAIN_THREAD.app_logger
5
+ MAIN_THREAD[:app_logger] ||= Banana::Logger.new
6
+ end
7
+
8
+ class Application
9
+ attr_reader :opts
10
+ include Dispatch
11
+
12
+ # =========
13
+ # = Setup =
14
+ # =========
15
+ def self.dispatch *a
16
+ new(*a) do |app|
17
+ app.parse_params
18
+ app.log "core ready"
19
+ app.dispatch
20
+ end
21
+ end
22
+
23
+ def initialize env, argv
24
+ @env, @argv = env, argv
25
+ @editor = which_editor
26
+ @opts = {
27
+ dispatch: :index,
28
+ dotfiles: false,
29
+ check_for_updates: true,
30
+ review: true,
31
+ simulate: false,
32
+ }
33
+ yield(self)
34
+ end
35
+
36
+ def parse_params
37
+ @optparse = OptionParser.new do |opts|
38
+ opts.banner = "Usage: dle [options] base_directory"
39
+
40
+ opts.on("-d", "--dotfiles", "Include dotfiles (unix invisible)") { @opts[:dotfiles] = true }
41
+ opts.on("-r", "--skip-review", "Skip review changes before applying") { @opts[:review] = false }
42
+ opts.on("-s", "--simulate", "Don't apply changes, show commands instead") { @opts[:simulate] = true ; @opts[:review] = false }
43
+ opts.on("-f", "--file DLFILE", "Use input file (be careful)") {|f| @opts[:input_file] = f }
44
+ opts.on("-m", "--monochrome", "Don't colorize output") { logger.colorize = false }
45
+ opts.on("-h", "--help", "Shows this help") { @opts[:dispatch] = :help }
46
+ opts.on("-v", "--version", "Shows version and other info") { @opts[:dispatch] = :info }
47
+ opts.on("-z", "Do not check for updates on GitHub (with -v/--version)") { @opts[:check_for_updates] = false }
48
+ end
49
+
50
+ begin
51
+ @optparse.parse!(@argv)
52
+ rescue OptionParser::ParseError => e
53
+ abort(e.message)
54
+ dispatch(:help)
55
+ exit 1
56
+ end
57
+ end
58
+
59
+ def which_editor
60
+ ENV["DLE_EDITOR"].presence ||
61
+ ENV["EDITOR"].presence ||
62
+ `which nano`.presence.try(:strip) ||
63
+ `which vim`.presence.try(:strip) ||
64
+ `which vi`.presence.try(:strip)
65
+ end
66
+
67
+ def open_editor file
68
+ system "#{@editor} #{file}"
69
+ end
70
+
71
+
72
+ # ==========
73
+ # = Logger =
74
+ # ==========
75
+ [:log, :warn, :abort, :debug].each do |meth|
76
+ define_method meth, ->(*a, &b) { Thread.main.app_logger.send(meth, *a, &b) }
77
+ end
78
+
79
+ def logger
80
+ Thread.main.app_logger
81
+ end
82
+
83
+ # Shortcut for logger.colorize
84
+ def c str, color = :yellow
85
+ logger.colorize? ? logger.colorize(str, color) : str
86
+ end
87
+
88
+ def ask question
89
+ logger.log_with_print(false) do
90
+ log c("#{question} ", :blue)
91
+ STDOUT.flush
92
+ STDIN.gets.chomp
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,151 @@
1
+ module Dle
2
+ class Application
3
+ module Dispatch
4
+ def dispatch action = (@opts[:dispatch] || :help)
5
+ case action
6
+ when :version, :info then dispatch_info
7
+ else
8
+ if respond_to?("dispatch_#{action}")
9
+ send("dispatch_#{action}")
10
+ else
11
+ abort("unknown action #{action}", 1)
12
+ end
13
+ end
14
+ end
15
+
16
+ def dispatch_help
17
+ @optparse.to_s.split("\n").each(&method(:log))
18
+ log ""
19
+ log "To set your favorite editor set the env variable #{c "DLE_EDITOR", :magenta}"
20
+ log "Note that you need a blocking call (e.g. subl -w, mate -w)"
21
+ log "Your current editor is: #{c @editor || "none", :magenta}"
22
+ end
23
+
24
+ def dispatch_info
25
+ logger.log_without_timestr do
26
+ log ""
27
+ log " Your version: #{your_version = Gem::Version.new(Dle::VERSION)}"
28
+
29
+ # get current version
30
+ logger.log_with_print do
31
+ log " Current version: "
32
+ if @opts[:check_for_updates]
33
+ require "net/http"
34
+ log c("checking...", :blue)
35
+
36
+ begin
37
+ current_version = Gem::Version.new Net::HTTP.get_response(URI.parse(Dle::UPDATE_URL)).body.strip
38
+
39
+ if current_version > your_version
40
+ status = c("#{current_version} (consider update)", :red)
41
+ elsif current_version < your_version
42
+ status = c("#{current_version} (ahead, beta)", :green)
43
+ else
44
+ status = c("#{current_version} (up2date)", :green)
45
+ end
46
+ rescue
47
+ status = c("failed (#{$!.message})", :red)
48
+ end
49
+
50
+ logger.raw "#{"\b" * 11}#{" " * 11}#{"\b" * 11}", :print # reset cursor
51
+ log status
52
+ else
53
+ log c("check disabled", :red)
54
+ end
55
+ end
56
+ log " Selected editor: #{c @editor || "none", :magenta}"
57
+
58
+ # more info
59
+ log ""
60
+ log " DLE DirectoryListEdit is brought to you by #{c "bmonkeys.net", :green}"
61
+ log " Contribute @ #{c "github.com/2called-chaos/dle", :cyan}"
62
+ log " Eat bananas every day!"
63
+ log ""
64
+ end
65
+ end
66
+
67
+ def dispatch_index
68
+ # require base directory
69
+ base_dir = ARGV[0].present? ? File.expand_path(ARGV[0].to_s) : ARGV[0].to_s
70
+ if !FileTest.directory?(base_dir)
71
+ if base_dir.present?
72
+ abort c(ARGV[0].to_s, :magenta) << c(" is not a valid directory!", :red)
73
+ else
74
+ dispatch(:help)
75
+ abort "Please provide a base directory.", 1
76
+ end
77
+ end
78
+
79
+ # index filesystem
80
+ log("index #{c base_dir, :magenta}")
81
+ @fs = Filesystem.new(base_dir, dotfiles: @opts[:dotfiles])
82
+ abort("Base directory is empty or not readable", 1) if @fs.index.empty?
83
+
84
+ file = "#{Dir.tmpdir}/#{SecureRandom.urlsafe_base64}"
85
+ begin
86
+ # read input file or open editor
87
+ if @opts[:input_file]
88
+ ifile = File.expand_path(@opts[:input_file])
89
+ if FileTest.file?(ifile) && FileTest.readable?(ifile)
90
+ @dlfile = DlFile.parse(ifile)
91
+ else
92
+ abort "Input file not readable: " << c(ifile, :magenta)
93
+ end
94
+ else
95
+ FileUtils.mkdir_p(File.dirname(file)) if !FileTest.exist?(File.dirname(file))
96
+ if !FileTest.exist?(file) || File.read(file).strip.empty?
97
+ File.open(file, "w") {|f| f.write @fs.to_dlfile }
98
+ end
99
+ log "open list for editing..."
100
+ open_editor(file)
101
+ @dlfile = DlFile.parse(file)
102
+ end
103
+
104
+ # delta changes
105
+ @delta = @fs.delta(@dlfile)
106
+
107
+ # no changes
108
+ if @delta.all?{|_, v| v.empty? }
109
+ abort c("No changes, nothing to do..."), 0
110
+ end
111
+
112
+ # review
113
+ if @opts[:review]
114
+ @delta.each do |action, snodes|
115
+ logger.ensure_prefix c("[#{action}]\t", :magenta) do
116
+ snodes.each do |snode|
117
+ if [:chown, :chmod].include?(action)
118
+ log(c("#{snode.node.relative_path} ", :blue) << c(snode.is, :red) << c(" » ") << c(snode.should, :green))
119
+ elsif [:cp, :mv].include?(action)
120
+ log(c(snode.is, :red) << c(" » ") << c(snode.should, :green))
121
+ else
122
+ log(c(snode.is, :red) << " (#{snode.snode.mode})")
123
+ end
124
+ end
125
+ end
126
+ end
127
+
128
+ answer = ask("Do you want to apply these changes? [yes/no/edit]")
129
+ while !["y", "yes", "n", "no", "e", "edit"].include?(answer.downcase)
130
+ answer = ask("Please be explicit, yes/no/edit:")
131
+ end
132
+ raise "retry" if ["e", "edit"].include?(answer.downcase)
133
+ abort("Aborted, nothing changed", 0) if !["y", "yes"].include?(answer.downcase)
134
+ end
135
+ rescue
136
+ $!.message == "retry" ? retry : raise
137
+ end
138
+
139
+ # apply changes
140
+ log "#{@opts[:simulate] ? "Simulating" : "Applying"} changes..."
141
+ @delta.each do |action, snodes|
142
+ logger.ensure_prefix c("[apply-#{action}]\t", :magenta) do
143
+ snodes.each do |snode|
144
+ Filesystem::Destructive.new(self, action, @fs, snode).perform
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,111 @@
1
+ module Dle
2
+ class DlFile
3
+ def self.generate fs
4
+ Generator.new(fs).render
5
+ end
6
+
7
+ def self.parse file
8
+ Parser.new(file).parse
9
+ end
10
+
11
+ class Parser
12
+ def initialize file
13
+ @file = file
14
+ end
15
+
16
+ def parse
17
+ {}.tap do |fs|
18
+ return fs unless File.readable?(@file)
19
+ File.readlines(@file).each do |l|
20
+ next if l.start_with?("#")
21
+ if l.strip.start_with?("<HD-BASE>")
22
+ fs[:HD_BASE] = l.strip.gsub("<HD-BASE>", "").gsub("</HD-BASE>", "")
23
+ elsif l.strip.start_with?()
24
+ fs[:HD_DOTFILES] = l.strip.gsub("<HD-DOTFILES>", "").gsub("</HD-DOTFILES>", "") == "true"
25
+ elsif l.count("|") >= 4
26
+ # parse line
27
+ chunks = l.split("|")
28
+ data = {}.tap do |r|
29
+ r[:inode] = chunks.shift.strip
30
+ r[:mode] = chunks.shift.strip
31
+ r[:uid], r[:gid] = chunks.shift.split(":").map(&:strip)
32
+ chunks.shift # ignore size
33
+ r[:relative_path] = chunks.join("|").strip
34
+ r[:path] = [fs[:HD_BASE], r[:relative_path]].join("/")
35
+ end
36
+
37
+ # skip headers
38
+ next if data[:inode].downcase == "in"
39
+ next if data[:mode].downcase == "mode"
40
+ next if data[:uid].downcase == "owner"
41
+ next if data[:relative_path].downcase == "file"
42
+
43
+ # map node
44
+ if fs.key? data[:inode]
45
+ Thread.main.app_logger.warn "inode #{data[:inode]} already mapped, ignore..."
46
+ else
47
+ fs[data[:inode]] = Filesystem::Softnode.new(data)
48
+ end
49
+ end
50
+ end
51
+ Thread.main.app_logger.warn("DLFILE has no HD-BASE, deltaFS will fail!") unless fs[:HD_BASE].present?
52
+ end
53
+ end
54
+ end
55
+
56
+ class Generator
57
+ include Helpers
58
+
59
+ def initialize fs
60
+ @fs = fs
61
+ end
62
+
63
+ def render
64
+ # inode, mode, own/grp, size, file
65
+ table = [[], [], [], [], []]
66
+ @fs.index.each do |rpath, node|
67
+ table[0] << node.inode
68
+ table[1] << node.mode
69
+ table[2] << node.owngrp
70
+ table[3] << human_filesize(node.size)
71
+ table[4] << node.relative_path
72
+ end
73
+
74
+ ([
75
+ %{#},
76
+ %{# - If you remove a line we just don't care!},
77
+ %{# - If you add a line we just don't care!},
78
+ %{# - If you change a path we will "mkdir -p" the destination and move the file/dir},
79
+ %{# - If you change the owner we will "chown" the file/dir},
80
+ %{# - If you change the mode we will "chmod" the file/dir},
81
+ %{# - If you change the mode to "cp" and modify the path we will copy instead of moving/renaming},
82
+ %{# - If you change the mode to "del" we will "rm" the file},
83
+ %{# - If you change the mode to "delr" we will "rm" the file or directory},
84
+ %{# - If you change the mode to "delf" or "delrf" we will "rm -f" the file or directory},
85
+ %{# - We will apply changes in this order (inside-out):},
86
+ %{# - Ownership},
87
+ %{# - Permissions},
88
+ %{# - Rename/Move},
89
+ %{# - Copy},
90
+ %{# - Delete},
91
+ %{#},
92
+ %{# Gotchas:},
93
+ %{# - If you have "folder/subfolder/file" and want to rename "subfolder" to "dubfolder"},
94
+ %{# do it only in the specific node, don't change the path of "file"!},
95
+ %{# - If you want to copy a directory, only copy the directory node, not a file inside it.},
96
+ %{# Folders will be copied recursively.},
97
+ %{# - The script works with file IDs (IN column). That allows you to remove files in renamed},
98
+ %{# folders without adjusting paths. This is not a gotcha, it's a hint :)},
99
+ %{# - Note that indexing is quite fast but applying changes on a base directory with a lot},
100
+ %{# of files and directories may be slow since we fully reindex after each operation.},
101
+ %{# Maybe the mapping will keep track of changes in later updates so that this isn't necessary},
102
+ %{# --------------------------------------------------},
103
+ %{},
104
+ %{<HD-BASE>#{@fs.base_dir}</HD-BASE>},
105
+ %{<HD-DOTFILES>#{@fs.opts[:dotfiles]}</HD-DOTFILES>},
106
+ %{},
107
+ ] + render_table(table, ["IN", "Mode", "Owner", "Size", "File"])).map(&:strip).join("\n")
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,109 @@
1
+ module Dle
2
+ class Filesystem
3
+ attr_reader :base_dir, :index, :opts
4
+
5
+ # ==========
6
+ # = Logger =
7
+ # ==========
8
+ [:log, :warn, :abort, :debug].each do |meth|
9
+ define_method meth, ->(*a, &b) { Thread.main.app_logger.send(meth, *a, &b) }
10
+ end
11
+
12
+ def logger
13
+ Thread.main.app_logger
14
+ end
15
+
16
+ # Shortcut for logger.colorize
17
+ def c str, color = :yellow
18
+ logger.colorize? ? logger.colorize(str, color) : str
19
+ end
20
+
21
+ # ---------
22
+
23
+ def initialize base_dir, opts = {}
24
+ raise ArgumentError, "#{base_dir} is not a directory" unless FileTest.directory?(base_dir)
25
+ @base_dir = File.expand_path(base_dir).freeze
26
+ @opts = { dotfiles: true }.merge(opts)
27
+ reindex!
28
+ end
29
+
30
+ def reindex!
31
+ @index = {}
32
+ index!
33
+ end
34
+
35
+ def index!
36
+ Find.find(@base_dir) do |path|
37
+ if File.basename(path)[0] == ?. && !@opts[:dotfiles]
38
+ Find.prune
39
+ else
40
+ index_node(path)
41
+ end
42
+ end
43
+ end
44
+
45
+ def relative_path path
46
+ if path.start_with?(@base_dir)
47
+ p = path[(@base_dir.length+1)..-1]
48
+ p.presence || "."
49
+ else
50
+ path
51
+ end
52
+ end
53
+
54
+ def to_dlfile
55
+ DlFile.generate(self)
56
+ end
57
+
58
+ def delta dlfile
59
+ abort "cannot delta DLFILE without HD_BASE", 1 unless dlfile[:HD_BASE].present?
60
+
61
+ # WARNING: The order of this result hash is important as it defines the order we process things!
62
+ {chown: [], chmod: [], mv: [], cp: [], rm: []}.tap do |r|
63
+ logger.ensure_prefix c("[dFS]\t", :magenta) do
64
+ log "HD-BASE is " << c(dlfile[:HD_BASE], :magenta)
65
+ dlfile.each do |ino, snode|
66
+ next if ino == :HD_BASE || ino == :HD_DOTFILES
67
+ node = @index[ino]
68
+ unless node
69
+ warn("INODE " << c(ino, :magenta) << c(" not found, ignore...", :red))
70
+ next
71
+ end
72
+
73
+ # flagged for removal
74
+ if %w[del delr delf delrf].include?(snode.mode)
75
+ r[:rm] << Softnode.new(node: node, snode: snode, is: node.relative_path)
76
+ next
77
+ end
78
+
79
+ # mode changed
80
+ if "#{snode.mode}".present? && "#{snode.mode}" != "cp" && "#{node.mode}" != "#{snode.mode}"
81
+ r[:chmod] << Softnode.new(node: node, snode: snode, is: node.mode, should: snode.mode)
82
+ end
83
+
84
+ # uid/gid changed
85
+ if "#{node.owngrp}" != "#{snode.uid}:#{snode.gid}"
86
+ r[:chown] << Softnode.new(node: node, snode: snode, is: node.owngrp, should: "#{snode.uid}:#{snode.gid}")
87
+ end
88
+
89
+ # path changed
90
+ if "#{node.relative_path}" != "#{snode.relative_path}"
91
+ r[snode.mode == "cp" ? :cp : :mv] << Softnode.new(node: node, snode: snode, is: node.relative_path, should: snode.relative_path)
92
+ end
93
+ end
94
+ end
95
+
96
+ # sort results to perform actions inside-out
97
+ r.each do |k, v|
98
+ r[k] = v.sort_by{|snode| snode.node.relative_path.length }.reverse
99
+ end
100
+ end
101
+ end
102
+
103
+ protected
104
+
105
+ def index_node path
106
+ Node.new(self, path).tap{|node| @index[node.inode] = node }
107
+ end
108
+ end
109
+ end