fled 0.0.1

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.
@@ -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