sequin 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +5 -0
- data/CHANGELOG +5 -0
- data/Gemfile +4 -0
- data/LICENSE +0 -0
- data/README.md +210 -0
- data/Rakefile +2 -0
- data/bin/seq +5 -0
- data/lib/listing.rb +334 -0
- data/lib/membership.rb +83 -0
- data/lib/sequin.rb +258 -0
- data/lib/sequin/version.rb +3 -0
- data/sequin.gemspec +21 -0
- metadata +76 -0
data/CHANGELOG
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
File without changes
|
data/README.md
ADDED
@@ -0,0 +1,210 @@
|
|
1
|
+
|
2
|
+
|
3
|
+
Sequin
|
4
|
+
======
|
5
|
+
|
6
|
+
Description
|
7
|
+
--------
|
8
|
+
|
9
|
+
Sequin is a collection of thor tasks that allow you to list, test and modify sequences of files on disk.
|
10
|
+
|
11
|
+
Installation
|
12
|
+
--------
|
13
|
+
|
14
|
+
$ gem install sequin
|
15
|
+
|
16
|
+
or
|
17
|
+
|
18
|
+
$ sudo gem install sequin
|
19
|
+
|
20
|
+
Usage
|
21
|
+
--------
|
22
|
+
|
23
|
+
The command is seq, followed by a task:
|
24
|
+
|
25
|
+
Tasks
|
26
|
+
|
27
|
+
* seq list [FILES] # List files as sequences.
|
28
|
+
* seq match [FILES] -r, --regex=REGEX # Match filename naming conventions using a regular expression.
|
29
|
+
* seq check [SIGNATURE] # Check the existence of files defined by a sequence signature.
|
30
|
+
* seq generate [NAME] # Generate a seq config file containing regular expression definitions used in the seq match task.
|
31
|
+
* seq help [TASK] # Describe available tasks or one specific task.
|
32
|
+
* seq usage # Print the usage banner.
|
33
|
+
|
34
|
+
All tasks have a single letter alias. Use m instead of match, c instead of check etc.
|
35
|
+
|
36
|
+
|
37
|
+
### List Sequences
|
38
|
+
|
39
|
+
seq list [FILES]
|
40
|
+
|
41
|
+
List the given files as sequences where possible.
|
42
|
+
|
43
|
+
Options:
|
44
|
+
|
45
|
+
* -v, [--verbose=N] Specify how verbose to be when listing missing frames.
|
46
|
+
|
47
|
+
* 0 Dont mention missing frames
|
48
|
+
* 1 show missing frame count
|
49
|
+
* 2 list missing frame numbers
|
50
|
+
* Default: 1
|
51
|
+
|
52
|
+
Each line of output from this task is a **sequence signature.** It consists of a **filename template**: a filename where the sequence digits replaced with "#"s, followed by a **range spec**: start, end, and by frames in square brackets.
|
53
|
+
|
54
|
+
For example: `../test/a/op_063_8900.####.jpg [0 to 25 by 1]`
|
55
|
+
|
56
|
+
|
57
|
+
|
58
|
+
If any frames are missing, the range spec will be colored red in the output, as will other information about the missing frames according to the verbose options.
|
59
|
+
|
60
|
+
$ seq l * -v 2
|
61
|
+
h264_960x540_film.mov
|
62
|
+
mjpega_1920x1080.mov
|
63
|
+
#### [1 to 30 by 1]
|
64
|
+
006_057_s3d_lf_r04.####.dpx [1 to 30 by 1]
|
65
|
+
006_057_s3d_lf_r05.#.dpx [1 to 30 by 1] - missing: [ 11,14,15,18,22 ]
|
66
|
+
006_057_s3d_lf_r06.#.dpx [1 to 30 by 1]
|
67
|
+
008_055_s3d_lf_r04.####.dpx [1 to 50 by 1]
|
68
|
+
fn_by_three.#.jpg [1 to 25 by 3] - missing: [ 19 ]
|
69
|
+
fn_by_two.#.jpg [1 to 29 by 2]
|
70
|
+
|
71
|
+
$ seq l 006* -v 1
|
72
|
+
006_057_s3d_lf_r04.####.dpx [1 to 30 by 1]
|
73
|
+
006_057_s3d_lf_r05.#.dpx [1 to 30 by 1] - missing: [ 5 frames ]
|
74
|
+
006_057_s3d_lf_r06.#.dpx [1 to 30 by 1]
|
75
|
+
|
76
|
+
|
77
|
+
### Match
|
78
|
+
|
79
|
+
seq match [FILES] -r, --regex=REGEX
|
80
|
+
|
81
|
+
Make sure filenames conform to file naming conventions by testing them against a regular expression.
|
82
|
+
|
83
|
+
Options:
|
84
|
+
|
85
|
+
* -r, --regex=REGEX # Specify a regular expression literally, or by reference to one listed in ~/.seq.yml
|
86
|
+
* -m, [--show-matched] # List files that match the regex, in green
|
87
|
+
* -f, [--show-failed] # List files that failed to match the regex, in red
|
88
|
+
* -a, [--show-all] # List all the files in order with color coding.
|
89
|
+
|
90
|
+
Normally you wouldn't want to type lengthy regular expressions on the command line, so instead they can be stored in a yaml file and referred to by the keys in the yaml data structure. The keys would typically represent projects and filetypes.
|
91
|
+
|
92
|
+
The yaml file looks something like this:
|
93
|
+
|
94
|
+
potter:
|
95
|
+
dpx: ^[0-9]{3}_[0-9]{3}_s3d_(rt|lf)_r[0-9]{2}\.[0-9]{4}\.dpx$
|
96
|
+
mcx: ^[0-9]{3}_[0-9]{3}_ver[0-9]{2,3}\.[0-9]{4}\.xml$
|
97
|
+
conan:
|
98
|
+
dpx: ^[a-z]{2}_[0-9]{3}_[0-9]{4}_(L|R|C)\.[0-9]{4,5}\.dpx$
|
99
|
+
mov: ^[a-z]{2}_h264_[0-9]{3,4}x[0-9]{3,4}_(film|pal|ntsc)\.mov$
|
100
|
+
|
101
|
+
The regular expression can then be referred to on the command line with `--regex potter:dpx` and so on.
|
102
|
+
|
103
|
+
$ seq m * -r potter:dpx
|
104
|
+
Testing against regular expression: /^[0-9]{3}_[0-9]{3}_s3d_(rt|lf)_r[0-9]{2}\.[0-9]{4}\.dpx$/
|
105
|
+
h264_960x540_film.mov FAILED
|
106
|
+
mjpega_1920x1080.mov FAILED
|
107
|
+
#### [1 to 30 by 1] (0 MATCHED, 30 FAILED)
|
108
|
+
006_057_s3d_lf_r04.####.dpx [1 to 30 by 1] (30 MATCHED)
|
109
|
+
006_057_s3d_lf_r05.#.dpx [1 to 30 by 1] (0 MATCHED, 25 FAILED)
|
110
|
+
006_057_s3d_lf_r06.#.dpx [1 to 30 by 1] (0 MATCHED, 30 FAILED)
|
111
|
+
008_055_s3d_lf_r04.####.dpx [1 to 50 by 1] (50 MATCHED)
|
112
|
+
fn_by_three.#.jpg [1 to 28 by 3] (0 MATCHED, 10 FAILED)
|
113
|
+
fn_by_two.#.jpg [1 to 29 by 2] (0 MATCHED, 15 FAILED)
|
114
|
+
op_063_8900.####.jpg [0 to 25 by 1] (0 MATCHED, 26 FAILED)
|
115
|
+
|
116
|
+
|
117
|
+
|
118
|
+
|
119
|
+
### Check membership
|
120
|
+
|
121
|
+
seq check [SIGNATURE]
|
122
|
+
|
123
|
+
Check the existence of files defined by a sequence signature.
|
124
|
+
|
125
|
+
Options:
|
126
|
+
|
127
|
+
* -f, [--show-found] # List files from the signature that are on disk.
|
128
|
+
* -m, [--show-missing] # List files from the signature that are missing on disk.
|
129
|
+
* -e, [--show-extra] # List files that are on disk but not in the signature.
|
130
|
+
* -a, [--show-all] # List the union of all files on disk or in the signature.
|
131
|
+
|
132
|
+
|
133
|
+
Files may either be found, missing, or extra. Extra means the file exists on disk but not in the signature. With no flags, the output will be just a single line showing the number of found, missing or extra. By using the flags, you can list the individual files in the sequence with color coding and and in order.
|
134
|
+
|
135
|
+
$ seq c 006_057_s3d_lf_r04.####.dpx [1 to 30 by 1]
|
136
|
+
006_057_s3d_lf_r04.####.dpx [found: 30] [missing: 0] [extra: 0]
|
137
|
+
|
138
|
+
$ seq c 006_057_s3d_lf_r04.####.dpx [5 to 35 by 1]
|
139
|
+
006_057_s3d_lf_r04.####.dpx [found: 26] [missing: 5] [extra: 4]
|
140
|
+
|
141
|
+
$ seq c 006_057_s3d_lf_r04.####.dpx [5 to 35 by 2] -m
|
142
|
+
006_057_s3d_lf_r04.####.dpx [found: 13] [missing: 3] [extra: 17]
|
143
|
+
006_057_s3d_lf_r04.0031.dpx
|
144
|
+
006_057_s3d_lf_r04.0033.dpx
|
145
|
+
006_057_s3d_lf_r04.0035.dpx
|
146
|
+
|
147
|
+
### Generate
|
148
|
+
|
149
|
+
seq generate [NAME]
|
150
|
+
|
151
|
+
Generate the .sequin config file in your home directory ~/.seq.yml
|
152
|
+
|
153
|
+
Options:
|
154
|
+
* -f, [--force] # Overwrite the file if it exists.
|
155
|
+
|
156
|
+
Currently, NAME must be config. This file contains regular expression definitions used in the `seq match` task.
|
157
|
+
|
158
|
+
$ seq g config
|
159
|
+
Skipping write of /Users/julianmann/.seq.yml because it exists. Use --force to remove it.
|
160
|
+
|
161
|
+
$ seq g config -f
|
162
|
+
Generating /Users/julianmann/.seq.yml
|
163
|
+
|
164
|
+
How Sequin finds sequences
|
165
|
+
-------
|
166
|
+
|
167
|
+
The goal of the sequence listing algorithm is to find sequences without the need
|
168
|
+
for the user to specify where the numbers are in the filenames, or what characters
|
169
|
+
delimit the numbers. This can be tricky because a filename may contain many parts
|
170
|
+
containing numbers. Here's a real world example: `004_055_s3d_lf_r04.0001.dpx`
|
171
|
+
How can we know which number part correctly represents the sequence?
|
172
|
+
It could be any of the following
|
173
|
+
|
174
|
+
###_055_s3d_lf_r04.0001.dpx
|
175
|
+
004_###_s3d_lf_r04.0001.dpx
|
176
|
+
004_055_s#d_lf_r04.0001.dpx
|
177
|
+
004_055_s3d_lf_r##.0001.dpx
|
178
|
+
004_055_s3d_lf_r04.####.dpx
|
179
|
+
|
180
|
+
The strategy we employ is to use the number part that causes the file to be in the largest sequence.
|
181
|
+
|
182
|
+
Consider the files:
|
183
|
+
|
184
|
+
file.04.0001.ext
|
185
|
+
file.04.0002.ext
|
186
|
+
file.04.0003.ext
|
187
|
+
file.05.0001.ext
|
188
|
+
file.05.0002.ext
|
189
|
+
file.05.0003.ext
|
190
|
+
|
191
|
+
There are 5 possible sequences here. Each file could be in 2 of them, one containing 2 files, one containing 3.
|
192
|
+
|
193
|
+
file.04.0001.ext could be in file.##.0001.ext or file.04.####.ext
|
194
|
+
|
195
|
+
There are more files in `file.04.####.ext` than `file.##.0001.ext`, so we resolve to `file.04.####.ext` for this file.
|
196
|
+
|
197
|
+
|
198
|
+
To Do
|
199
|
+
-------
|
200
|
+
|
201
|
+
* Add support for listing recursively
|
202
|
+
* Add modifier tasks such as pad, offset, replace etc.
|
203
|
+
* Test colors in shells other than bash.
|
204
|
+
* Test on Ruby 1.8.7. Currently works under Ruby 1.9.2
|
205
|
+
|
206
|
+
|
207
|
+
License
|
208
|
+
-------
|
209
|
+
|
210
|
+
See the LICENSE file for further details.
|
data/Rakefile
ADDED
data/bin/seq
ADDED
data/lib/listing.rb
ADDED
@@ -0,0 +1,334 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
require 'pp'
|
3
|
+
require 'fileutils'
|
4
|
+
|
5
|
+
# add colors to String class
|
6
|
+
class String
|
7
|
+
def colorize(code)
|
8
|
+
"\e[0;#{code.to_s}m#{self}\e[0m"
|
9
|
+
|
10
|
+
# self
|
11
|
+
end
|
12
|
+
|
13
|
+
# I tried to put "define_method" in a loop but it was having none of it
|
14
|
+
# Please accept my humble apologies
|
15
|
+
def red; self.colorize(31);end
|
16
|
+
def green; self.colorize(32);end
|
17
|
+
def yellow; self.colorize(33);end
|
18
|
+
def blue; self.colorize(34);end
|
19
|
+
def purple; self.colorize(35);end
|
20
|
+
def cyan; self.colorize(36);end
|
21
|
+
end
|
22
|
+
|
23
|
+
|
24
|
+
|
25
|
+
module Sequin
|
26
|
+
|
27
|
+
class Sequin::Listing
|
28
|
+
|
29
|
+
# The goal of this sequence listing algorithm is to find sequences without the need
|
30
|
+
# for the user to specify where the numbers are in the filenames, or what characters
|
31
|
+
# delimit the numbers. This can be tricky because a filename may contain many parts
|
32
|
+
# containing numbers. Here's a real world example: 004_055_s3d_lf_r04.0001.dpx
|
33
|
+
# How can we know which number part correctly represents the sequence?
|
34
|
+
# It could be any of the following
|
35
|
+
# ###_055_s3d_lf_r04.0001.dpx
|
36
|
+
# 004_###_s3d_lf_r04.0001.dpx
|
37
|
+
# 004_055_s#d_lf_r04.0001.dpx
|
38
|
+
# 004_055_s3d_lf_r##.0001.dpx
|
39
|
+
# 004_055_s3d_lf_r04.####.dpx
|
40
|
+
#
|
41
|
+
# The strategy we employ is to use the number part that causes the file to be in the largest sequence.
|
42
|
+
|
43
|
+
# Consider the files:
|
44
|
+
# file.04.0001.ext
|
45
|
+
# file.04.0002.ext
|
46
|
+
# file.04.0003.ext
|
47
|
+
# file.05.0001.ext
|
48
|
+
# file.05.0002.ext
|
49
|
+
# file.05.0003.ext
|
50
|
+
#
|
51
|
+
# There are 5 possible sequences here. Each file could be in 2 of them, one containing 2 files, one containing 3.
|
52
|
+
# file.04.0001.ext could be in file.##.0001.ext or file.04.####.ext
|
53
|
+
# There are more files in file.04.####.ext than file.##.0001.ext, so we use that sequence for this file.
|
54
|
+
|
55
|
+
|
56
|
+
|
57
|
+
def initialize(files)
|
58
|
+
@sequences = Hash.new
|
59
|
+
|
60
|
+
# make a hash containing a signature key for all possible sequences
|
61
|
+
all_seqs = all_possible_sequences files
|
62
|
+
|
63
|
+
# now select from that hash the best sequences according
|
64
|
+
# to the strategy outlined above
|
65
|
+
select_best_sequences(all_seqs, files)
|
66
|
+
|
67
|
+
# we need to make a note of the padding because
|
68
|
+
# we will be converting the number part to integers
|
69
|
+
each_sequence { |k,v| v[:padding] = v[:numbers].min.length }
|
70
|
+
|
71
|
+
# convert the number part to integers and sort them
|
72
|
+
# so we can figure out the range, by_frame, and missing frames
|
73
|
+
each_sequence { |k,v| v[:numbers].collect! {|el| el.to_i} }
|
74
|
+
each_sequence { |k,v| v[:numbers].sort! }
|
75
|
+
|
76
|
+
# find the by_frame so that we can figure out which
|
77
|
+
# frames are missing, if any. If we don't get
|
78
|
+
# the by_frame for a sequence that is by 2 for example, we would
|
79
|
+
# think that every other frame was missing.
|
80
|
+
each_sequence {|k,v| v[:by] = calc_by_frame v[:numbers]}
|
81
|
+
|
82
|
+
# now we know the by frame, we can find out which frames
|
83
|
+
# are missing because the user will want to know this.
|
84
|
+
each_sequence do |k,v|
|
85
|
+
range = v[:numbers].min..v[:numbers].max
|
86
|
+
v[:missing] = []
|
87
|
+
range.step(v[:by]) do |val|
|
88
|
+
v[:missing] << val unless v[:numbers].include? val
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# we no longer need the filenames. We have enough info to
|
93
|
+
# recreate them if necessary
|
94
|
+
each_sequence {|k,v| v.delete(:names) }
|
95
|
+
|
96
|
+
# here we change the hash sequence keys (which are arrays). We make the number slot
|
97
|
+
# represent the padding. This is so we can create a sequence key
|
98
|
+
# by simply calling join on the array elements
|
99
|
+
each_sequence { |k,v| k[v[:numbers_index]] = ("#" *v[:padding]) }
|
100
|
+
end
|
101
|
+
|
102
|
+
|
103
|
+
# for debugging
|
104
|
+
def inspect
|
105
|
+
pp @sequences
|
106
|
+
end
|
107
|
+
|
108
|
+
|
109
|
+
|
110
|
+
# a sequence string describes the sequence like so.
|
111
|
+
# filename [ <start> to <end> by <by_frame> ] - missing [ <missing frames> ]
|
112
|
+
# This is what gets printed out in a listing, and can also be used as input
|
113
|
+
# to other seq commands such as seq pad, seq renumber, seq substitute
|
114
|
+
# The missing frames section is optional. When a sequence_signature is used as input
|
115
|
+
# the missing frames will be determined by seq internally.
|
116
|
+
def sequence_signature(k,v,verbose=1)
|
117
|
+
range_str= "[#{v[:numbers].min} to #{v[:numbers].max} by #{v[:by]}]"
|
118
|
+
unless v[:missing].empty?
|
119
|
+
range_str = "#{range_str} - missing: [ #{v[:missing].count} frames ]" if verbose == 1
|
120
|
+
range_str = "#{range_str} - missing: [ #{v[:missing].join(",")} ]"if verbose == 2
|
121
|
+
range_str = range_str.red
|
122
|
+
else
|
123
|
+
range_str = range_str.green
|
124
|
+
end
|
125
|
+
str = "#{k.join()} #{range_str}"
|
126
|
+
end
|
127
|
+
|
128
|
+
|
129
|
+
|
130
|
+
# simple listing of single files and sequences
|
131
|
+
def list(verbose=1)
|
132
|
+
each_single {|f| puts f}
|
133
|
+
|
134
|
+
each_sequence do |k,v|
|
135
|
+
str = sequence_signature(k,v,verbose)
|
136
|
+
puts str
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
|
141
|
+
# match a sequence against a regex.
|
142
|
+
# create an array where
|
143
|
+
# each element is a sub array of the form
|
144
|
+
# ["filename", <:matched|:failed>, frame_number]
|
145
|
+
#
|
146
|
+
# The sequence is more managable as a single
|
147
|
+
# array as it can be sorted.
|
148
|
+
def self.match_matrix(regex, key , seq)
|
149
|
+
prefix, suffix = key.join().split(/#+/)
|
150
|
+
matrix = []
|
151
|
+
seq[:numbers].each do |n|
|
152
|
+
fn = [prefix , ("%0#{seq[:padding]}d" % n) , suffix].join()
|
153
|
+
unless regex.match(File.basename(fn)).nil?
|
154
|
+
matrix << [fn,:matched,n]
|
155
|
+
else
|
156
|
+
matrix << [fn,:failed,n]
|
157
|
+
end
|
158
|
+
end
|
159
|
+
matrix.sort! { |a,b| a[2] <=> b[2] }
|
160
|
+
end
|
161
|
+
|
162
|
+
|
163
|
+
|
164
|
+
|
165
|
+
|
166
|
+
# make a one line message describing the result of this
|
167
|
+
# matching a regex against all files in a sequence. Most of
|
168
|
+
# the info we need is in the fn_matrix array, but we also
|
169
|
+
# need the sequence key/value pair to create the signature.
|
170
|
+
def assessment_message(fn_matrix,k,v)
|
171
|
+
matched = fn_matrix.count { |el| el[1] == :matched }
|
172
|
+
failed = fn_matrix.count - matched
|
173
|
+
|
174
|
+
msg = "#{matched} MATCHED"
|
175
|
+
msg = "#{msg}, #{failed} FAILED" if failed > 0
|
176
|
+
msg = "(#{msg})"
|
177
|
+
msg = "#{msg} / #{fn_matrix.count} files" if failed > 0 && matched > 0
|
178
|
+
|
179
|
+
if failed == 0
|
180
|
+
msg = msg.green
|
181
|
+
elsif matched == 0
|
182
|
+
msg = msg.red
|
183
|
+
else
|
184
|
+
msg = msg.yellow
|
185
|
+
end
|
186
|
+
|
187
|
+
fn = sequence_signature(k,v,0)
|
188
|
+
message = "#{fn} #{msg}"
|
189
|
+
end
|
190
|
+
|
191
|
+
|
192
|
+
|
193
|
+
# this proc is called from the main script. Matches a regex against
|
194
|
+
# all single files and all sequences in the @sequences object and outputs
|
195
|
+
# information according to verbosity options
|
196
|
+
def match(the_regex, options ={})
|
197
|
+
re = Regexp.new(the_regex)
|
198
|
+
puts "Testing against regular expression: /#{the_regex}/".cyan
|
199
|
+
|
200
|
+
each_single do |f|
|
201
|
+
msg = re.match(File.basename(f)).nil? ? 'FAILED'.red : 'PASSED'.green
|
202
|
+
puts "#{f} #{msg}"
|
203
|
+
end
|
204
|
+
|
205
|
+
each_sequence do |k,v|
|
206
|
+
fn_matrix = Sequin::Listing.match_matrix(re, k,v)
|
207
|
+
puts "#{assessment_message(fn_matrix,k,v)}"
|
208
|
+
unless options["show_all"].nil? && options["show_matched"].nil? && options["show_failed"].nil?
|
209
|
+
fn_matrix.each do |el|
|
210
|
+
key = "show_#{el[1].to_s}"
|
211
|
+
puts el[0].send(Sequin::Listing.alert_colors[el[1]]) unless options[key].nil? && options["show_all"].nil?
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
end
|
217
|
+
|
218
|
+
|
219
|
+
# takes a block and iterates through sequence
|
220
|
+
# keys, ignoring single files
|
221
|
+
def each_sequence
|
222
|
+
@sequences.each do |k,v|
|
223
|
+
yield(k,v) unless k == :single
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
# takes a block and iterates through single
|
228
|
+
# files, ignoring . and .. and sequences
|
229
|
+
def each_single
|
230
|
+
@sequences.each do |k,v|
|
231
|
+
if k == :single
|
232
|
+
v.each { |f| yield(f) unless( f =="." || f == "..") }
|
233
|
+
end
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
|
238
|
+
private
|
239
|
+
|
240
|
+
|
241
|
+
def all_possible_sequences(files)
|
242
|
+
h = Hash.new
|
243
|
+
files.each do |f|
|
244
|
+
|
245
|
+
# an array containing the number parts in the odd slots: [1] [3] etc.
|
246
|
+
parts = f.split(/([0-9]+)/)
|
247
|
+
|
248
|
+
# If there are an even number of parts, half of them are numbers
|
249
|
+
# If there are an odd number of parts, there are more not-numbers than numbers
|
250
|
+
# By dividing by 2 and relying on integer rounding, we get the right answer in both cases
|
251
|
+
number_count = parts.count / 2
|
252
|
+
|
253
|
+
# For every number position, make our hash key from the remaining parts
|
254
|
+
# and make the hash value another hash containing the filenames, numbers, and other info
|
255
|
+
(0...number_count).each do |i|
|
256
|
+
number_part_index = (i*2)+1
|
257
|
+
key =[]
|
258
|
+
(0...parts.count).each do |part_index|
|
259
|
+
key << (part_index == number_part_index ? "#" : parts[part_index] )
|
260
|
+
end
|
261
|
+
|
262
|
+
h[key] ||= {}
|
263
|
+
(h[key][:names] ||= []) << f
|
264
|
+
(h[key][:numbers] ||= []) << parts[number_part_index]
|
265
|
+
h[key][:numbers_index] = number_part_index
|
266
|
+
end
|
267
|
+
end
|
268
|
+
h
|
269
|
+
end
|
270
|
+
|
271
|
+
|
272
|
+
# Find the best sequence that each file belongs to.
|
273
|
+
# As mentioned above, the best sequence is the one with
|
274
|
+
# the most files out of the set of all sequences that
|
275
|
+
# this file is in.
|
276
|
+
def select_best_sequences(all_seqs, files)
|
277
|
+
files.each do |f|
|
278
|
+
most_frames = 0
|
279
|
+
best_hash = {}
|
280
|
+
best_key = nil
|
281
|
+
|
282
|
+
all_seqs.each do |k,v|
|
283
|
+
curr_frames = all_seqs[k][:numbers].count
|
284
|
+
if curr_frames > most_frames
|
285
|
+
if all_seqs[k][:names].include?(f)
|
286
|
+
best_hash = all_seqs[k]
|
287
|
+
best_key = k
|
288
|
+
most_frames = curr_frames
|
289
|
+
end
|
290
|
+
end
|
291
|
+
end
|
292
|
+
if best_key.nil? || most_frames < 2
|
293
|
+
( @sequences[:single] ||= [] ) << f
|
294
|
+
else
|
295
|
+
@sequences[best_key] = best_hash
|
296
|
+
end
|
297
|
+
end
|
298
|
+
end
|
299
|
+
|
300
|
+
# to determine the by_frame, we calculate the greatest common factor of
|
301
|
+
# all the gaps between successive frames. If the gap gets to 1, then break
|
302
|
+
# because we don't consider fractional frame numbers. 1 is the smallest gap allowed.
|
303
|
+
# Note: with this method, we can still know the by_frame even if some frames
|
304
|
+
# are missing
|
305
|
+
def calc_by_frame(frames)
|
306
|
+
gcf = frames.max - frames.min
|
307
|
+
last = frames.min
|
308
|
+
frames.each do |n|
|
309
|
+
if n != frames.min
|
310
|
+
new_gcf = greatest_common_factor(gcf, n - last)
|
311
|
+
gcf = new_gcf if new_gcf < gcf
|
312
|
+
break if gcf == 1
|
313
|
+
last = n
|
314
|
+
end
|
315
|
+
end
|
316
|
+
gcf
|
317
|
+
end
|
318
|
+
|
319
|
+
|
320
|
+
# recursive GCF
|
321
|
+
def greatest_common_factor(n1, n2)
|
322
|
+
return ( n1 % n2==0 ) ? n2 : greatest_common_factor(n2, n1 % n2 )
|
323
|
+
end
|
324
|
+
|
325
|
+
def self.alert_colors
|
326
|
+
{ :matched =>"green", :failed =>"red" }
|
327
|
+
end
|
328
|
+
|
329
|
+
|
330
|
+
|
331
|
+
end
|
332
|
+
|
333
|
+
|
334
|
+
end
|
data/lib/membership.rb
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
|
2
|
+
|
3
|
+
module Sequin
|
4
|
+
|
5
|
+
|
6
|
+
class Membership
|
7
|
+
|
8
|
+
# Membership
|
9
|
+
# In order to give information about files in relation to
|
10
|
+
# the sequence signature, we need to collect all the filenames
|
11
|
+
# the user asks for, and all the filenames on disk that
|
12
|
+
# match the sequence mask (the filename part of the sequence signature).
|
13
|
+
# Thewn we can determine whether the file was (found), (misssing),
|
14
|
+
# or (extra) i.e. existing but not asked for
|
15
|
+
#
|
16
|
+
# Each element in the array returned by this method is a
|
17
|
+
# 3 element sub array that has the following form:
|
18
|
+
# ["filename", <:found|:missing|:extra>, frame_number]
|
19
|
+
#
|
20
|
+
# It is sorted on the frame_number, which is an integer
|
21
|
+
def initialize(fn_template, options={} )
|
22
|
+
|
23
|
+
@fn_template = fn_template
|
24
|
+
@fn_matrix = Dir.glob(fn_template.gsub(/#+/, "*")).to_a
|
25
|
+
prefix, suffix = @fn_template.split(/#+/)
|
26
|
+
|
27
|
+
@fn_matrix.collect! { |f| [f, :extra] }
|
28
|
+
|
29
|
+
range = options[:start_frame]..options[:end_frame]
|
30
|
+
range.step(options[:by_frame] || 1) do |val|
|
31
|
+
|
32
|
+
filename = fn_template.gsub(/#+/, ("%0#{options[:padding]}d" % val) )
|
33
|
+
|
34
|
+
el = @fn_matrix.find {|e| e[0] == filename }
|
35
|
+
if el.nil?
|
36
|
+
@fn_matrix << [filename , :missing ]
|
37
|
+
else
|
38
|
+
el[1] = :found
|
39
|
+
end
|
40
|
+
end
|
41
|
+
@fn_matrix.each { |el| el[2] = el[0].sub(prefix,'').sub(suffix,'').to_i }
|
42
|
+
@fn_matrix.sort! { |a,b| a[2] <=> b[2] }
|
43
|
+
end
|
44
|
+
|
45
|
+
# generate a message from the filename matrix - something like this:
|
46
|
+
# ../test/a/006_057_s3d_lf_r06.#.dpx [found: 13] [missing: 3] [extra: 17]
|
47
|
+
# where the found missing and extra sections are color coded according
|
48
|
+
# to whether there were files present with that attribute
|
49
|
+
def assessment_message
|
50
|
+
message = @fn_template
|
51
|
+
[:found, :missing, :extra].each do |t|
|
52
|
+
num_present = @fn_matrix.count { |el| el[1] == t }
|
53
|
+
m = "[#{t.to_s}: #{num_present}]"
|
54
|
+
m = (num_present > 0) ? m.send(Sequin::Membership.alert_colors[t][:present]) : m.send(Sequin::Membership.alert_colors[t][:absent])
|
55
|
+
message = "#{message} #{m}"
|
56
|
+
end
|
57
|
+
message
|
58
|
+
end
|
59
|
+
|
60
|
+
# output a brief assessment message and optionally list files
|
61
|
+
# that are found, missing, and/or extra
|
62
|
+
def output(options={})
|
63
|
+
puts "#{assessment_message}"
|
64
|
+
@fn_matrix.each do |el|
|
65
|
+
key = "show_#{el[1].to_s}"
|
66
|
+
puts el[0].send(Sequin::Membership.alert_colors[el[1]][:present]) unless options[key].nil? && options["show_all"].nil?
|
67
|
+
end
|
68
|
+
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def self.alert_colors
|
74
|
+
{
|
75
|
+
:found => {:present => "green", :absent => "red"} ,
|
76
|
+
:missing => {:present =>"red", :absent => "green"} ,
|
77
|
+
:extra => {:present =>"yellow", :absent => "green"}
|
78
|
+
}
|
79
|
+
end
|
80
|
+
|
81
|
+
end
|
82
|
+
|
83
|
+
end
|
data/lib/sequin.rb
ADDED
@@ -0,0 +1,258 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
require 'pp'
|
3
|
+
require "thor"
|
4
|
+
require 'fileutils'
|
5
|
+
require 'yaml'
|
6
|
+
require File.expand_path(File.dirname(__FILE__) + '/listing')
|
7
|
+
require File.expand_path(File.dirname(__FILE__) + '/membership')
|
8
|
+
|
9
|
+
module Sequin
|
10
|
+
|
11
|
+
class Seq < Thor
|
12
|
+
|
13
|
+
|
14
|
+
no_tasks do
|
15
|
+
# Some typical regular expressions for use by seq:match.
|
16
|
+
# When seq:generate config is called, this hash is dumped to a yaml file
|
17
|
+
def regex_hash
|
18
|
+
h =
|
19
|
+
{'project1' =>
|
20
|
+
{'dpx' => '^[0-9]{3}_[0-9]{3}_s3d_(rt|lf)_r[0-9]{2}\.[0-9]{4}\.dpx$',
|
21
|
+
'jpg' => '^[0-9]{3}_[0-9]{3}_s3d_r[0-9]{2}\.[0-9]{4}\.jpg$',
|
22
|
+
'mcx' => '^[0-9]{3}_[0-9]{3}_ver[0-9]{2,3}\.[0-9]{4}\.xml$'},
|
23
|
+
'project2' =>
|
24
|
+
{ 'dpx' => '^[a-z]{2}_[0-9]{3}_[0-9]{4}_(L|R|C)\.[0-9]{4,5}\.dpx$',
|
25
|
+
'jpg' => '^[a-z]{2}_[0-9]{3}_[0-9]{4}_(L|R|C)\.[0-9]{4,5}\.jpg$',
|
26
|
+
'mov' => '^[a-z]{2}_h264_[0-9]{3,4}x[0-9]{3,4}_(film|pal|ntsc)\.mov$' }
|
27
|
+
}
|
28
|
+
end
|
29
|
+
|
30
|
+
def regex_comments
|
31
|
+
<<-COMMENTS
|
32
|
+
#------------------------------------------------------------------------------------
|
33
|
+
#
|
34
|
+
# ~/.seq.yml
|
35
|
+
#
|
36
|
+
# The regular expressions in this file represent naming conventions for various
|
37
|
+
# projects and file_types. By matching filenames against these regexes, you can
|
38
|
+
# quickly determine if they conform to the desired naming convention.
|
39
|
+
#
|
40
|
+
# The regexes are accessed on the command line using the hash keys separated by
|
41
|
+
# ":". i.e. seq match <files> --regex project1:dpx
|
42
|
+
#
|
43
|
+
# For regex newbies, here are some disections:
|
44
|
+
# ^[a-z]+_[0-9]{3}_s3d_(rt|lf)_r[0,1][0-9]\.[0-9]{4}\.dpx$
|
45
|
+
# ^ : the next character must be at the start of the string
|
46
|
+
# [a-z]+_ : one or more lower case letters followed by an underscore
|
47
|
+
# [0-9]{3}_ : exactly 3 numbers
|
48
|
+
# _s3d_ : "_s3d_"
|
49
|
+
# (rt|lf) : "rt" or "lf"
|
50
|
+
# r[0,1][0-9] : "r" followed by a 2 digit number between 00 and 19
|
51
|
+
# \. : "." It must be escaped
|
52
|
+
# [0-9]{4} : 4 padded number
|
53
|
+
# \.dpx : ".dpx" extension.
|
54
|
+
# $ : The preceding character must be the last character. "x" in this case.
|
55
|
+
#------------------------------------------------------------------------------------
|
56
|
+
|
57
|
+
COMMENTS
|
58
|
+
end
|
59
|
+
|
60
|
+
end
|
61
|
+
|
62
|
+
|
63
|
+
# When the user types seq and gives no task, we want to show
|
64
|
+
# the default help with our own mods added.
|
65
|
+
default_task :usage
|
66
|
+
|
67
|
+
desc "usage", "Print the usage banner"
|
68
|
+
def usage
|
69
|
+
help # default Task descriptions
|
70
|
+
|
71
|
+
puts <<-BANNER
|
72
|
+
------------------------------------------------------------------------------------
|
73
|
+
Seq is a collection of commands to help deal with sequences on disk
|
74
|
+
|
75
|
+
More help here
|
76
|
+
|
77
|
+
BANNER
|
78
|
+
|
79
|
+
end
|
80
|
+
|
81
|
+
|
82
|
+
|
83
|
+
|
84
|
+
|
85
|
+
|
86
|
+
|
87
|
+
# map "i" => :inspect
|
88
|
+
# desc "inspect [FILES]", "inspect raw sequence hash for debugging purposes"
|
89
|
+
# def inspect(*files)
|
90
|
+
# files = Dir.new(".").entries if files.count == 0
|
91
|
+
# l = Sequin::Listing.new(files)
|
92
|
+
# l.inspect
|
93
|
+
# end
|
94
|
+
|
95
|
+
|
96
|
+
map "l" => :list
|
97
|
+
desc "list [FILES]", "list files as sequences"
|
98
|
+
method_option :verbose,:type => :numeric, :default => 1, :aliases => "-v", :desc => "0 => dont mention missing frames, 1 => show missing frame count, 2 => list missing frame numbers"
|
99
|
+
def list(*files)
|
100
|
+
unless files.count == 0
|
101
|
+
l = Sequin::Listing.new(files)
|
102
|
+
l.list(options[:verbose])
|
103
|
+
else
|
104
|
+
msg = "No files."
|
105
|
+
puts msg.red
|
106
|
+
help :list
|
107
|
+
return
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
|
112
|
+
|
113
|
+
|
114
|
+
map "g" => :generate
|
115
|
+
desc "generate [NAME]", "Generate a seq config file containing regular expression definitions used in the seq:match command. Currently, NAME must be config"
|
116
|
+
method_option :force, :type => :boolean, :aliases => "-f", :desc => "Overwrite the file if it exists.", :default => false
|
117
|
+
|
118
|
+
def generate(name)
|
119
|
+
if name.downcase == "config"
|
120
|
+
yml = File.join(ENV['HOME'],".seq.yml")
|
121
|
+
FileUtils.rm(yml) if options[:force] && File.exists?(yml)
|
122
|
+
if File.exists?(yml)
|
123
|
+
msg = "Skipping write of #{yml} because it exists. Use --force to remove it."
|
124
|
+
puts msg.red
|
125
|
+
help :generate
|
126
|
+
return
|
127
|
+
else
|
128
|
+
puts "Generating #{yml}"
|
129
|
+
File.open(yml , 'w') do |f|
|
130
|
+
f.write(regex_comments)
|
131
|
+
YAML.dump(regex_hash, f)
|
132
|
+
end
|
133
|
+
end
|
134
|
+
else
|
135
|
+
msg = "Error: #{name} is not recognized."
|
136
|
+
puts msg.red
|
137
|
+
help :generate
|
138
|
+
return
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
map "c" => :check
|
143
|
+
desc "check [SIGNATURE]", "Check the existence of files defined by a sequence signature."
|
144
|
+
method_option :show_missing, :type => :boolean, :aliases => "-m", :desc => "List files from the signature that are missing on disk"
|
145
|
+
method_option :show_extra, :type => :boolean, :aliases => "-e", :desc => "List files that are on disk but not in the signature"
|
146
|
+
method_option :show_found, :type => :boolean, :aliases => "-f", :desc => "List files from the signature that are on disk"
|
147
|
+
method_option :show_all, :type => :boolean, :aliases => "-a", :desc => "List the union of all files on disk and in the signature."
|
148
|
+
def check(*args)
|
149
|
+
signature = args.join(" ")
|
150
|
+
|
151
|
+
chunks = signature.split(/\[|\]/)
|
152
|
+
if chunks.count < 2
|
153
|
+
display_str = (signature.length > 60) ? "#{signature[0, 60]}..." : signature
|
154
|
+
msg = "Error: #{display_str} is not a valid sequence signature."
|
155
|
+
puts msg.red
|
156
|
+
help :check
|
157
|
+
return
|
158
|
+
end
|
159
|
+
|
160
|
+
fn_template = chunks[0].strip
|
161
|
+
numbers = chunks[1].split(/([0-9]+)/)
|
162
|
+
|
163
|
+
if numbers.count < 4
|
164
|
+
display_str = (signature.length > 60) ? "#{signature[0, 60]}..." : signature
|
165
|
+
msg = "Error: #{display_str} is not a valid sequence signature. Should have [<start> to <end> [by <by_frame>]]. "
|
166
|
+
puts msg.red
|
167
|
+
help :check
|
168
|
+
return
|
169
|
+
end
|
170
|
+
|
171
|
+
|
172
|
+
match = /#+/.match(fn_template)
|
173
|
+
if match.nil?
|
174
|
+
msg = "Error: #{fn_template} has no # characters so does not represent a sequence."
|
175
|
+
puts msg.red
|
176
|
+
help :check
|
177
|
+
return
|
178
|
+
end
|
179
|
+
|
180
|
+
range_spec=Hash.new
|
181
|
+
range_spec[:padding] = match[0].length
|
182
|
+
range_spec[:start_frame] = numbers[1].to_i
|
183
|
+
range_spec[:end_frame] = numbers[3].to_i
|
184
|
+
range_spec[:by_frame] = numbers[5].to_i || 1
|
185
|
+
|
186
|
+
if range_spec[:start_frame].nil? || range_spec[:end_frame].nil? || ( range_spec[:start_frame] >= range_spec[:end_frame] )
|
187
|
+
msg = "Error: start_frame #{start_frame} must be less than end_frame #{end_frame}."
|
188
|
+
puts msg.red
|
189
|
+
help :check
|
190
|
+
return
|
191
|
+
end
|
192
|
+
|
193
|
+
# range_spec.merge!(options)
|
194
|
+
membership =Sequin::Membership.new(fn_template, range_spec)
|
195
|
+
|
196
|
+
membership.output(options)
|
197
|
+
|
198
|
+
end
|
199
|
+
|
200
|
+
|
201
|
+
|
202
|
+
|
203
|
+
map "m" => :match
|
204
|
+
desc "match [FILES]", "Match filename naming conventions using a regular expression."
|
205
|
+
method_option :regex, :type => :string, :aliases => "-r", :required => true, :desc => "Specify a regular expression literally, or by reference to one listed in ~/.seq.yml"
|
206
|
+
method_option :show_matched, :type => :boolean, :aliases => "-m", :desc => "List files that matched the regex in green"
|
207
|
+
method_option :show_failed, :type => :boolean, :aliases => "-f", :desc => "List files that failed to match the regex in red"
|
208
|
+
method_option :show_all, :type => :boolean, :aliases => "-a", :desc => "List all the files in order with color coding."
|
209
|
+
def match(*files)
|
210
|
+
keys = options[:regex].split(":")
|
211
|
+
if keys.count != 2
|
212
|
+
the_regex = options[:regex]
|
213
|
+
else
|
214
|
+
|
215
|
+
begin
|
216
|
+
yml = File.join(ENV['HOME'],".seq.yml")
|
217
|
+
regexp_hash = YAML::load_file(yml)
|
218
|
+
rescue
|
219
|
+
msg = "Error: opening file #{yml} - please check the file exists and is valid YAML.\nIf it doesn't exist, run: seq generate config."
|
220
|
+
puts msg.red
|
221
|
+
help :match
|
222
|
+
return
|
223
|
+
end
|
224
|
+
|
225
|
+
unless regexp_hash.has_key?(keys[0])
|
226
|
+
msg = "Error: #{keys[0]} not in #{yml} - please check the file is valid YAML and has a top level key: #{keys[0]}."
|
227
|
+
puts msg.red
|
228
|
+
help :match
|
229
|
+
return
|
230
|
+
end
|
231
|
+
|
232
|
+
unless regexp_hash[keys[0]].has_key?(keys[1])
|
233
|
+
msg = "Error: #{keys[1]} not in #{yml} - please check the file is valid YAML and has a second level key: #{keys[1]}."
|
234
|
+
puts msg.red
|
235
|
+
help :match
|
236
|
+
return
|
237
|
+
end
|
238
|
+
|
239
|
+
the_regex = regexp_hash[keys[0]][keys[1]]
|
240
|
+
|
241
|
+
end
|
242
|
+
|
243
|
+
unless the_regex.nil?
|
244
|
+
l = Sequin::Listing.new(files)
|
245
|
+
l.match(the_regex, options)
|
246
|
+
else
|
247
|
+
msg = "Error: No valid regular expression."
|
248
|
+
puts msg.red
|
249
|
+
help :match
|
250
|
+
return
|
251
|
+
end
|
252
|
+
|
253
|
+
end
|
254
|
+
|
255
|
+
end
|
256
|
+
|
257
|
+
|
258
|
+
end
|
data/sequin.gemspec
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "sequin/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "sequin"
|
7
|
+
s.version = Sequin::VERSION
|
8
|
+
s.platform = Gem::Platform::RUBY
|
9
|
+
s.authors = ["Julian Mann"]
|
10
|
+
s.email = ["julian.mann@gmail.com"]
|
11
|
+
s.homepage = ""
|
12
|
+
s.summary = %q{Tools to deal with file sequences.}
|
13
|
+
s.description = %q{Sequin is a collection of commands to list, test and modify sequences of files on disk.}
|
14
|
+
s.add_dependency('thor')
|
15
|
+
s.rubyforge_project = "sequin"
|
16
|
+
|
17
|
+
s.files = `git ls-files`.split("\n")
|
18
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
19
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
20
|
+
s.require_paths = ["lib"]
|
21
|
+
end
|
metadata
ADDED
@@ -0,0 +1,76 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: sequin
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
prerelease:
|
5
|
+
version: 0.0.1
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Julian Mann
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
|
13
|
+
date: 2011-04-21 00:00:00 Z
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: thor
|
17
|
+
prerelease: false
|
18
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
19
|
+
none: false
|
20
|
+
requirements:
|
21
|
+
- - ">="
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: "0"
|
24
|
+
type: :runtime
|
25
|
+
version_requirements: *id001
|
26
|
+
description: Sequin is a collection of commands to list, test and modify sequences of files on disk.
|
27
|
+
email:
|
28
|
+
- julian.mann@gmail.com
|
29
|
+
executables:
|
30
|
+
- seq
|
31
|
+
extensions: []
|
32
|
+
|
33
|
+
extra_rdoc_files: []
|
34
|
+
|
35
|
+
files:
|
36
|
+
- .gitignore
|
37
|
+
- CHANGELOG
|
38
|
+
- Gemfile
|
39
|
+
- LICENSE
|
40
|
+
- README.md
|
41
|
+
- Rakefile
|
42
|
+
- bin/seq
|
43
|
+
- lib/listing.rb
|
44
|
+
- lib/membership.rb
|
45
|
+
- lib/sequin.rb
|
46
|
+
- lib/sequin/version.rb
|
47
|
+
- sequin.gemspec
|
48
|
+
homepage: ""
|
49
|
+
licenses: []
|
50
|
+
|
51
|
+
post_install_message:
|
52
|
+
rdoc_options: []
|
53
|
+
|
54
|
+
require_paths:
|
55
|
+
- lib
|
56
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: "0"
|
62
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
63
|
+
none: false
|
64
|
+
requirements:
|
65
|
+
- - ">="
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: "0"
|
68
|
+
requirements: []
|
69
|
+
|
70
|
+
rubyforge_project: sequin
|
71
|
+
rubygems_version: 1.7.2
|
72
|
+
signing_key:
|
73
|
+
specification_version: 3
|
74
|
+
summary: Tools to deal with file sequences.
|
75
|
+
test_files: []
|
76
|
+
|