sqlui 0.1.18 → 0.1.20
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.
- checksums.yaml +4 -4
- data/.version +1 -1
- data/app/args.rb +33 -0
- data/app/database_config.rb +30 -0
- data/app/database_metadata.rb +116 -0
- data/app/deep.rb +58 -0
- data/app/mysql_types.rb +33 -0
- data/app/server.rb +151 -74
- data/app/sql_parser.rb +17 -0
- data/app/sqlui.rb +20 -214
- data/app/sqlui_config.rb +44 -0
- data/app/views/databases.erb +8 -8
- data/bin/sqlui +3 -1
- data/client/resources/sqlui.css +263 -242
- data/client/resources/sqlui.html +8 -5
- data/client/resources/sqlui.js +166 -81
- metadata +9 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f41becc73029aa0f4e09476edbe96d273ed59b6dfd778eed97728c9d8b957e8e
|
4
|
+
data.tar.gz: 845897a0a4ab1a39e2ee2e6b8e5688b2548c0f30a463ef48171bd4953c138c01
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 85a81eeb433be3d01e0ecbacc990515249cc620c50cb852639a15a35849d2a69461c5c975140c1cb3ba743ac5f719756c0d1c7c44eed6e869f368cd32fd7386c
|
7
|
+
data.tar.gz: 9ad8b11e30e9ef47689eb01a7eb961fe409a7a5b10b06f32cdab6ce16a1ac43adaf91ab5a2643f51095d50262309f67d1af09cf8b304d532668380105c65c1dc
|
data/.version
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.1.
|
1
|
+
0.1.20
|
data/app/args.rb
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Argument validation.
|
4
|
+
class Args
|
5
|
+
def self.fetch_non_empty_string(hash, key)
|
6
|
+
value = fetch_non_nil(hash, key, String)
|
7
|
+
raise ArgumentError, "required parameter #{key} empty" if value.strip.empty?
|
8
|
+
|
9
|
+
value
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.fetch_non_empty_hash(hash, key)
|
13
|
+
value = fetch_non_nil(hash, key, Hash)
|
14
|
+
raise ArgumentError, "required parameter #{key} empty" if value.empty?
|
15
|
+
|
16
|
+
value
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.fetch_non_nil(hash, key, *classes)
|
20
|
+
raise ArgumentError, "required parameter #{key} missing" unless hash.key?(key)
|
21
|
+
|
22
|
+
value = hash[key]
|
23
|
+
raise ArgumentError, "required parameter #{key} null" if value.nil?
|
24
|
+
|
25
|
+
if classes.size.positive? && !classes.find { |clazz| value.is_a?(clazz) }
|
26
|
+
raise ArgumentError, "required parameter #{key} not a #{classes[0].to_s.downcase}" if classes.size == 1
|
27
|
+
|
28
|
+
raise ArgumentError, "required parameter #{key} not #{classes.map(&:to_s).map(&:downcase).join(' or ')}"
|
29
|
+
end
|
30
|
+
|
31
|
+
value
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'mysql2'
|
4
|
+
require_relative 'args'
|
5
|
+
|
6
|
+
# Config for a single database.
|
7
|
+
class DatabaseConfig
|
8
|
+
attr_reader :display_name, :description, :url_path, :saved_path, :client_params
|
9
|
+
|
10
|
+
def initialize(hash)
|
11
|
+
@display_name = Args.fetch_non_empty_string(hash, :display_name).strip
|
12
|
+
@description = Args.fetch_non_empty_string(hash, :description).strip
|
13
|
+
@url_path = Args.fetch_non_empty_string(hash, :url_path).strip
|
14
|
+
raise ArgumentError, 'url_path should start with a /' unless @url_path.start_with?('/')
|
15
|
+
raise ArgumentError, 'url_path should not end with a /' if @url_path.length > 1 && @url_path.end_with?('/')
|
16
|
+
|
17
|
+
@saved_path = Args.fetch_non_empty_string(hash, :saved_path).strip
|
18
|
+
@client_params = Args.fetch_non_empty_hash(hash, :client_params)
|
19
|
+
end
|
20
|
+
|
21
|
+
def with_client(&block)
|
22
|
+
client = Mysql2::Client.new(@client_params)
|
23
|
+
result = block.call(client)
|
24
|
+
client.close
|
25
|
+
client = nil
|
26
|
+
result
|
27
|
+
ensure
|
28
|
+
client&.close
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Loads metadata from a database.
|
4
|
+
class DatabaseMetadata
|
5
|
+
def initialize
|
6
|
+
raise "can't instantiate #{self.class}"
|
7
|
+
end
|
8
|
+
|
9
|
+
class << self
|
10
|
+
def lookup(client, database_config)
|
11
|
+
result = load_columns(client, database_config)
|
12
|
+
load_stats(result, client, database_config)
|
13
|
+
|
14
|
+
result
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def load_columns(client, database_config)
|
20
|
+
result = {}
|
21
|
+
database = database_config.client_params[:database]
|
22
|
+
where_clause = if database
|
23
|
+
"where table_schema = '#{database}'"
|
24
|
+
else
|
25
|
+
"where table_schema not in('mysql', 'sys', 'information_schema', 'performance_schema')"
|
26
|
+
end
|
27
|
+
column_result = client.query(
|
28
|
+
<<~SQL
|
29
|
+
select
|
30
|
+
table_schema,
|
31
|
+
table_name,
|
32
|
+
column_name,
|
33
|
+
data_type,
|
34
|
+
character_maximum_length,
|
35
|
+
is_nullable,
|
36
|
+
column_key,
|
37
|
+
column_default,
|
38
|
+
extra
|
39
|
+
from information_schema.columns
|
40
|
+
#{where_clause}
|
41
|
+
order by table_schema, table_name, column_name, ordinal_position;
|
42
|
+
SQL
|
43
|
+
)
|
44
|
+
column_result.each do |row|
|
45
|
+
row = row.transform_keys(&:downcase).transform_keys(&:to_sym)
|
46
|
+
table_schema = row[:table_schema]
|
47
|
+
unless result[table_schema]
|
48
|
+
result[table_schema] = {
|
49
|
+
tables: {}
|
50
|
+
}
|
51
|
+
end
|
52
|
+
table_name = row[:table_name]
|
53
|
+
tables = result[table_schema][:tables]
|
54
|
+
unless tables[table_name]
|
55
|
+
tables[table_name] = {
|
56
|
+
indexes: {},
|
57
|
+
columns: {}
|
58
|
+
}
|
59
|
+
end
|
60
|
+
columns = result[table_schema][:tables][table_name][:columns]
|
61
|
+
column_name = row[:column_name]
|
62
|
+
columns[column_name] = {} unless columns[column_name]
|
63
|
+
column = columns[column_name]
|
64
|
+
column[:name] = column_name
|
65
|
+
column[:data_type] = row[:data_type]
|
66
|
+
column[:length] = row[:character_maximum_length]
|
67
|
+
column[:allow_null] = row[:is_nullable]
|
68
|
+
column[:key] = row[:column_key]
|
69
|
+
column[:default] = row[:column_default]
|
70
|
+
column[:extra] = row[:extra]
|
71
|
+
end
|
72
|
+
result
|
73
|
+
end
|
74
|
+
|
75
|
+
def load_stats(result, client, database_config)
|
76
|
+
database = database_config.client_params[:database]
|
77
|
+
|
78
|
+
where_clause = if database
|
79
|
+
"where table_schema = '#{database}'"
|
80
|
+
else
|
81
|
+
"where table_schema not in('mysql', 'sys', 'information_schema', 'performance_schema')"
|
82
|
+
end
|
83
|
+
stats_result = client.query(
|
84
|
+
<<~SQL
|
85
|
+
select
|
86
|
+
table_schema,
|
87
|
+
table_name,
|
88
|
+
index_name,
|
89
|
+
seq_in_index,
|
90
|
+
non_unique,
|
91
|
+
column_name
|
92
|
+
from information_schema.statistics
|
93
|
+
#{where_clause}
|
94
|
+
order by table_schema, table_name, if(index_name = "PRIMARY", 0, index_name), seq_in_index;
|
95
|
+
SQL
|
96
|
+
)
|
97
|
+
stats_result.each do |row|
|
98
|
+
row = row.transform_keys(&:downcase).transform_keys(&:to_sym)
|
99
|
+
table_schema = row[:table_schema]
|
100
|
+
tables = result[table_schema][:tables]
|
101
|
+
table_name = row[:table_name]
|
102
|
+
indexes = tables[table_name][:indexes]
|
103
|
+
index_name = row[:index_name]
|
104
|
+
indexes[index_name] = {} unless indexes[index_name]
|
105
|
+
index = indexes[index_name]
|
106
|
+
column_name = row[:column_name]
|
107
|
+
index[column_name] = {}
|
108
|
+
column = index[column_name]
|
109
|
+
column[:name] = index_name
|
110
|
+
column[:seq_in_index] = row[:seq_in_index]
|
111
|
+
column[:non_unique] = row[:non_unique]
|
112
|
+
column[:column_name] = row[:column_name]
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
data/app/deep.rb
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Deep extensions for Enumerable.
|
4
|
+
module Enumerable
|
5
|
+
def deep_transform_keys!(&block)
|
6
|
+
each { |value| value.deep_transform_keys!(&block) if value.respond_to?(:deep_transform_keys!) }
|
7
|
+
self
|
8
|
+
end
|
9
|
+
|
10
|
+
def deep_dup(result = {})
|
11
|
+
map do |value|
|
12
|
+
value.respond_to?(:deep_dup) ? value.deep_dup : value.clone
|
13
|
+
end
|
14
|
+
result
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
# Deep extensions for Hash.
|
19
|
+
class Hash
|
20
|
+
def deep_transform_keys!(&block)
|
21
|
+
transform_keys!(&:to_s)
|
22
|
+
each_value { |value| value.deep_transform_keys!(&block) if value.respond_to?(:deep_transform_keys!) }
|
23
|
+
self
|
24
|
+
end
|
25
|
+
|
26
|
+
def deep_dup(result = {})
|
27
|
+
each do |key, value|
|
28
|
+
result[key] = value.respond_to?(:deep_dup) ? value.deep_dup : value.clone
|
29
|
+
end
|
30
|
+
result
|
31
|
+
end
|
32
|
+
|
33
|
+
def deep_set(*path, value:)
|
34
|
+
raise ArgumentError, 'no path specified' if path.empty?
|
35
|
+
|
36
|
+
if path.size == 1
|
37
|
+
self[path[0]] = value
|
38
|
+
else
|
39
|
+
raise KeyError, "key not found: #{path[0]}" unless key?(path[0])
|
40
|
+
raise ArgumentError, "value for key is not a hash: #{path[0]}" unless self.[](path[0]).is_a?(Hash)
|
41
|
+
|
42
|
+
self.[](path[0]).deep_set(*path[1..], value: value)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def deep_delete(*path)
|
47
|
+
raise ArgumentError, 'no path specified' if path.empty?
|
48
|
+
raise KeyError, "key not found: #{path[0]}" unless key?(path[0])
|
49
|
+
|
50
|
+
if path.size == 1
|
51
|
+
delete(path[0])
|
52
|
+
else
|
53
|
+
raise ArgumentError, "value for key is not a hash: #{path[0]}" unless self.[](path[0]).is_a?(Hash)
|
54
|
+
|
55
|
+
self.[](path[0]).deep_delete(*path[1..])
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
data/app/mysql_types.rb
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Maps MySQL data types to type descriptions the client can use.
|
4
|
+
class MysqlTypes
|
5
|
+
def initialize
|
6
|
+
raise "can't instantiate #{self.class}"
|
7
|
+
end
|
8
|
+
|
9
|
+
class << self
|
10
|
+
def map_to_google_charts_types(types)
|
11
|
+
types.map do |type|
|
12
|
+
type.gsub!(/\([^)]*\)/, '').strip!
|
13
|
+
case type
|
14
|
+
when 'bool', 'boolean'
|
15
|
+
'boolean'
|
16
|
+
when /double/, 'bigint', 'bit', 'dec', 'decimal', 'float', 'int', 'integer', 'mediumint', 'smallint', 'tinyint'
|
17
|
+
'number'
|
18
|
+
when /char/, /binary/, /blob/, /text/, 'enum', 'set', /image/
|
19
|
+
'string'
|
20
|
+
when 'date', 'year'
|
21
|
+
'date'
|
22
|
+
when 'datetime', 'timestamp'
|
23
|
+
'datetime'
|
24
|
+
when 'time'
|
25
|
+
'timeofday'
|
26
|
+
else
|
27
|
+
puts "unexpected MySQL type: #{type}"
|
28
|
+
'string'
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
data/app/server.rb
CHANGED
@@ -2,95 +2,172 @@
|
|
2
2
|
|
3
3
|
require 'erb'
|
4
4
|
require 'json'
|
5
|
-
require 'mysql2'
|
6
5
|
require 'sinatra/base'
|
7
|
-
|
8
|
-
|
6
|
+
require 'uri'
|
7
|
+
require_relative 'database_metadata'
|
9
8
|
require_relative 'environment'
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
exit
|
14
|
-
end
|
15
|
-
|
16
|
-
raise 'you must specify a configuration file' unless ARGV.size == 1
|
17
|
-
raise 'configuration file does not exist' unless File.exist?(ARGV[0])
|
9
|
+
require_relative 'mysql_types'
|
10
|
+
require_relative 'sql_parser'
|
11
|
+
require_relative 'sqlui'
|
18
12
|
|
19
13
|
# SQLUI Sinatra server.
|
20
14
|
class Server < Sinatra::Base
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
15
|
+
def self.init_and_run(config, resources_dir)
|
16
|
+
set :logging, true
|
17
|
+
set :bind, '0.0.0.0'
|
18
|
+
set :port, Environment.server_port
|
19
|
+
set :env, Environment.server_env
|
20
|
+
set :raise_errors, false
|
21
|
+
set :show_exceptions, false
|
22
|
+
|
23
|
+
get '/-/health' do
|
24
|
+
status 200
|
25
|
+
body 'OK'
|
30
26
|
end
|
31
27
|
|
32
|
-
|
33
|
-
|
34
|
-
unless client
|
35
|
-
client = Mysql2::Client.new(@params)
|
36
|
-
Thread.current.thread_variable_set(:client, client)
|
37
|
-
end
|
38
|
-
client.query(sql)
|
28
|
+
get "#{config.list_url_path}/?" do
|
29
|
+
erb :databases, locals: { config: config }
|
39
30
|
end
|
40
|
-
end
|
41
31
|
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
host: database_config['db_host'],
|
47
|
-
port: database_config['db_port'] || 3306,
|
48
|
-
username: database_config['db_username'],
|
49
|
-
password: database_config['db_password'],
|
50
|
-
database: database_config['db_database'],
|
51
|
-
read_timeout: 10, # seconds
|
52
|
-
write_timeout: 0, # seconds
|
53
|
-
connect_timeout: 5 # seconds
|
54
|
-
}
|
55
|
-
client = Client.new(client_params)
|
56
|
-
[
|
57
|
-
database_config['url_path'],
|
58
|
-
::SQLUI.new(
|
59
|
-
client: client,
|
60
|
-
table_schema: database_config['db_database'],
|
61
|
-
name: database_config['name'],
|
62
|
-
saved_path: File.join(saved_path_root, database_config['saved_path'])
|
63
|
-
)
|
64
|
-
]
|
65
|
-
end
|
32
|
+
config.database_configs.each do |database|
|
33
|
+
get database.url_path.to_s do
|
34
|
+
redirect "#{database.url_path}/app", 301
|
35
|
+
end
|
66
36
|
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
37
|
+
get "#{database.url_path}/app" do
|
38
|
+
@html ||= File.read(File.join(resources_dir, 'sqlui.html'))
|
39
|
+
status 200
|
40
|
+
headers 'Content-Type': 'text/html'
|
41
|
+
body @html
|
42
|
+
end
|
71
43
|
|
72
|
-
|
73
|
-
|
74
|
-
|
44
|
+
get "#{database.url_path}/sqlui.css" do
|
45
|
+
@css ||= File.read(File.join(resources_dir, 'sqlui.css'))
|
46
|
+
status 200
|
47
|
+
headers 'Content-Type': 'text/css'
|
48
|
+
body @css
|
49
|
+
end
|
75
50
|
|
76
|
-
|
77
|
-
|
78
|
-
|
51
|
+
get "#{database.url_path}/sqlui.js" do
|
52
|
+
@js ||= File.read(File.join(resources_dir, 'sqlui.js'))
|
53
|
+
status 200
|
54
|
+
headers 'Content-Type': 'text/javascript'
|
55
|
+
body @js
|
56
|
+
end
|
57
|
+
|
58
|
+
get "#{database.url_path}/metadata" do
|
59
|
+
database_config = config.database_config_for(url_path: database.url_path)
|
60
|
+
metadata = database_config.with_client do |client|
|
61
|
+
{
|
62
|
+
server: config.name,
|
63
|
+
schemas: DatabaseMetadata.lookup(client, database_config),
|
64
|
+
saved: Dir.glob("#{database_config.saved_path}/*.sql").map do |path|
|
65
|
+
comment_lines = File.readlines(path).take_while do |l|
|
66
|
+
l.start_with?('--')
|
67
|
+
end
|
68
|
+
description = comment_lines.map { |l| l.sub(/^-- */, '') }.join
|
69
|
+
{
|
70
|
+
filename: File.basename(path),
|
71
|
+
description: description
|
72
|
+
}
|
73
|
+
end
|
74
|
+
}
|
75
|
+
end
|
76
|
+
status 200
|
77
|
+
headers 'Content-Type': 'application/json'
|
78
|
+
body metadata.to_json
|
79
|
+
end
|
79
80
|
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
81
|
+
get "#{database.url_path}/query_file" do
|
82
|
+
break client_error('missing file param') unless params[:file]
|
83
|
+
break client_error('no such file') unless File.exist?(params[:file])
|
84
|
+
|
85
|
+
database_config = config.database_config_for(url_path: database.url_path)
|
86
|
+
sql = File.read(File.join(database_config.saved_path, params[:file]))
|
87
|
+
result = database_config.with_client do |client|
|
88
|
+
execute_query(client, sql).tap { |r| r[:file] = params[:file] }
|
89
|
+
end
|
90
|
+
|
91
|
+
status 200
|
92
|
+
headers 'Content-Type': 'application/json'
|
93
|
+
body result.to_json
|
94
|
+
end
|
95
|
+
|
96
|
+
post "#{database.url_path}/query" do
|
97
|
+
params.merge!(JSON.parse(request.body.read, symbolize_names: true))
|
98
|
+
break client_error('missing sql') unless params[:sql]
|
99
|
+
|
100
|
+
sql = params[:sql]
|
101
|
+
selection = params[:selection]
|
102
|
+
if selection
|
103
|
+
selection = selection.split(':').map { |v| Integer(v) }
|
104
|
+
|
105
|
+
sql = if selection[0] == selection[1]
|
106
|
+
SqlParser.find_statement_at_cursor(params[:sql], selection[0])
|
107
|
+
else
|
108
|
+
params[:sql][selection[0], selection[1]]
|
109
|
+
end
|
110
|
+
break client_error("can't find query at selection") unless sql
|
111
|
+
end
|
112
|
+
|
113
|
+
database_config = config.database_config_for(url_path: database.url_path)
|
114
|
+
result = database_config.with_client do |client|
|
115
|
+
execute_query(client, sql)
|
116
|
+
end
|
117
|
+
|
118
|
+
result[:selection] = params[:selection]
|
119
|
+
|
120
|
+
status 200
|
121
|
+
headers 'Content-Type': 'application/json'
|
122
|
+
body result.to_json
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
error do |e|
|
127
|
+
status 500
|
128
|
+
headers 'Content-Type': 'application/json'
|
129
|
+
result = {
|
130
|
+
error: e.message,
|
131
|
+
stacktrace: e.backtrace.map { |b| b }.join("\n")
|
132
|
+
}
|
133
|
+
body result.to_json
|
134
|
+
end
|
135
|
+
|
136
|
+
run!
|
85
137
|
end
|
86
138
|
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
status
|
91
|
-
headers
|
92
|
-
body
|
139
|
+
private
|
140
|
+
|
141
|
+
def client_error(message, stacktrace: nil)
|
142
|
+
status(400)
|
143
|
+
headers('Content-Type': 'application/json')
|
144
|
+
body({ error: message, stacktrace: stacktrace }.compact.to_json)
|
93
145
|
end
|
94
146
|
|
95
|
-
|
147
|
+
def execute_query(client, sql)
|
148
|
+
if sql.include?(';')
|
149
|
+
results = sql.split(/(?<=;)/).map { |current| client.query(current) }
|
150
|
+
result = results[-1]
|
151
|
+
else
|
152
|
+
result = client.query(sql)
|
153
|
+
end
|
154
|
+
# NOTE: the call to result.field_types must go before any other interaction with the result. Otherwise you will
|
155
|
+
# get a seg fault. Seems to be a bug in Mysql2.
|
156
|
+
if result
|
157
|
+
column_types = MysqlTypes.map_to_google_charts_types(result.field_types)
|
158
|
+
rows = result.map(&:values)
|
159
|
+
columns = result.first&.keys || []
|
160
|
+
else
|
161
|
+
column_types = []
|
162
|
+
rows = []
|
163
|
+
columns = []
|
164
|
+
end
|
165
|
+
{
|
166
|
+
query: sql,
|
167
|
+
columns: columns,
|
168
|
+
column_types: column_types,
|
169
|
+
total_rows: rows.size,
|
170
|
+
rows: rows.take(Sqlui::MAX_ROWS)
|
171
|
+
}
|
172
|
+
end
|
96
173
|
end
|
data/app/sql_parser.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Used to parse strings containing one or more SQL statements.
|
4
|
+
class SqlParser
|
5
|
+
def self.find_statement_at_cursor(sql, cursor)
|
6
|
+
parts_with_ranges = []
|
7
|
+
sql.scan(/[^;]*;[ \n]*/) { |part| parts_with_ranges << [part, 0, part.size] }
|
8
|
+
parts_with_ranges.inject(0) do |pos, current|
|
9
|
+
current[1] += pos
|
10
|
+
current[2] += pos
|
11
|
+
end
|
12
|
+
part_with_range = parts_with_ranges.find do |current|
|
13
|
+
cursor >= current[1] && cursor < current[2]
|
14
|
+
end || parts_with_ranges[-1]
|
15
|
+
part_with_range[0].strip
|
16
|
+
end
|
17
|
+
end
|