fled 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,260 @@
1
+ module FlEd
2
+ class FileListing
3
+ def initialize
4
+ @objects_by_id = {}
5
+ @objects = []
6
+ end
7
+ RELATION_KEYS = [:parent]
8
+ def dup
9
+ result = self.class.new
10
+ each do |object|
11
+ result.add object[:uid], object.dup
12
+ end
13
+ result.each do |object|
14
+ RELATION_KEYS.each do |relation|
15
+ next unless foreign = object[relation]
16
+ unless foreign[:uid]
17
+ raise RuntimeError, "Cannot duplicate anonymous object and maintain #{relation.inspect} referential"
18
+ end
19
+ unless (object[relation] = result[foreign[:uid]])
20
+ raise RuntimeError, "Cannot duplicate object in #{relation.inspect} referential, no duplicate found in target (?!)"
21
+ end
22
+ end
23
+ end
24
+ result
25
+ end
26
+ def each &block ; @objects.each(&block) ; end
27
+ def count ; @objects.count ; end
28
+ def add uid, object = {}
29
+ if uid
30
+ uid = uid.to_s
31
+ raise RuntimeError, "UID #{uid.inspect} already declared" if @objects_by_id[uid]
32
+ @objects_by_id[uid] = object
33
+ object[:uid] = uid
34
+ end
35
+ @objects << object
36
+ object
37
+ end
38
+ def [](uid)
39
+ if uid.is_a?(String)
40
+ @objects_by_id[uid]
41
+ else
42
+ @objects[uid]
43
+ end
44
+ end
45
+ end
46
+ class FileListing # to and from text format
47
+ def to_s
48
+ return "" if @objects.empty?
49
+ max_width = @objects.map { |o| o[:line] }.map(&:length).max + 10
50
+ @objects.map { |e| "#{e[:line].ljust(max_width)}:#{e[:uid]}" }.join("\n")
51
+ end
52
+ def self.parse listing
53
+ objects = self.new
54
+ previous_indent = nil
55
+ previous = nil
56
+ stack = []
57
+ listing.split("\n").each do |line|
58
+ next if line.strip.empty?
59
+ raise RuntimeError, "Unparsable line #{line.inspect}" unless line =~ /^((?: )*)(.*?)(?::(\d+))?\r?$/
60
+ indent = $1
61
+ name = $2.strip
62
+ if (dir = name[-1..-1] == "/")
63
+ name = name[0..-2]
64
+ end
65
+ current = objects.add($3, :name => name, :line => "#{$1}#{$2}")
66
+ current[:dir] = true if dir
67
+ next if name.strip == "" # Ignore indent when there is no name - element will be deleted, parent isnt used
68
+ if previous_indent && previous_indent != indent
69
+ if previous_indent.length < indent.length
70
+ stack.push(previous)
71
+ else
72
+ stack.pop
73
+ end
74
+ end
75
+ current[:parent] = stack.last if stack.count > 0
76
+ previous = current
77
+ previous_indent = indent
78
+ end
79
+ objects
80
+ end
81
+ end
82
+ class FileListing # Shell operation list builder
83
+ def operations_from! source_listing
84
+ errors = []
85
+ operations = []
86
+ pending_renames = []
87
+ running_source = source_listing.dup
88
+ self.breadth_first do |target, path|
89
+ next if path.any? { |o| o[:error] }
90
+ if target[:name] != "" && !target[:uid]
91
+ operations << [:mk, self.path_of(target).map { |o| o[:name] }]
92
+ fake_source = {:name => target[:name]}
93
+ fake_source[:parent] = running_source[path.last[:source][:uid]] unless path.empty?
94
+ target_uid = "new_#{running_source.count}"
95
+ target_uid += "_" while @objects_by_id[target_uid] || running_source[target_uid]
96
+ target[:uid] = target_uid
97
+ @objects_by_id[target_uid] = target
98
+ target[:source] = running_source.add(target_uid, fake_source)
99
+ else
100
+ source = running_source[target[:uid]]
101
+ if !(target[:source] = source)
102
+ target[:error] = true
103
+ errors += [[:fail, :no_such_uid, target]]
104
+ next
105
+ end
106
+ next if target[:name] == ""
107
+ if (target[:parent] || {})[:uid] != (source[:parent] ||{})[:uid]
108
+ existing_names = running_source.children_of((target[:parent] || {})[:uid]).map { |o| o[:name] }
109
+ new_name = target[:name]
110
+ new_name += ".tmp" while existing_names.any? { |n| n.casecmp(new_name) == 0 }
111
+ if new_name != target[:name]
112
+ pending_renames << [:renamed, target, target[:name]]
113
+ end
114
+ source_path = running_source.path_of(source).map { |o| o[:name] }
115
+ target[:name] = source[:name] = new_name
116
+ operations << [:moved,
117
+ source_path,
118
+ self.path_of(target).map { |o| o[:name] }
119
+ ]
120
+ if target[:parent]
121
+ source[:parent] = running_source[target[:parent][:uid]]
122
+ else
123
+ source.delete(:parent)
124
+ end
125
+ elsif target[:name] != source[:name]
126
+ source_path = running_source.path_of(source).map { |o| o[:name] }
127
+ existing_names = running_source.children_of((target[:parent] || {})[:uid]).map { |o| o[:name] }
128
+ new_name = target[:name]
129
+ new_name += ".tmp" while existing_names.any? { |n| n.casecmp(new_name) == 0 }
130
+ if new_name != target[:name]
131
+ pending_renames << [:renamed, target, target[:name]]
132
+ end
133
+ target[:name] = source[:name] = new_name
134
+ operations << [:renamed,
135
+ source_path,
136
+ target[:name]
137
+ ]
138
+ end
139
+ end
140
+ end
141
+ pending_renames.each do |op|
142
+ target = op[1]
143
+ new_name = op[2]
144
+ existing_names = running_source.children_of((target[:parent] || {})[:uid]).map { |o| o[:name] }
145
+ if existing_names.any? { |n| n.casecmp(new_name) == 0 }
146
+ errors += [[:warn, :would_overwrite, target, new_name]]
147
+ else
148
+ operations << [:renamed,
149
+ running_source.path_of(target).map { |o| o[:name] },
150
+ new_name
151
+ ]
152
+ target[:name] = target[:source][:name] = new_name
153
+ end
154
+ end
155
+ self.depth_first do |target, path|
156
+ if target[:name] == ""
157
+ operations << [:rm, running_source.path_of(target[:source]).map { |o| o[:name] }, target[:source]]
158
+ end
159
+ end
160
+ errors + operations
161
+ end
162
+ def has_child? parent, child
163
+ while child = child[:parent]
164
+ return true if child[:uid] == parent[:uid]
165
+ end
166
+ false
167
+ end
168
+ def path_name_of object
169
+ File.join(path_of(object).map { |o| o[:name] })
170
+ end
171
+ def path_of object
172
+ res = []
173
+ while object
174
+ res.unshift object
175
+ object = object[:parent]
176
+ end
177
+ res
178
+ end
179
+ def children_of parent, &blk
180
+ unless !parent || parent.is_a?(Hash)
181
+ parent_object = self[parent]
182
+ raise RuntimeError, "No parent #{parent.inspect} found" unless parent_object
183
+ parent = parent_object
184
+ end
185
+ @objects.
186
+ select { |e| (e[:parent].nil? && parent.nil?) || (e[:parent] == parent) }.
187
+ sort { |a, b| a[:name] <=> b[:name] }.
188
+ each(&blk)
189
+ end
190
+ def depth_first parent = nil, path = [], &block
191
+ children_of parent do |child|
192
+ depth_first(child, path + [child], &block)
193
+ yield child, path
194
+ end
195
+ end
196
+ def breadth_first parent = nil, path = [], &block
197
+ to_browse = []
198
+ children_of parent do |child|
199
+ yield child, path
200
+ to_browse += [child]
201
+ end
202
+ to_browse.each { |child| breadth_first child, path + [child], &block }
203
+ end
204
+ end
205
+ class ListingBuilder < DTC::Utils::FileVisitor
206
+ attr_accessor :listing
207
+ def initialize listing = FileListing.new
208
+ @listing = listing
209
+ end
210
+ def add_object path, dir
211
+ uid = next_uid
212
+ name = File.basename(path)
213
+ object = @listing.add(uid,
214
+ :path => path,
215
+ :name => File.basename(path),
216
+ :parent => @object_stack.last,
217
+ :line => "#{" " * self.depth}#{name}#{dir ? "/" : ""}"
218
+ )
219
+ object[:dir] = true if dir
220
+ object
221
+ end
222
+ def enter_folder dir
223
+ depth = self.depth
224
+ if self.depth == 0
225
+ @object_stack = []
226
+ else
227
+ @object_stack.push add_object(dir, true)
228
+ end
229
+ super
230
+ end
231
+ def visit_file name, full_path
232
+ add_object full_path, false
233
+ super
234
+ end
235
+ def leave_folder
236
+ @object_stack.pop
237
+ super
238
+ end
239
+ protected
240
+ def next_uid
241
+ @listing.count
242
+ end
243
+ def source_path source
244
+ res = []
245
+ while source
246
+ res.unshift target[:name]
247
+ target = target[:parent]
248
+ end
249
+ File.join(res)
250
+ end
251
+ def target_path target
252
+ res = []
253
+ while target
254
+ res.unshift target[:name]
255
+ target = target[:parent]
256
+ end
257
+ File.join(res)
258
+ end
259
+ end
260
+ end
data/lib/fled.rb ADDED
@@ -0,0 +1,38 @@
1
+ require 'dtc/utils'
2
+ require 'shellwords'
3
+
4
+ module FlEd
5
+ require 'fled/file_listing'
6
+
7
+ VERSION = '0.0.1'
8
+
9
+ def self.operation_list_to_bash ops
10
+ ops = ops.map do |op|
11
+ case op.first
12
+ when :mk
13
+ [:mkdir, File.join(op[1])]
14
+ when :moved
15
+ [:mv, File.join(op[1]), File.join(op[2])]
16
+ when :renamed
17
+ [:mv, File.join(op[1]), File.join((op[1].empty? ? [] : op[1][0..-2]) + [op[2]])]
18
+ when :rm
19
+ [op[2][:dir] ? :rmdir : :rm , File.join(op[1])]
20
+ else
21
+ op
22
+ end
23
+ end
24
+ warnings, operations = *ops.partition { |e| e.first == :warn }
25
+ result = []
26
+ unless warnings.empty?
27
+ result = ["# Warning:"]
28
+ warnings.each do |warning|
29
+ result += ["# - #{warning[1]}: #{File.join(warning[2][:source][:path], warning[3])}"]
30
+ end
31
+ result += ['', 'exit 1 # There are warnings to check first !', '']
32
+ end
33
+ result += operations.map do |op|
34
+ "#{Shellwords.join(op.map(&:to_s))}"
35
+ end
36
+ result
37
+ end
38
+ end
data/tests/helper.rb ADDED
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env ruby
2
+ $:.unshift File.join(File.dirname(__FILE__),'../lib')
3
+ require 'fled'
4
+
5
+ class PrintingFileVisitor < DTC::Utils::FileVisitor
6
+ def enter_folder dir
7
+ depth = self.depth
8
+ puts (" " * depth) + (depth > 0 ? File.basename(dir) : dir)
9
+ super
10
+ end
11
+ def visit_file name, full_path
12
+ puts (" " * depth) + name
13
+ super
14
+ end
15
+ end
16
+
17
+ class TestListingBuilder < FlEd::ListingBuilder
18
+ def next_uid
19
+ @uid
20
+ end
21
+ def enter_folder dir, uid = nil
22
+ @uid = uid
23
+ super dir
24
+ end
25
+ def visit_file name, uid
26
+ @uid = uid
27
+ super name, current_path(name)
28
+ end
29
+ end
30
+
31
+ class TestFS
32
+ def initialize &block
33
+ @root = DTC::Utils::DSLDSL::DSLHashWriter.write_static_tree_dsl(&block)
34
+ end
35
+ def receive visitor, root = @root
36
+ root.each_pair do |name, val|
37
+ next if name == :options
38
+ if val.is_a?(Array)
39
+ visitor.visit_file name, val[0]
40
+ elsif val.is_a?(Hash)
41
+ if visitor.enter_folder(name, val[:options][0])
42
+ receive(visitor, val)
43
+ visitor.leave_folder
44
+ end
45
+ else
46
+ raise RuntimeError, "Unknown value #{val.inspect}"
47
+ end
48
+ end
49
+ end
50
+ def new_builder
51
+ builder = TestListingBuilder.new
52
+ builder.enter_folder("$")
53
+ receive builder
54
+ builder.leave_folder
55
+ builder
56
+ end
57
+ def new_listing
58
+ new_builder.listing
59
+ end
60
+ def operation_list_if_edited_as edited_text
61
+ listing = new_listing
62
+ parsed = FlEd::FileListing.parse(edited_text)
63
+ parsed.operations_from!(listing)
64
+ end
65
+ def operations_if_edited_as edited_text
66
+ operation_list_if_edited_as(edited_text).map do |op|
67
+ case op.first
68
+ when :mk
69
+ [:mkdir, File.join(op[1])]
70
+ when :moved
71
+ [:mv, File.join(op[1]), File.join(op[2])]
72
+ when :renamed
73
+ [:mv, File.join(op[1]), File.join((op[1].empty? ? [] : op[1][0..-2]) + [op[2]])]
74
+ when :rm
75
+ [op[2][:dir] ? :rmdir : :rm , File.join(op[1])]
76
+ else
77
+ op
78
+ end
79
+ end
80
+ end
81
+ def commands_if_edited_as edited_text
82
+ FlEd::operation_list_to_bash(operation_list_if_edited_as(edited_text))
83
+ end
84
+ end
85
+
86
+ if __FILE__==$0
87
+ Dir[File.join(File.dirname(__FILE__),'./test_*.rb')].each do |test_file|
88
+ require test_file
89
+ end
90
+ end
data/tests/readme.rb ADDED
@@ -0,0 +1,256 @@
1
+ if Kernel.respond_to?(:require_relative)
2
+ require_relative "helper"
3
+ else
4
+ require File.join(File.dirname(__FILE__), 'helper')
5
+ end
6
+
7
+ class MarkdownDumbWriter
8
+ def initialize
9
+ @result = []
10
+ end
11
+ def ensure_clear exception = nil
12
+ unless @result.last == ""
13
+ @result << ""
14
+ else
15
+ if exception && @result.length > 1 &&
16
+ (@result[-2] || "")[0..exception.length - 1] == exception
17
+ @result.pop
18
+ end
19
+ end
20
+ end
21
+ def << str ; @result += str.is_a?(Array) ? str : [str] ; end
22
+ def nl ; @result += [""] ; end
23
+ def title str, depth = 2, *args
24
+ ensure_clear "#"
25
+ self << "#{"#" * depth} #{str}"
26
+ nl
27
+ args.each { |a| text a }
28
+ end
29
+ def h1 str, *a ; title str, 1, *a ; end
30
+ def h2 str, *a ; title str, 2, *a ; end
31
+ def h3 str, *a ; title str, 3, *a ; end
32
+ def code str, indent = " "
33
+ ensure_clear
34
+ self << reindent(str, indent)
35
+ nl
36
+ end
37
+ def reindent str, indent = ""
38
+ lines = str.split(/\r?\n/).map
39
+ lines.shift if lines.first.empty?
40
+ min_spaces = lines.map { |l| l =~ /^( +)/ ? $1.length : nil }.select{ |e| e }.min || 0
41
+ lines.map { |l| indent + ((min_spaces == 0 ? l : l[min_spaces..-1]) || "") }
42
+ end
43
+ def text str ; self << reindent(str, "") ; end
44
+ def em str ; text "*#{str}*" ; end
45
+ def li str ;
46
+ ensure_clear "-"
47
+ self << reindent(str, "- ")
48
+ nl
49
+ end
50
+ def show_example fs, example
51
+ code example
52
+ ops = fs.commands_if_edited_as(example)
53
+ text "Generates:"
54
+ if ops.empty?
55
+ em "No operation"
56
+ else
57
+ code ops.join("\n")
58
+ end
59
+ end
60
+ def result ; @result.join("\n") ; end
61
+ end
62
+
63
+ class ExampleDSLWriter < DTC::Utils::DSLDSL::DSLArrayWriter
64
+ def self.run &blk
65
+ visitor = self.new()
66
+ visitor.visit_dsl(&blk)
67
+ writer = MarkdownDumbWriter.new
68
+ visitor.each do |method, *args|
69
+ writer.__send__(method, *args)
70
+ end
71
+ writer.result
72
+ end
73
+ end
74
+
75
+ readme = ExampleDSLWriter.run do
76
+ h1 "FlEd", '`fled` lets you organise your files and folders in your favourite editor'
77
+
78
+ h2 "Disclaimer", <<-MD
79
+ Warning: This is a very dangerous tool. The author recommends you do not
80
+ use it. The author cannot be held responsible in any case.
81
+ MD
82
+
83
+ h2 "Introduction", <<-MD
84
+ `fled` enumerates a folder and its files, and generates a text listing.
85
+ You can then edit that listing in your favourite editor, and save changes.
86
+ `fled` then reloads those changes, and prints a shell script that would move
87
+ your files and folders around as-per your edits.
88
+
89
+ **You should review that shell script very carefully before running it.**
90
+
91
+ MD
92
+
93
+ h3 "Philosophy", <<-MD
94
+ `fled` only generates text, it does not perform any operation directly.
95
+
96
+ The design optimises for making the edits very simple. The consequence of
97
+ this is that very small edits can have large consequences, which makes
98
+ this a **very dangerous** tool. But so is `rm` and the rest of the shell anyway...
99
+ MD
100
+
101
+ h3 "Caveats", <<-MD
102
+ `fled` is only aware of files it scanned. It will not warn for overwrites,
103
+ nor use temporary files in those cases, etc.
104
+
105
+ `fled`'s editing model is rather complex and fuzzy. While there are some test
106
+ cases defined, any help is much appreciated.
107
+
108
+ You should be scared when using `fled`.
109
+ MD
110
+
111
+ h3 "Examples"
112
+ [
113
+ ["Print help text and option list", "fled --help"],
114
+ ["Edit current folder", "fled"],
115
+ ["Edit all files directly in `path` folder", "fled -a path -d 0"],
116
+ ["Save default options", "fled --options > fled.config.yaml"],
117
+ ["Edit current folder using options", "fled --load fled.config.yaml"],
118
+ ["Add options to a command (`mkdir`, `mv`, `rm` or `rmdir`)", "fled | sed 's/^mv/mv -i/'"],
119
+ ].each do |t, c|
120
+ text t
121
+ code c
122
+ end
123
+
124
+ h2 "Listing Format"
125
+
126
+ fs = TestFS.new do
127
+ folder(0) {
128
+ file_one(1)
129
+ folder_two(2) {
130
+ file_three(3)
131
+ }
132
+ }
133
+ end
134
+
135
+ code fs.new_listing.to_s
136
+
137
+ text <<-MD
138
+ Each line of the listing is in the format *`[indentation]`* *`[name]`* `:`*`[uid]`*
139
+
140
+ - The *indentation* must consist of only spaces, and is used to indicate the parent folder
141
+ - The *name* must not use colons (`:`). If it is cleared, it is assumed the file/folder is to be deleted
142
+ - The *uid* is used by FlEd to recognise the original of the edited line. Do not assume a *uid* does not
143
+ change between runs. It is valid only once.
144
+ MD
145
+
146
+ h2 "Operations"
147
+
148
+ h3 "Creating a new folder", 'Add a new line (therefore with no uid):'
149
+ show_example fs, <<-EXAMPLE
150
+ folder/ :0
151
+ new_folder
152
+ folder_two/ :2
153
+ EXAMPLE
154
+
155
+ h3 "Moving"
156
+ text 'Change the indentation and/or line order to change the parent of a file or folder:'
157
+ show_example fs, <<-EXAMPLE
158
+ folder/ :0
159
+ folder_two/ :2
160
+ file_one :1
161
+ file_three :3
162
+ EXAMPLE
163
+ em 'Moving an item below itself or its children is not recommended, as the listing may not be exhaustive'
164
+
165
+ h3 "Renaming"
166
+ text 'Edit the name while preserving the uid to rename the item'
167
+ show_example fs, <<-EXAMPLE
168
+ folder_renamed/ :0
169
+ file_one :1
170
+ folder_two/ :2
171
+ file_changed :3
172
+ EXAMPLE
173
+ text '*Swapping file names may not work in cases where the generated intermediary file exists but was not included in the listing*'
174
+
175
+ h3 "Deleting", 'Clear a name but leave the uid to delete that item'
176
+ show_example fs, <<-EXAMPLE
177
+ folder_renamed/ :0
178
+ :1
179
+ :2
180
+ :3
181
+ EXAMPLE
182
+
183
+ h3 "No-op"
184
+ text 'If a line (and all child-lines) is removed from the listing, it will have no operation.'
185
+ show_example fs, <<-EXAMPLE
186
+ folder/ :0
187
+ EXAMPLE
188
+ nl
189
+ nl
190
+ text '*Note that removing a folder without removing its children will move its children:*'
191
+
192
+ show_example fs, <<-EXAMPLE
193
+ folder/ :0
194
+ file_one :1
195
+ file_three :3
196
+ EXAMPLE
197
+
198
+ nl
199
+ text "If an indent is forgotten:"
200
+
201
+ show_example fs, <<-EXAMPLE
202
+ folder/ :0
203
+ file_one :1
204
+ file_three :3
205
+ EXAMPLE
206
+
207
+ h3 "All together"
208
+ show_example fs, <<-EXAMPLE
209
+ folder_new/ :0
210
+ new_folder/
211
+ first :1
212
+ second :3
213
+ :2
214
+ EXAMPLE
215
+
216
+ h2 "Edge cases", "These sort-of work, but are still rather experimental"
217
+
218
+ h3 "Swapping files"
219
+
220
+ fs = TestFS.new do
221
+ folder(0) {
222
+ file_one(1)
223
+ file_two(2)
224
+ }
225
+ end
226
+ code fs.new_listing.to_s
227
+ text "When applying"
228
+ show_example fs, <<-EXAMPLE
229
+ folder/ :0
230
+ file_two :1
231
+ file_one :2
232
+ EXAMPLE
233
+
234
+ h3 "Tree swapping"
235
+ fs = TestFS.new do
236
+ folder(0) {
237
+ sub_folder(1) {
238
+ sub_sub_folder(2) {
239
+ file.txt(3)
240
+ }
241
+ }
242
+ }
243
+ end
244
+ code fs.new_listing.to_s
245
+ text "When applying"
246
+ show_example fs, <<-EXAMPLE
247
+ sub_sub_folder/ :2
248
+ sub_folder/ :1
249
+ folder/ :0
250
+ file.txt :3
251
+ EXAMPLE
252
+
253
+ h2 "Contributors"
254
+ li "[Eric Doughty-Papassideris](http://github.com/ddlsmurf)"
255
+ end
256
+ puts readme