Rdb: Ruby-Nachbau der MWC /rdb-Werkzeuge
This commit is contained in:
+42
@@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# A Ruby re-creation of /rdb, the relational database tools Mark Williams
|
||||
# Company sold for COHERENT (~1991).
|
||||
|
||||
require 'open3'
|
||||
require 'date'
|
||||
require 'tmpdir'
|
||||
require 'fileutils'
|
||||
|
||||
module Rdb
|
||||
ROOT = File.expand_path('..', __dir__)
|
||||
NUM = /\A\s*[+-]?(\d+\.?\d*|\.\d+)\s*\z/
|
||||
|
||||
Command = Struct.new(:name, :desc, :syntax, :block)
|
||||
COMMANDS = {}
|
||||
|
||||
class << self
|
||||
def die(msg)
|
||||
abort "#{File.basename($PROGRAM_NAME)}: #{msg}"
|
||||
end
|
||||
|
||||
# register 'name', 'description', 'syntax' do |args| ... end
|
||||
def register(name, desc, syntax = nil, &block)
|
||||
COMMANDS[name] = Command.new(name, desc, syntax || name, block)
|
||||
end
|
||||
|
||||
def alias_for(name, target)
|
||||
cmd = COMMANDS.fetch(target)
|
||||
COMMANDS[name] = Command.new(name, cmd.desc, cmd.syntax.sub(target, name), cmd.block)
|
||||
end
|
||||
|
||||
def run(name, args)
|
||||
cmd = COMMANDS[name] or die("unknown command '#{name}'")
|
||||
Env.new(name).instance_exec(args.dup, &cmd.block)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
require_relative 'rdb/table'
|
||||
require_relative 'rdb/env'
|
||||
Dir[File.join(__dir__, 'rdb/cmd/*.rb')].sort.each { |f| require f }
|
||||
@@ -0,0 +1,479 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Finding, adding, changing and locking records. Many of these take the name
|
||||
# of the table instead of reading it on stdin, because they change the file.
|
||||
|
||||
require_relative '../fast'
|
||||
|
||||
# Parse the common fast-access options. Stops at the first non-option, which
|
||||
# is left in args; returns the option hash.
|
||||
def fast_opts(args)
|
||||
o = { m: 's', h: false, l: false, s: false, x: false, exact: false, a: false, v: false, o: nil }
|
||||
until args.empty?
|
||||
a = args.first
|
||||
case a
|
||||
when /\A-m([bhirs]?)\z/ then o[:m] = Regexp.last_match(1).empty? ? 's' : Regexp.last_match(1)
|
||||
when '+x' then o[:exact] = true
|
||||
when '-o' then args.shift; o[:o] = args.first
|
||||
when /\A-[hslxav]+\z/ then a[1..].each_char { |c| o[c.to_sym] = true }
|
||||
else break
|
||||
end
|
||||
args.shift
|
||||
end
|
||||
o
|
||||
end
|
||||
|
||||
def need_index(table, method)
|
||||
suffix = Rdb::Fast::SUFFIX[method]
|
||||
return unless suffix && !File.exist?(table + suffix)
|
||||
|
||||
Rdb.die("no index #{table}#{suffix}: run index -m#{method} #{table}")
|
||||
end
|
||||
|
||||
def each_key(ncols)
|
||||
if $stdin.tty?
|
||||
$stdin.each_line { |l| yield l.chomp.split("\t", -1).first(ncols) }
|
||||
else
|
||||
Rdb::Fast.read_keys($stdin, ncols).each { |k| yield k }
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('index', 'Set up table for search', 'index [-m[bhirs]] [-x] [-l] tableorlist [keycol ...]') do |args|
|
||||
o = fast_opts(args)
|
||||
table = args.shift or die 'usage: index [-m[bhirs]] table [keycolumn]'
|
||||
data = Rdb::Fast::Data.new(table)
|
||||
key = args.first || data.head[0]
|
||||
data.column(key)
|
||||
Rdb::Fast.build(table, o[:m], key, fold: o[:x])
|
||||
if o[:l]
|
||||
Rdb::Fast::Data.new(table).rows.each { |r| warn "#{r.from} #{r.to} 0 0" }
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('search', 'Search a table',
|
||||
'search [-m[bhirs]] [-hsnx +x] [-l] tableorlist [keycolumn ...] [< keytable]') do |args|
|
||||
o = fast_opts(args)
|
||||
table = args.shift or die 'usage: search [-m[bhirs]] table [keycolumn ...] < keys'
|
||||
data = Rdb::Fast::Data.new(table)
|
||||
need_index(table, o[:m])
|
||||
keycols = args.empty? ? [data.head[0]] : args
|
||||
puts data.head.join("\t"), Rdb::Table.dash_row(data.head) unless o[:h]
|
||||
each_key(keycols.size) do |key|
|
||||
hits = Rdb::Fast.search(data, o[:m], keycols, key, first: o[:s] || o[:l], partial: o[:x], exact: o[:exact])
|
||||
hits.each do |h|
|
||||
puts h.row.line
|
||||
warn "#{h.row.from} #{h.row.to} #{h.xfrom} #{h.xto}" if o[:l]
|
||||
end
|
||||
$stdout.flush
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('seek', 'Return the beginning and ending offset of a row', 'seek [-m[bihrs]] [-o outfile] tableorlist [keycol...] < file') do |args|
|
||||
o = fast_opts(args)
|
||||
table = args.shift or die 'usage: seek [-m] [-o outfile] table [keycolumn ...]'
|
||||
data = Rdb::Fast::Data.new(table)
|
||||
need_index(table, o[:m])
|
||||
keycols = args.empty? ? [data.head[0]] : args
|
||||
each_key(keycols.size) do |key|
|
||||
Rdb::Fast.search(data, o[:m], keycols, key, first: true).each do |h|
|
||||
puts "#{h.row.from} #{h.row.to} #{h.xfrom} #{h.xto}"
|
||||
if o[:o]
|
||||
File.write(o[:o], data.head.join("\t") + "\n" + Rdb::Table.dash_row(data.head) + "\n" + h.row.line + "\n")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('append', 'Add a row to a table and update index tables', 'append [-h -m[bhirs]] table [keycolumn ...] < tableorrow') do |args|
|
||||
o = fast_opts(args)
|
||||
table = args.shift or die 'usage: append [-h] [-m] table [keycolumn] < tableorrow'
|
||||
data = Rdb::Fast::Data.new(table)
|
||||
need_index(table, o[:m])
|
||||
keycol = args.first || data.head[0]
|
||||
text = $stdin.read
|
||||
rows =
|
||||
if o[:h] then text.lines.map { |l| l.chomp("\n").split("\t", -1) }
|
||||
else Rdb::Table.parse(text).rows
|
||||
end
|
||||
rows.each { |cells| Rdb::Fast.append_row(table, cells, o[:m], keycol) }
|
||||
end
|
||||
|
||||
Rdb.register('delete', 'Blank record and update index file', 'delete [-m[bhirs]] table keycolumn ... < keytable') do |args|
|
||||
o = fast_opts(args)
|
||||
table = args.shift or die 'usage: delete [-m] table keycolumn ... < keytable'
|
||||
need_index(table, o[:m])
|
||||
keycols = args.empty? ? [Rdb::Fast::Data.new(table).head[0]] : args
|
||||
each_key(keycols.size) do |key|
|
||||
data = Rdb::Fast::Data.new(table)
|
||||
Rdb::Fast.search(data, o[:m], keycols, key).each { |h| Rdb::Fast.delete_hit(table, h, o[:m]) }
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('replace', 'Insert a record into a file at specified location',
|
||||
'replace [-a] [-m[bhirs]] tableorlist from to [xfrom xto] < intableorlist') do |args|
|
||||
o = fast_opts(args)
|
||||
table = args.shift or die 'usage: replace [-a] [-m] table from to [xfrom xto] < row'
|
||||
from, to, xfrom, xto = args.map(&:to_i)
|
||||
die 'usage: replace [-a] [-m] table from to [xfrom xto] < row' unless to
|
||||
|
||||
text = $stdin.read
|
||||
lines = text.lines.map { |l| l.chomp("\n") }
|
||||
lines = lines.drop(2) if lines.size >= 2 && Rdb::Table.dash_line?(lines[1].split("\t", -1))
|
||||
die 'replace: no input row' if lines.empty?
|
||||
|
||||
keycol = Rdb::Fast::Data.new(table).head[0]
|
||||
newfrom = Rdb::Fast.replace_row(table, from, to, lines.first.split("\t", -1), o[:m], xfrom.to_i, xto.to_i, keycol,
|
||||
allow_append: !o[:a])
|
||||
die 'replace: record too big for its slot (-a)' unless newfrom
|
||||
end
|
||||
|
||||
Rdb.register('enter', 'Add rows to a table or list file without an editor', 'enter [-limit] tableorlist') do |args|
|
||||
limit = args.first&.match?(/\A-\d+\z/) ? args.shift[1..].to_i : nil
|
||||
file = args.first or die 'usage: enter [-limit] tableorlist'
|
||||
t = Rdb::Table.open(file)
|
||||
count = 0
|
||||
loop do
|
||||
break if limit && count >= limit
|
||||
|
||||
vals = []
|
||||
i = 0
|
||||
eof = false
|
||||
while i < t.head.size
|
||||
$stdout.print "#{t.head[i]}: "
|
||||
$stdout.flush
|
||||
line = $stdin.gets
|
||||
if line.nil?
|
||||
eof = true
|
||||
break
|
||||
end
|
||||
line = line.chomp
|
||||
if line == '^'
|
||||
i = [i - 1, 0].max
|
||||
next
|
||||
end
|
||||
vals[i] = line
|
||||
i += 1
|
||||
end
|
||||
break if eof || vals.first.to_s.empty?
|
||||
|
||||
if t.list
|
||||
File.open(file, 'a') { |f| t.head.each_with_index { |h, j| f.puts "#{h}\t#{vals[j]}" }; f.puts }
|
||||
else
|
||||
Rdb::Fast.append_row(file, vals, 's', t.head[0])
|
||||
end
|
||||
count += 1
|
||||
end
|
||||
end
|
||||
|
||||
# ---- locks -------------------------------------------------------------------
|
||||
|
||||
def lock_file(name)
|
||||
File.join(Dir.tmpdir, "L#{File.basename(name)}")
|
||||
end
|
||||
|
||||
Rdb.register('lock', 'Lock a record or field of a file', 'lock filename processid from to indexfrom indexto') do |args|
|
||||
die 'usage: lock filename processid from to indexfrom indexto' if args.size < 4
|
||||
name, pid, from, to = args
|
||||
lf = lock_file(name)
|
||||
held = File.exist?(lf) ? File.readlines(lf, chomp: true).map { |l| l.split.map(&:to_i) } : []
|
||||
clash = held.find { |p, f, t| p != pid.to_i && f < to.to_i && from.to_i < t }
|
||||
if clash
|
||||
warn "lock: #{name}: bytes #{from}-#{to} are locked by process #{clash[0]}"
|
||||
exit 1
|
||||
end
|
||||
File.open(lf, 'a') { |f| f.puts args.drop(1).join(' ') }
|
||||
end
|
||||
|
||||
Rdb.register('unlock', 'Unlock a record or field of a file', 'unlock tableorlist processid from to indexfrom indexto') do |args|
|
||||
die 'usage: unlock filename processid from to indexfrom indexto' if args.size < 4
|
||||
name = args.shift
|
||||
lf = lock_file(name)
|
||||
next unless File.exist?(lf)
|
||||
|
||||
keep = File.readlines(lf, chomp: true).reject { |l| l.split.first(3) == args.first(3) }
|
||||
keep.empty? ? File.delete(lf) : File.write(lf, keep.join("\n") + "\n")
|
||||
end
|
||||
|
||||
Rdb.register('vilock', 'Lock a table before editing, unlock afterward', 'vilock tableorlist') do |args|
|
||||
file = args.first or die 'usage: vilock tableorlist'
|
||||
locked = File.join(File.dirname(file), "LOCK#{File.basename(file)}")
|
||||
die "#{file} is locked or missing" if File.exist?(locked) || !File.exist?(file)
|
||||
|
||||
File.rename(file, locked)
|
||||
begin
|
||||
system(ENV['EDITOR'] || 'vi', locked)
|
||||
ensure
|
||||
File.rename(locked, file)
|
||||
end
|
||||
end
|
||||
|
||||
# ---- interactive editing --------------------------------------------------------
|
||||
|
||||
def show_record(data_head, cells)
|
||||
data_head.each_with_index { |h, i| puts "#{i + 1} #{h}\t#{cells[i].to_s.strip}" }
|
||||
end
|
||||
|
||||
def edit_cells(head, cells)
|
||||
require 'tempfile'
|
||||
Tempfile.create('rdb-edit') do |f|
|
||||
f.write Rdb::Table.new(head, [cells], list: true).to_s
|
||||
f.flush
|
||||
system(ENV['EDITOR'] || 'vi', f.path)
|
||||
t = Rdb::Table.parse(File.read(f.path))
|
||||
return nil if t.rows.empty?
|
||||
|
||||
head.map { |h| (j = t.head.index(h)) ? t.rows[0][j] : '' }
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('update.rdb', 'Display and edit records in any sized file',
|
||||
'update.rdb [-l -m[bhirs] -v] tableorlist [keycolumn ... ]') do |args|
|
||||
o = fast_opts(args)
|
||||
table = args.shift or die 'usage: update.rdb [-l -m -v] table [keycolumn]'
|
||||
need_index(table, o[:m])
|
||||
keycols = args.empty? ? [Rdb::Fast::Data.new(table).head[0]] : args
|
||||
cur = nil
|
||||
help = lambda do
|
||||
puts ' <Return> or n next record p or - previous record',
|
||||
' number go to that record /pattern next record matching pattern',
|
||||
' e edit this record d delete this record',
|
||||
' anything else look up by key q or CTRL-d quit'
|
||||
end
|
||||
loop do
|
||||
data = Rdb::Fast::Data.new(table)
|
||||
rows = Rdb::Fast.live_rows(data)
|
||||
print '> '
|
||||
$stdout.flush
|
||||
line = $stdin.gets or break
|
||||
cmd = line.strip
|
||||
case cmd
|
||||
when '', /\An/i then cur = cur ? cur + 1 : 0
|
||||
when /\A-/, /\Ap/i then cur = [(cur || 1) - 1, 0].max
|
||||
when /\Aq/i then break
|
||||
when /\Ah/i then help.call; next
|
||||
when /\A\d+\z/ then cur = cmd.to_i - 1
|
||||
when %r{\A/(.*)}
|
||||
re = Regexp.new(Regexp.last_match(1), Regexp::IGNORECASE)
|
||||
found = rows.each_index.find { |i| i > (cur || -1) && rows[i].line.match?(re) }
|
||||
found ? cur = found : (puts 'not found'; next)
|
||||
when /\Ae/i
|
||||
next puts('no current record') unless cur && rows[cur]
|
||||
|
||||
r = rows[cur]
|
||||
cells = r.cells
|
||||
Rdb::Fast.splice(table, r.from, r.to, Rdb::Fast.blanked(r.line) + "\n") unless o[:l]
|
||||
new = edit_cells(data.head, cells.map(&:strip))
|
||||
if new
|
||||
hit = Rdb::Fast::Hit.new(r, 0, 0)
|
||||
xf, xt = 0, 0
|
||||
if Rdb::Fast::SUFFIX[o[:m]]
|
||||
h = Rdb::Fast.search(Rdb::Fast::Data.new(table), o[:m], keycols, cells.values_at(*keycols.map { |c| data.column(c) }), first: true).first
|
||||
xf, xt = h.xfrom, h.xto if h
|
||||
end
|
||||
Rdb::Fast.replace_row(table, hit.row.from, hit.row.to, new, o[:m], xf, xt, keycols[0])
|
||||
puts 'record replaced'
|
||||
else
|
||||
Rdb::Fast.splice(table, r.from, r.to, r.line + "\n")
|
||||
puts 'unchanged'
|
||||
end
|
||||
next
|
||||
when /\Ad/i
|
||||
next puts('no current record') unless cur && rows[cur]
|
||||
|
||||
h = Rdb::Fast::Hit.new(rows[cur], 0, 0)
|
||||
if Rdb::Fast::SUFFIX[o[:m]]
|
||||
h = Rdb::Fast.search(data, o[:m], keycols, rows[cur].cells.values_at(*keycols.map { |c| data.column(c) }), first: true).first || h
|
||||
end
|
||||
Rdb::Fast.delete_hit(table, h, o[:m])
|
||||
puts 'record deleted'
|
||||
next
|
||||
else
|
||||
key = cmd.split("\t", -1)
|
||||
hit = Rdb::Fast.search(data, o[:m], keycols, key, first: true).first
|
||||
next puts('not found') unless hit
|
||||
|
||||
cur = rows.index { |r| r.from == hit.row.from }
|
||||
end
|
||||
if cur && rows[cur]
|
||||
puts "-- record #{cur + 1} of #{rows.size}"
|
||||
show_record(data.head, rows[cur].cells)
|
||||
else
|
||||
puts 'no such record'
|
||||
cur = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('ve', 'Visually edit a table', 've [table [-n[n] -fc -mc -i -s [file] -h [file] -a [file] -v [file]]] [-b]') do |args|
|
||||
file = args.shift or die 'usage: ve table (a plain prompt-driven editor; see update.rdb for fast access)'
|
||||
auto = args.find { |a| a.match?(/\A-n\d*\z/) }
|
||||
unless File.exist?(file)
|
||||
head = Rdb::Table.new(%w[Item], []).head
|
||||
print 'Column names for the new table, tab separated: '
|
||||
head = ($stdin.gets || '').chomp.split("\t")
|
||||
File.write(file, Rdb::Table.new(head, []).to_s)
|
||||
end
|
||||
cur = 0
|
||||
loop do
|
||||
t = Rdb::Table.open(file)
|
||||
rows = t.rows
|
||||
puts "-- #{file}: record #{rows.empty? ? 0 : cur + 1} of #{rows.size}"
|
||||
show_record(t.head, rows[cur]) if rows[cur]
|
||||
print '[n]ext [p]rev [g]o N [e]dit [a]dd [d]elete [q]uit > '
|
||||
$stdout.flush
|
||||
line = $stdin.gets or break
|
||||
case line.strip
|
||||
when 'n', '' then cur = [cur + 1, rows.size - 1].min
|
||||
when 'p' then cur = [cur - 1, 0].max
|
||||
when /\Ag\s*(\d+)/ then cur = [[Regexp.last_match(1).to_i - 1, 0].max, rows.size - 1].min
|
||||
when 'e'
|
||||
next unless rows[cur]
|
||||
|
||||
puts 'field number, then the new value:'
|
||||
print 'field> '
|
||||
f = ($stdin.gets || '').to_i - 1
|
||||
next puts('no such field') unless (0...t.head.size).cover?(f)
|
||||
|
||||
print "#{t.head[f]}> "
|
||||
rows[cur][f] = ($stdin.gets || '').chomp
|
||||
File.write(file, t.to_s)
|
||||
when 'a'
|
||||
row = t.head.each_with_index.map do |h, i|
|
||||
if i.zero? && auto
|
||||
start = auto[2..].to_i
|
||||
next ([rows.map { |r| r[0].to_i }.max.to_i + 1, start].max).to_s
|
||||
end
|
||||
print "#{h}> "
|
||||
($stdin.gets || '').chomp
|
||||
end
|
||||
rows << row
|
||||
File.write(file, t.to_s)
|
||||
cur = rows.size - 1
|
||||
when 'd'
|
||||
rows.delete_at(cur) if rows[cur]
|
||||
File.write(file, t.to_s)
|
||||
cur = [cur, rows.size - 1].min
|
||||
cur = 0 if cur.negative?
|
||||
when 'q' then break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ---- checking data ------------------------------------------------------------
|
||||
|
||||
Rdb.register('domain', 'Display invalid values in a column', 'domain domaintable [string ...] [< one-column-table]') do |args|
|
||||
dom = args.shift or die 'usage: domain domaintable [string ...]'
|
||||
valid = open_table(dom).rows.map { |r| r[0].strip }.to_h { |v| [v, true] }
|
||||
if args.any?
|
||||
args.reject { |s| valid[s.strip] }.each { |s| puts s }
|
||||
else
|
||||
t = table_in
|
||||
t.with(rows: t.rows.reject { |r| valid[r[0].strip] }).write
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('validate', 'Find invalid data', "validate 'pattern { action } ...' < tableorlist") do |args|
|
||||
die "usage: validate 'pattern { action } ...' < table" unless args.size == 1
|
||||
t = table_in
|
||||
puts t.head.join("\t"), Rdb::Table.dash_row(t.head)
|
||||
prog = "BEGIN{OFS=\"\\t\"} #{to_awk(args[0], t.head)} {print}"
|
||||
out, ok = awk_text(prog, t.rows)
|
||||
$stdout.write out
|
||||
exit 1 unless ok
|
||||
end
|
||||
|
||||
Rdb.register('fd', 'Test for functional dependency of columns', 'fd determinecolumn dependcolumn < table') do |args|
|
||||
die 'usage: fd determinecolumn dependcolumn < table' unless args.size == 2
|
||||
t = table_in
|
||||
a = t.index_of(args[0])
|
||||
b = t.index_of(args[1])
|
||||
seen = {}
|
||||
ok = t.rows.all? { |r| (seen[r[a]] ||= r[b]) == r[b] }
|
||||
puts ok ? 'true' : 'false'
|
||||
exit(ok ? 0 : 1)
|
||||
end
|
||||
|
||||
Rdb.register('searchtree', 'Seek a string node in tree table', 'searchtree [-m[bhirs]] table root goal column1 column2') do |args|
|
||||
fast_opts(args)
|
||||
die 'usage: searchtree table root goal column1 column2' unless args.size == 5
|
||||
file, root, goal, c1, c2 = args
|
||||
t = Rdb::Table.open(file)
|
||||
i1 = t.index_of(c1)
|
||||
i2 = t.index_of(c2)
|
||||
seen = { root => true }
|
||||
queue = [root]
|
||||
found = root == goal
|
||||
until found || queue.empty?
|
||||
node = queue.shift
|
||||
t.rows.each do |r|
|
||||
next unless r[i1].strip == node
|
||||
|
||||
child = r[i2].strip
|
||||
next if seen[child]
|
||||
|
||||
seen[child] = true
|
||||
found ||= child == goal
|
||||
queue << child
|
||||
end
|
||||
end
|
||||
puts found ? 'TRUE' : 'FALSE'
|
||||
exit(found ? 0 : 1)
|
||||
end
|
||||
|
||||
Rdb.register('vindex', 'Create and display ve look-up tables', 'vindex table [ key-column [: decode-column ] ... ]') do |args|
|
||||
file = args.shift or die 'usage: vindex table [key-column [: decode-column] ...]'
|
||||
t = Rdb::Table.open(file)
|
||||
if args.empty?
|
||||
Dir["#{File.dirname(file)}/*-[AB]"].sort.each { |f| puts "== #{File.basename(f)}"; $stdout.write File.read(f) }
|
||||
next
|
||||
end
|
||||
until args.empty?
|
||||
key = args.shift
|
||||
decode = nil
|
||||
if args.first == ':'
|
||||
args.shift
|
||||
decode = args.shift
|
||||
end
|
||||
k = t.index_of(key)
|
||||
prefix = key.tr(' ', '_')[0, 14]
|
||||
vals = t.rows.map { |r| [r[k].strip, decode ? r[t.index_of(decode)].strip : nil] }.reject { |v, _| v.empty? }.uniq.sort
|
||||
File.write(File.join(File.dirname(file), "#{prefix}-A"), Rdb::Table.new([key], vals.map { |v, _| [v] }.uniq).to_s)
|
||||
File.write(File.join(File.dirname(file), "#{prefix}-B"), Rdb::Table.new([key, decode], vals.map { |v, d| [v, d] }).to_s) if decode
|
||||
end
|
||||
end
|
||||
|
||||
# ---- exercising the fast-access methods ---------------------------------------------
|
||||
|
||||
def sample_table(path, n = 2000)
|
||||
rows = (1..n).map { |i| [i.to_s, "name#{i * 7919 % n}", "city#{i % 97}"] }
|
||||
File.write(path, Rdb::Table.new(%w[Id Name City], rows).to_s)
|
||||
end
|
||||
|
||||
def time_methods(n = 2000)
|
||||
Dir.mktmpdir do |dir|
|
||||
path = File.join(dir, 'unixtable')
|
||||
sample_table(path, n)
|
||||
%w[s b h i r].each do |m|
|
||||
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
Rdb::Fast.build(path, m, 'Name') unless m == 's'
|
||||
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
data = Rdb::Fast::Data.new(path)
|
||||
hits = 0
|
||||
(1..200).each do |i|
|
||||
key = m == 'r' ? i.to_s : "name#{i * 13 % n}"
|
||||
hits += Rdb::Fast.search(data, m, ['Name'], key).size
|
||||
end
|
||||
t2 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
yield m, t1 - t0, t2 - t1, hits
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('timesearch', 'Time the fast-access methods', 'timesearch [ > timesearch.new ]') do |_args|
|
||||
puts 'method index(s) 200 searches(s) rows found'
|
||||
time_methods { |m, ti, ts, hits| puts format('-m%-5s %8.4f %15.4f %10d', m, ti, ts, hits) }
|
||||
end
|
||||
|
||||
Rdb.register('testsearch', 'Test the fast-access methods', 'testsearch') do |_args|
|
||||
time_methods(500) { |m, _ti, _ts, hits| puts "-m#{m}: #{hits.positive? ? 'ok' : 'FAILED'} (#{hits} rows found)" }
|
||||
end
|
||||
@@ -0,0 +1,409 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# The /act accounting package: general ledger, payroll, inventory, sales.
|
||||
# Like the original, it is an example of data-base programming with /rdb, not
|
||||
# an accounting package: each command reads and writes plain /rdb tables in
|
||||
# the current directory.
|
||||
|
||||
ACT_COMMANDS = %w[act adjust balance bom calcpay calculate cashflow chartdup close consolidate cstate explode
|
||||
fillform foot getjournal invoice sale tax update.inv w2].freeze
|
||||
|
||||
def num(str)
|
||||
str.to_s.strip.empty? ? 0.0 : str.to_s.strip.to_f
|
||||
end
|
||||
|
||||
def money(val)
|
||||
val == val.to_i ? val.to_i.to_s : format('%.2f', val)
|
||||
end
|
||||
|
||||
def need(file)
|
||||
File.exist?(file) || Rdb.die("#{file}: no such table (the /act commands work on tables in the current directory)")
|
||||
Rdb::Table.open(file)
|
||||
end
|
||||
|
||||
def next_date
|
||||
ENV['NEXTDATE'] || Date.today.strftime('%y%m%d')
|
||||
end
|
||||
|
||||
def account_key(acct)
|
||||
[acct.to_f, acct]
|
||||
end
|
||||
|
||||
Rdb.register('act', 'List all /act commands', 'act') do |_args|
|
||||
names = ACT_COMMANDS.sort
|
||||
w = names.map(&:size).max + 2
|
||||
names.each_slice(5) { |row| puts row.map { |n| n.ljust(w) }.join.rstrip }
|
||||
end
|
||||
|
||||
Rdb.register('chartdup', 'Check for duplicate names and accounts in chart', 'chartdup') do |_args|
|
||||
t = need('chart')
|
||||
{ 'Account' => 'Account numbers', 'Short' => 'Short names' }.each do |col, label|
|
||||
next unless (i = t.head.index(col))
|
||||
|
||||
dups = t.rows.group_by { |r| r[i] }.select { |_, v| v.size > 1 }
|
||||
next if dups.empty?
|
||||
|
||||
puts "Duplicate #{label} in chart. See chart.#{col}"
|
||||
dups.each_value { |rows| rows.each { |r| puts r.join("\t") } }
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('foot', 'Foot or subtotal Debits and Credit of ledger', 'foot') do |_args|
|
||||
t = need('ledger')
|
||||
d = t.index_of('Debit')
|
||||
c = t.index_of('Credit')
|
||||
out = []
|
||||
t.rows.sort_by { |r| account_key(r[0]) }.group_by { |r| r[0] }.each_value do |rows|
|
||||
out.concat(rows)
|
||||
out << t.head.map { |h| '-' * [h.size, 1].max }
|
||||
sub = [''] * t.head.size
|
||||
sub[0] = rows.first[0]
|
||||
sub[d] = money(rows.sum { |r| num(r[d]) })
|
||||
sub[c] = money(rows.sum { |r| num(r[c]) })
|
||||
out << sub
|
||||
end
|
||||
t.with(rows: out).write
|
||||
end
|
||||
|
||||
Rdb.register('adjust', 'Create adjusted trial balance table', 'adjust') do |_args|
|
||||
tb = need('trialbalance')
|
||||
ja = need('journaladjust')
|
||||
meta = {}
|
||||
(tb.rows.map { |r| tb.head.zip(r).to_h } + ja.rows.map { |r| ja.head.zip(r).to_h }).each do |h|
|
||||
m = (meta[h['Account']] ||= {})
|
||||
h.each { |k, v| m[k] = v if !v.to_s.strip.empty? && !%w[Debit Credit].include?(k) && !m.key?(k) }
|
||||
end
|
||||
net = Hash.new(0.0)
|
||||
[tb, ja].each do |t|
|
||||
d = t.index_of('Debit')
|
||||
c = t.index_of('Credit')
|
||||
t.rows.each { |r| net[r[0]] += num(r[d]) - num(r[c]) }
|
||||
end
|
||||
head = tb.head
|
||||
rows = net.keys.sort_by { |a| account_key(a) }.map do |acct|
|
||||
n = net[acct]
|
||||
head.map do |h|
|
||||
case h
|
||||
when 'Debit' then n >= 0 ? money(n) : ''
|
||||
when 'Credit' then n.negative? ? money(-n) : ''
|
||||
else meta[acct][h].to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
File.write('adjustedtrial', Rdb::Table.new(head, rows).to_s)
|
||||
end
|
||||
|
||||
def trial_parts(t)
|
||||
h = t.head
|
||||
fmt = h.index('Format')
|
||||
rows = t.rows.map { |r| { acct: r[0], debit: num(r[h.index('Debit')]), credit: num(r[h.index('Credit')]), fmt: r[fmt].to_s, name: r[h.index('Name')].to_s, row: r } }
|
||||
rows
|
||||
end
|
||||
|
||||
Rdb.register('balance', 'Create balance sheet from adjusted trial balance', 'balance') do |_args|
|
||||
rows = trial_parts(need('adjustedtrial'))
|
||||
bs = rows.select { |r| r[:fmt].start_with?('B-') }
|
||||
inc = rows.select { |r| r[:fmt].start_with?('I-') }
|
||||
assets = bs.select { |r| r[:fmt] == 'B-A' }
|
||||
others = bs - assets
|
||||
profit = inc.sum { |r| r[:credit] - r[:debit] }
|
||||
company = File.exist?('company') ? File.read('company').strip : (ENV['COMPANY'] || 'Company')
|
||||
out = []
|
||||
out << company.center(30).rstrip << Date.today.strftime('%-d %B %Y').center(30).rstrip << 'Balancesheet'.center(30).rstrip
|
||||
assets.each { |r| out << format('%5s %s', money(r[:debit] - r[:credit]), r[:name]) }
|
||||
total = assets.sum { |r| r[:debit] - r[:credit] }
|
||||
out << "#{'-' * 5} #{'-' * 24}" << format('%5s Total Assets', money(total))
|
||||
others.each { |r| out << format('%6s %s', money(r[:credit] - r[:debit]), r[:name]) }
|
||||
out << format('%6s Retained Earnings (Profit)', money(profit))
|
||||
out << "#{'-' * 6} #{'-' * 26}"
|
||||
out << format('%6s Total Liabilities and Equity', money(others.sum { |r| r[:credit] - r[:debit] } + profit))
|
||||
File.write('balancesheet', out.join("\n") + "\n")
|
||||
end
|
||||
|
||||
Rdb.register('close', 'Close accounting period creating journal for next', 'close') do |_args|
|
||||
rows = trial_parts(need('adjustedtrial')).select { |r| r[:fmt].start_with?('B-') }
|
||||
date = next_date
|
||||
out = rows.map { |r| [r[:acct], date, r[:debit].zero? ? '' : money(r[:debit]), r[:credit].zero? ? '' : money(r[:credit]), 'jl', 'brought forward'] }
|
||||
net = rows.sum { |r| r[:debit] - r[:credit] }
|
||||
out << ['4997', date, net.negative? ? money(-net) : '', net.negative? ? '' : money(net), 'jl', 'brought forward']
|
||||
File.write('journalnext', Rdb::Table.new(%w[Account Date Debit Credit Ref Description], out.map { |r| r }).to_s)
|
||||
end
|
||||
|
||||
Rdb.register('getjournal', 'Copy journals for sales, purchasing, pay systems', 'getjournal journalname') do |args|
|
||||
die 'usage: getjournal journalname' unless args.size == 1
|
||||
t = need(args[0])
|
||||
a = t.index_of('Amount')
|
||||
total = t.rows.sum { |r| num(r[a]) }
|
||||
Rdb::Table.new(%w[Date Amount Account Ref Description],
|
||||
[[next_date, format('%.2f', total), '', t.rows.first&.fetch(t.head.index('Ref') || 0, ''), "#{args[0].delete_prefix('journal')} from #{args[0]}"]]).write
|
||||
end
|
||||
|
||||
Rdb.register('consolidate', 'Combine all subsidiary journals to general journal', 'consolidate nextdate') do |args|
|
||||
die 'usage: consolidate nextdate' unless args.size == 1
|
||||
skip = %w[journal journaladjust journalnext journallast journalpay]
|
||||
files = Dir['journal*'].reject { |f| skip.include?(f) || f.include?('.') }.sort
|
||||
head = %w[Date Account Debit Credit Ref Description]
|
||||
rows = []
|
||||
files.each do |f|
|
||||
t = Rdb::Table.open(f)
|
||||
debit_journal = f.match?(/expense|pur|pay/)
|
||||
t.rows.each do |r|
|
||||
h = t.head.zip(r).to_h
|
||||
if t.head.include?('Amount')
|
||||
if h['Account'].to_s.strip.empty?
|
||||
warn "consolidate: #{f}: row without an Account skipped"
|
||||
next
|
||||
end
|
||||
amount = money(num(h['Amount']).abs)
|
||||
h['Debit'], h['Credit'] = debit_journal ? [amount, ''] : ['', amount]
|
||||
end
|
||||
rows << head.map { |c| h[c].to_s }
|
||||
end
|
||||
end
|
||||
d = rows.sum { |r| num(r[2]) }
|
||||
c = rows.sum { |r| num(r[3]) }
|
||||
rows << [args[0], '1010', '', money(d), '', 'double-entry'] unless d.zero?
|
||||
rows << [args[0], '1010', money(c), '', '', 'double-entry'] unless c.zero?
|
||||
File.write('journal', Rdb::Table.new(head, rows).to_s)
|
||||
end
|
||||
|
||||
Rdb.register('fillform', 'Fill a tax from with adjusted trial balance data', 'fillform form') do |args|
|
||||
die 'usage: fillform form' unless args.size == 1
|
||||
lines = need("taxform.#{args[0]}")
|
||||
trial = need('adjustedtrial')
|
||||
ti = trial.index_of('Taxline')
|
||||
sums = Hash.new(0.0)
|
||||
trial.rows.each { |r| sums[r[ti]] += (num(r[trial.index_of('Debit')]) - num(r[trial.index_of('Credit')])).abs }
|
||||
rows = lines.rows.map { |r| [r[0], money(sums[r[0]]), r[1].to_s] }
|
||||
File.write(args[0], Rdb::Table.new(%w[Taxline Amount Description], rows).to_s)
|
||||
end
|
||||
|
||||
Rdb.register('calculate', 'Compute each tax form listed', 'calculate taxform ...') do |args|
|
||||
die 'usage: calculate taxform ...' if args.empty?
|
||||
args.each do |form|
|
||||
t = need(form)
|
||||
script = "#{form}.calc"
|
||||
File.exist?(script) or die("#{script}: missing (one 'Column = expression' per line)")
|
||||
prog = "BEGIN{OFS=\"\\t\"} { #{to_awk(File.readlines(script, chomp: true).join('; '), t.head)}; print }"
|
||||
File.write(form, t.with(rows: awk(prog, t.rows)).to_s)
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('tax', 'Compute tax from income and tax table', 'tax income < taxtable') do |args|
|
||||
die 'usage: tax income < taxtable' unless args.size == 1
|
||||
income = args[0].to_f
|
||||
rows = table_in.rows.map { |r| r.map { |c| num(c) } }.sort_by(&:first)
|
||||
row = rows.reverse.find { |r| r[0] <= income }
|
||||
puts format('%7d', row ? (row[1] + (income - row[0]) * row[2] / 100.0).round : 0)
|
||||
end
|
||||
|
||||
Rdb.register('cashflow', 'Compute balance column of cash table', 'cashflow < cashtable') do |_args|
|
||||
t = table_in
|
||||
a = t.index_of('Amount')
|
||||
b = t.index_of('Balance')
|
||||
bal = 0.0
|
||||
rows = t.rows.map do |r|
|
||||
r = r.dup
|
||||
if r[a].strip.empty? && !r[b].strip.empty?
|
||||
bal = num(r[b])
|
||||
r[a] = money(bal)
|
||||
else
|
||||
bal += num(r[a])
|
||||
end
|
||||
r[b] = money(bal)
|
||||
r
|
||||
end
|
||||
t.with(rows: rows).write
|
||||
end
|
||||
|
||||
# ---- payroll -------------------------------------------------------------------------
|
||||
|
||||
Rdb.register('calcpay', 'Post payroll to ledgerpay', 'calcpay') do |_args|
|
||||
t = need('journalpay')
|
||||
n = t.index_of('Number')
|
||||
File.write('ledgerpay', t.with(rows: t.rows.each_with_index.sort_by { |r, i| [r[n].to_f, i] }.map(&:first)).to_s)
|
||||
end
|
||||
|
||||
Rdb.register('w2', 'Produce W-2 totals per employee from ledgerpay', 'w2') do |_args|
|
||||
t = need('ledgerpay')
|
||||
n = t.index_of('Number')
|
||||
cols = %w[Gross Federal State Net].select { |c| t.head.include?(c) }
|
||||
rows = t.rows.group_by { |r| r[n] }.map do |emp, rs|
|
||||
[emp] + cols.map { |c| money(rs.sum { |r| num(r[t.index_of(c)]) }) }
|
||||
end
|
||||
Rdb::Table.new(['Number'] + cols, rows).write
|
||||
end
|
||||
|
||||
# ---- operations -----------------------------------------------------------------------
|
||||
|
||||
Rdb.register('bom', 'Produce bill-of-materials from parts list', 'bom') do |_args|
|
||||
part = need('part')
|
||||
items = need('saleitem')
|
||||
qty = Hash.new(0.0)
|
||||
items.rows.each { |r| qty[r[items.index_of('Code')]] += num(r[items.index_of('Qty')]) }
|
||||
need_total = Hash.new(0.0)
|
||||
part.rows.each do |r|
|
||||
code = r[part.index_of('Code')]
|
||||
need_total[r[part.index_of('Subpart')]] += qty[code] * num(r[part.index_of('Count')]) if qty.key?(code)
|
||||
end
|
||||
File.write('bom', Rdb::Table.new(%w[Code Qty], need_total.sort.map { |k, v| [k, money(v)] }).to_s)
|
||||
end
|
||||
|
||||
Rdb.register('explode', 'Produce table of subparts and their count for a part', 'explode Part Amount [ table ]') do |args|
|
||||
die 'usage: explode Part Amount [table]' if args.size < 2
|
||||
t = need(args[2] || 'parttable')
|
||||
totals = Hash.new(0.0)
|
||||
queue = [[args[0], args[1].to_f]]
|
||||
until queue.empty?
|
||||
part, amount = queue.shift
|
||||
totals[part] += amount
|
||||
t.rows.each { |r| queue << [r[1], amount * num(r[2])] if r[0] == part }
|
||||
end
|
||||
Rdb::Table.new(%w[Part Amount], totals.sort_by { |k, _| [k.to_f, k] }.map { |k, v| [k, money(v)] }).write
|
||||
end
|
||||
|
||||
# ---- sales ------------------------------------------------------------------------------
|
||||
|
||||
def ask(prompt)
|
||||
print prompt
|
||||
$stdout.flush
|
||||
($stdin.gets || '').strip
|
||||
end
|
||||
|
||||
def address_block(customers, number)
|
||||
c = customers.rows.find { |r| r[0] == number }
|
||||
return ["Customer Number #{number}"] unless c
|
||||
|
||||
h = customers.head.zip(c).to_h
|
||||
["Mailing Address for Customer Number #{number}",
|
||||
[h['Title'], h['First'], h['Last'], h['Position']].compact.join(' ').strip,
|
||||
h['Company'], h['Street'], [h['City'], h['State'], h['Country']].compact.join(' ')].compact.reject(&:empty?)
|
||||
end
|
||||
|
||||
Rdb.register('invoice', 'Print invoice for a sale order', 'invoice') do |_args|
|
||||
orders = need('salesorder')
|
||||
items = need('saleitem')
|
||||
customers = File.exist?('customer') ? Rdb::Table.open('customer') : Rdb::Table.new(%w[Number], [])
|
||||
loop do
|
||||
n = ask('Please enter order number (or Return to exit): ')
|
||||
break if n.empty?
|
||||
|
||||
o = orders.rows.find { |r| r[0] == n }
|
||||
next puts('no such order') unless o
|
||||
|
||||
oh = orders.head.zip(o).to_h
|
||||
puts ' Invoice', ' -------'
|
||||
puts ENV['COMPANY'] || 'Company'
|
||||
puts "Order Number #{n}", "Date Ordered #{oh['Date']}", "Date Shipped #{oh['Shipped']}"
|
||||
puts address_block(customers, oh['Cust'])
|
||||
mine = items.rows.select { |r| r[0] == n }
|
||||
items.with(rows: mine).write
|
||||
puts format('%-40s Gross %.2f', '', num(oh['Gross']))
|
||||
puts format('%-40s Tax %.2f', '', num(oh['Tax']))
|
||||
puts format('%-40s Total %.2f', '', num(oh['Total']))
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('cstate', 'Produce customer statement', 'cstate') do |_args|
|
||||
orders = need('salesorder')
|
||||
deposits = File.exist?('deposit') ? Rdb::Table.open('deposit') : Rdb::Table.new(%w[Cust Date Amount Ref], [])
|
||||
customers = File.exist?('customer') ? Rdb::Table.open('customer') : Rdb::Table.new(%w[Number], [])
|
||||
loop do
|
||||
c = ask('Please enter customer number (or all or Return to exit): ')
|
||||
break if c.empty?
|
||||
|
||||
chosen = c == 'all' ? orders.rows.map { |r| r[1] }.uniq : [c]
|
||||
chosen.each do |cust|
|
||||
lines = orders.rows.select { |r| r[1] == cust }.map { |r| [cust, r[2], format('%.2f', -num(r[orders.index_of('Total')])), r[0]] } +
|
||||
deposits.rows.select { |r| r[0] == cust }.map { |r| [cust, r[1], format('%.2f', num(r[2])), r[3]] }
|
||||
balance = lines.sum { |l| num(l[2]) }
|
||||
puts ' Customer Statement', ' ------------------'
|
||||
puts address_block(customers, cust)
|
||||
puts "Now Due and Payable is your balance of: #{format('%.2f', -balance)}"
|
||||
puts 'Details of Your Orders and Payments'
|
||||
Rdb::Table.new(%w[Cust Date Amount Ref], lines.sort_by { |l| l[1] }).write
|
||||
puts format('%-20s %s', '', format('%.2f', balance))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('sale', 'Enter sale order, and item, update customer', 'sale') do |_args|
|
||||
products = need('product')
|
||||
customers = File.exist?('customer') ? Rdb::Table.open('customer') : Rdb::Table.new(%w[Number Company], [])
|
||||
File.write('salesorder', Rdb::Table.new(%w[Number Cust Date Shipped Gross Tax Total], []).to_s) unless File.exist?('salesorder')
|
||||
File.write('saleitem', Rdb::Table.new(%w[Order Number Code Backord Qty Price Total Name], []).to_s) unless File.exist?('saleitem')
|
||||
loop do
|
||||
cust = ask('Enter Customer Number (? for new, /string search, Return to exit) ')
|
||||
break if cust.empty?
|
||||
|
||||
if cust == '?'
|
||||
row = customers.head.map { |h| ask("#{h}: ") }
|
||||
cust = row[0]
|
||||
customers.rows << row
|
||||
File.write('customer', customers.to_s)
|
||||
elsif cust.start_with?('/')
|
||||
re = Regexp.new(cust[1..], Regexp::IGNORECASE)
|
||||
customers.rows.select { |r| r.join(' ').match?(re) }.each { |r| puts r.join("\t") }
|
||||
next
|
||||
end
|
||||
puts address_block(customers, cust)
|
||||
n = Rdb::Table.open('salesorder').rows.map { |r| r[0].to_i }.max.to_i + 1
|
||||
gross = 0.0
|
||||
items = []
|
||||
loop do
|
||||
code = ask('Code (Return when done): ')
|
||||
break if code.empty?
|
||||
|
||||
p = products.rows.find { |r| r[0] == code }
|
||||
next puts('no such product') unless p
|
||||
|
||||
q = ask('Qty: ').to_i
|
||||
price = num(p[products.index_of('Price')])
|
||||
items << [n.to_s, (items.size + 1).to_s, code, '10', q.to_s, money(price), format('%.2f', price * q), p[products.index_of('Name')]]
|
||||
gross += price * q
|
||||
end
|
||||
next if items.empty?
|
||||
|
||||
tax = gross * 0.05
|
||||
order = [n.to_s, cust, Date.today.strftime('%y%m%d'), '0', format('%.2f', gross), format('%.2f', tax), format('%.2f', gross + tax)]
|
||||
puts Rdb::Table.new(%w[Number Cust Date Shipped Gross Tax Total], [order]).to_s
|
||||
if ask('Is this correct? (y, n) ').downcase.start_with?('y')
|
||||
File.write('salesorder', Rdb::Table.open('salesorder').tap { |t| t.rows << order }.to_s)
|
||||
File.write('saleitem', Rdb::Table.open('saleitem').tap { |t| t.rows.concat(items) }.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('update.inv', 'Multi-user update with screen form and record locking', 'update.inv') do |_args|
|
||||
file = ENV['INV'] || 'inv'
|
||||
File.exist?(file) or die("#{file}: no such table (set INV to your inventory table)")
|
||||
Rdb::Fast.build(file, 'i', 'Item') unless File.exist?("#{file}.i")
|
||||
puts "Inventory Update #{Date.today.strftime('%a %b %-d, %Y')}"
|
||||
loop do
|
||||
item = ask('Item Number: (or hit Return key to exit.) ')
|
||||
break if item.empty?
|
||||
|
||||
data = Rdb::Fast::Data.new(file)
|
||||
hit = Rdb::Fast.search(data, 'i', ['Item'], item, first: true).first
|
||||
next puts('no such item') unless hit
|
||||
|
||||
pid = Process.pid.to_s
|
||||
unless system(File.join(Rdb::ROOT, 'bin', 'lock'), file, pid, hit.row.from.to_s, hit.row.to.to_s, hit.xfrom.to_s, hit.xto.to_s)
|
||||
puts 'record is being updated by someone else'
|
||||
next
|
||||
end
|
||||
begin
|
||||
cells = hit.row.cells
|
||||
h = data.head
|
||||
puts h.zip(cells).map { |a, b| "#{a}=#{b.strip}" }.join(' ')
|
||||
amount = ask("Amount #{h.include?('Onhand') ? 'Onhand' : 'Amount'}: ")
|
||||
unless amount.empty?
|
||||
cells[h.index('Onhand') || h.index('Amount')] = amount
|
||||
cells[h.index('Value')] = money(num(amount) * num(cells[h.index('Cost')])) if h.include?('Value') && h.include?('Cost')
|
||||
Rdb::Fast.replace_row(file, hit.row.from, hit.row.to, cells, 'i', hit.xfrom, hit.xto, 'Item')
|
||||
end
|
||||
ensure
|
||||
system(File.join(Rdb::ROOT, 'bin', 'unlock'), file, pid, hit.row.from.to_s, hit.row.to.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,142 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Converting tables to other formats, dates, and name matching.
|
||||
|
||||
Rdb.register('tabletolist', 'Convert a table to list format', 'tabletolist [ -l ] < table') do |args|
|
||||
t = table_in
|
||||
text = t.with(rows: t.rows).tap { |x| x.list = true }.to_s
|
||||
$stdout.write args.include?('-l') ? text.sub(/\A\n/, '') : text
|
||||
end
|
||||
|
||||
Rdb.register('listtotable', 'Convert from list to table format', 'listtotable [ -el ] < listfile') do |_args|
|
||||
t = Rdb::Table.parse_list($stdin.read.sub(/\A(?!\n)/, "\n"))
|
||||
t.list = false
|
||||
t.write
|
||||
end
|
||||
|
||||
Rdb.register('listtosh', 'Convert list format to shell variable', 'listtosh < list') do |_args|
|
||||
t = Rdb::Table.parse_list($stdin.read.sub(/\A(?!\n)/, "\n"))
|
||||
t.rows.each do |r|
|
||||
t.head.each_with_index { |h, i| puts "#{h.gsub(/\W/, '_')}='#{r[i].gsub("'", %q('\\\\''))}'" }
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('tabletofix', 'Convert /rdb table format to fixed-length format', 'tabletofix column=n[r] ... < tableorlist') do |args|
|
||||
t = table_in
|
||||
specs = args.map do |a|
|
||||
name, w = a.split('=', 2)
|
||||
die "bad column spec '#{a}'" unless w
|
||||
[t.index_of(name), w.to_i, w.end_with?('r')]
|
||||
end
|
||||
t.rows.each do |r|
|
||||
next if blank_row?(r)
|
||||
|
||||
puts specs.map { |i, w, right| right ? r[i].strip[0, w].rjust(w) : r[i].strip[0, w].ljust(w) }.join
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('fixtotable', 'Converts fixed length format to /rdb table format', 'fixtotable column[=n1,n2] ... < fixtdb') do |args|
|
||||
specs = args.map do |a|
|
||||
name, pos = a.split('=', 2)
|
||||
[name, pos ? pos.split(',').map(&:to_i) : nil]
|
||||
end
|
||||
rows = $stdin.each_line.map { |l| l.chomp("\n") }.reject { |l| l.strip.empty? }.map do |l|
|
||||
specs.map { |_, pos| pos ? (l[pos[0] - 1, pos[1]] || '').strip : '' }
|
||||
end
|
||||
Rdb::Table.new(specs.map(&:first), rows).write
|
||||
end
|
||||
|
||||
Rdb.register('tabletostruct', 'Convert table to C-language struct declaration', 'tabletostruct tag name < table > table.h') do |args|
|
||||
die 'usage: tabletostruct tag name < table' unless args.size == 2
|
||||
t = table_in
|
||||
puts "struct #{args[0]}", '{'
|
||||
t.head.each { |h| puts "char *#{h};" }
|
||||
puts "} #{args[1]} [] =", '{ ' + t.rows.map { |r| r.map { |c| %("#{c.strip}") }.join(',') }.join(" },\n{ ") + ' }', ';'
|
||||
end
|
||||
|
||||
Rdb.register('tabletotbl', 'Convert /rdb table format to UNIX tbl/nroff format', 'tabletotbl < tablefile > tblfile') do |_args|
|
||||
t = table_in
|
||||
fmt = t.head.each_index.map { |i| t.rows.all? { |r| r[i].empty? || numeric?(r[i]) } ? 'n' : 'l' }.join(' ')
|
||||
puts '.TS', "#{fmt}.", t.head.join("\t"), '_'
|
||||
t.rows.each { |r| puts r.join("\t") }
|
||||
puts '.TE'
|
||||
end
|
||||
|
||||
Rdb.register('tabletofact', 'Converts a table to PROLOG fact-file format', 'tabletofact table ... > prologfactfile') do |args|
|
||||
die 'usage: tabletofact table ...' if args.empty?
|
||||
args.each do |f|
|
||||
open_table(f).rows.each { |r| puts "#{File.basename(f)}(#{r.map(&:strip).join(',')})." }
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('tabletorule', 'Convert a table to PROLOG rule-file format', 'tabletorule table ... > prologrulefile') do |args|
|
||||
die 'usage: tabletorule table ...' if args.empty?
|
||||
args.each do |f|
|
||||
open_table(f).rows.each { |r| puts "#{r[0].strip} :- #{r[1].strip}." }
|
||||
end
|
||||
end
|
||||
|
||||
# ---- dates ------------------------------------------------------------------
|
||||
|
||||
{ 'julian' => 'Convert column of dates for arithmetic and format change',
|
||||
'gregorian' => 'Convert column of dates for arithmetic and format change' }.each do |cmd, desc|
|
||||
Rdb.register(cmd, desc, "#{cmd} [ -ceu ] [ Column ... ] < tableorlist") do |args|
|
||||
fmt = 'c'
|
||||
args.reject! { |a| a.match?(/\A-[ceu]\z/) && (fmt = a[1]) }
|
||||
t = table_in
|
||||
sel = args.empty? ? [0] : args.map { |a| t.index_of(a) }
|
||||
rows = t.rows.map do |r|
|
||||
r = r.dup
|
||||
sel.each { |i| r[i] = cmd == 'julian' ? to_julian(r[i], fmt).to_s : from_julian(r[i], fmt) }
|
||||
r
|
||||
end
|
||||
t.with(rows: rows).write
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('computedate', 'Add given number of days to a date', 'computedate date days') do |args|
|
||||
die 'usage: computedate date days' unless args.size == 2
|
||||
puts from_julian(to_julian(args[0]) + args[1].to_i)
|
||||
end
|
||||
|
||||
Rdb.register('todaysdate', "Print today's date in YYMMDD format", 'todaysdate') do |_args|
|
||||
puts Date.today.strftime('%y%m%d')
|
||||
end
|
||||
|
||||
# ---- names that sound alike ----------------------------------------------------
|
||||
|
||||
def soundex_code(word)
|
||||
codes = { 'BFPV' => '1', 'CGJKQSXZ' => '2', 'DT' => '3', 'L' => '4', 'MN' => '5', 'R' => '6' }
|
||||
letters = word.upcase.gsub(/[^A-Z]/, '').chars
|
||||
return '' if letters.empty?
|
||||
|
||||
code = ->(c) { codes.find { |k, _| k.include?(c) }&.last }
|
||||
out = letters[0].dup
|
||||
last = code.(letters[0])
|
||||
letters.drop(1).each do |c|
|
||||
d = code.(c)
|
||||
out << d if d && d != last
|
||||
last = d unless 'HW'.include?(c)
|
||||
break if out.size == 4
|
||||
end
|
||||
out.ljust(4, '0')
|
||||
end
|
||||
|
||||
Rdb.register('soundex', 'Convert a name into soundex code', 'soundex < file > file.x ; index -mb file.x Soundex') do |_args|
|
||||
t = table_in
|
||||
Rdb::Table.new([t.head[0], 'Soundex'], t.rows.map { |r| [r[0], soundex_code(r[0])] }).write
|
||||
end
|
||||
|
||||
Rdb.register('like', 'Find names that sound like another name', 'like similar-name file [ name-column ]') do |args|
|
||||
die 'usage: like similar-name file [name-column]' if args.size < 2
|
||||
t = open_table("#{args[1]}.x")
|
||||
col = args[2] ? t.index_of(args[2]) : 0
|
||||
want = soundex_code(args[0])
|
||||
s = t.index_of('Soundex')
|
||||
Rdb::Table.new([t.head[col]], t.rows.select { |r| r[s] == want }.map { |r| [r[col]] }).write
|
||||
end
|
||||
|
||||
Rdb.register('hashkey', 'Return the hash offset for key strings', 'hashkey rows keystring ...') do |args|
|
||||
die 'usage: hashkey rows keystring ...' if args.size < 2
|
||||
puts args.drop(1).join.delete(" \t").bytes.sum % args[0].to_i
|
||||
end
|
||||
@@ -0,0 +1,341 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Selecting, combining and summarising tables.
|
||||
|
||||
Rdb.register('row', 'Make a new table where rows match logical condition',
|
||||
"row 'column condition column-or-value ..' < table") do |args|
|
||||
die "usage: row 'condition' < table" unless args.size == 1
|
||||
t = table_in
|
||||
prog = "BEGIN{OFS=\"\\t\"} #{to_awk(args[0], t.head)} {print}"
|
||||
t.with(rows: awk(prog, t.rows)).write
|
||||
end
|
||||
Rdb.alias_for('select', 'row')
|
||||
|
||||
Rdb.register('column', 'Display columns of a table in any order', 'column [ Column ... ] < tableorlist') do |args|
|
||||
t = table_in
|
||||
next t.write if args.empty?
|
||||
|
||||
idx = args.map { |a| t.head.index(a) }
|
||||
t.with(head: args, rows: t.rows.map { |r| idx.map { |i| i ? r[i] : '' } }).write
|
||||
end
|
||||
Rdb.alias_for('project', 'column')
|
||||
|
||||
Rdb.register('sorttable', 'Sort a table by one or more columns',
|
||||
'sorttable [-nrf] [columnname ...] < tableorlist') do |args|
|
||||
t = table_in
|
||||
numeric = reverse = fold = false
|
||||
cols = []
|
||||
args.each do |a|
|
||||
if a.match?(/\A-[nrf]+\z/)
|
||||
numeric ||= a.include?('n')
|
||||
reverse ||= a.include?('r')
|
||||
fold ||= a.include?('f')
|
||||
else
|
||||
cols << t.index_of(a)
|
||||
end
|
||||
end
|
||||
cols = (0...t.head.size).to_a if cols.empty?
|
||||
key = ->(r) { cols.map { |i| numeric ? r[i].to_f : (fold ? r[i].downcase : r[i]) } }
|
||||
sorted = t.rows.each_with_index.sort_by { |r, n| [key.(r), n] }.map(&:first)
|
||||
sorted.reverse! if reverse
|
||||
t.with(rows: sorted).write
|
||||
end
|
||||
|
||||
Rdb.register('compute', 'Calculate columns of a table', "compute 'column = expression [ ; ... ]' < tableorlist") do |args|
|
||||
die "usage: compute 'column = expression [; ...]' < table" unless args.size == 1
|
||||
t = table_in
|
||||
prog = "BEGIN{OFS=\"\\t\"} { #{to_awk(args[0], t.head)}; print }"
|
||||
t.with(rows: awk(prog, t.rows)).write
|
||||
end
|
||||
|
||||
Rdb.register('addcol', 'Add a column to a table', 'addcol newcolumn < table') do |args|
|
||||
die 'usage: addcol newcolumn ... < table' if args.empty?
|
||||
t = table_in
|
||||
t.with(head: t.head + args, rows: t.rows.map { |r| r + [''] * args.size }).write
|
||||
end
|
||||
|
||||
Rdb.register('rename', 'Rename a column', 'rename [ oldcolumn newcolumn ... ] < tableorlist') do |args|
|
||||
die 'usage: rename oldcolumn newcolumn ... < table' if args.empty? || args.size.odd?
|
||||
t = table_in
|
||||
head = t.head.dup
|
||||
args.each_slice(2) { |old, new| head[t.index_of(old)] = new }
|
||||
t.with(head: head).write
|
||||
end
|
||||
|
||||
{ 'number' => 'number', 'Number' => 'Number' }.each do |cmd, title|
|
||||
Rdb.register(cmd, 'Insert a column-row number into a table or list', "#{cmd} < tableorlist") do |_args|
|
||||
t = table_in
|
||||
t.with(head: [title] + t.head, rows: t.rows.each_with_index.map { |r, i| [(i + 1).to_s] + r }).write
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('record', 'Find and output a record from a table', 'record number < table') do |args|
|
||||
die 'usage: record number < table' unless args.size == 1
|
||||
t = table_in
|
||||
row = t.rows[args[0].to_i - 1]
|
||||
t.with(rows: row ? [row] : []).write
|
||||
end
|
||||
|
||||
# ---- combining tables ------------------------------------------------------
|
||||
|
||||
Rdb.register('jointable', 'Join two tables into one where keys match',
|
||||
'jointable [-a[1|2]] [-j[1|2] column]... [-n] [-] table1 [table2]') do |args|
|
||||
all1 = all2 = false
|
||||
k1 = k2 = nil
|
||||
files = []
|
||||
until args.empty?
|
||||
a = args.shift
|
||||
case a
|
||||
when '-a' then all1 = all2 = true
|
||||
when '-a1' then all1 = true
|
||||
when '-a2' then all2 = true
|
||||
when '-j' then k1 = k2 = args.shift
|
||||
when '-j1' then k1 = args.shift
|
||||
when '-j2' then k2 = args.shift
|
||||
when '-n' then nil
|
||||
else files << a
|
||||
end
|
||||
end
|
||||
die 'usage: jointable [-a[1|2]] [-j[1|2] column] table1 [table2]' if files.empty? || files.size > 2
|
||||
|
||||
files << '-' if files.size == 1
|
||||
t1, t2 = files.map { |f| open_table(f) }
|
||||
i1 = k1 ? t1.index_of(k1) : 0
|
||||
i2 = k2 ? t2.index_of(k2) : 0
|
||||
keep2 = (0...t2.head.size).to_a - [i2]
|
||||
by_key = Hash.new { |h, k| h[k] = [] }
|
||||
t2.rows.each { |r| by_key[r[i2]] << r }
|
||||
used = {}
|
||||
out = []
|
||||
t1.rows.each do |r|
|
||||
if (m = by_key[r[i1]]).any?
|
||||
used[r[i1]] = true
|
||||
m.each { |r2| out << r + keep2.map { |i| r2[i] } }
|
||||
elsif all1
|
||||
out << r + [''] * keep2.size
|
||||
end
|
||||
end
|
||||
if all2
|
||||
t2.rows.each do |r2|
|
||||
next if used[r2[i2]]
|
||||
|
||||
blank = [''] * t1.head.size
|
||||
blank[i1] = r2[i2]
|
||||
out << blank + keep2.map { |i| r2[i] }
|
||||
end
|
||||
end
|
||||
Rdb::Table.new(t1.head + keep2.map { |i| t2.head[i] }, out).write
|
||||
end
|
||||
|
||||
Rdb.register('union', 'Concatenate tables', 'union tableorlist ... [-] [< tableorlist]') do |args|
|
||||
args = ['-'] if args.empty?
|
||||
tabs = args.map { |a| open_table(a) }
|
||||
tabs.first.with(rows: tabs.flat_map(&:rows)).write
|
||||
end
|
||||
|
||||
Rdb.register('intersect', 'Write table of rows that are in both input tables', 'intersect table1 table2') do |args|
|
||||
die 'usage: intersect table1 table2' unless args.size == 2
|
||||
a, b = args.map { |x| open_table(x) }
|
||||
seen = b.rows.to_h { |r| [r, true] }
|
||||
a.with(rows: a.rows.select { |r| seen[r] }.uniq).write
|
||||
end
|
||||
|
||||
Rdb.register('difference', 'Output table of rows that are in only one table', 'difference table1 table2') do |args|
|
||||
die 'usage: difference table1 table2' unless args.size == 2
|
||||
a, b = args.map { |x| open_table(x) }
|
||||
sb = b.rows.to_h { |r| [r, true] }
|
||||
a.with(rows: a.rows.reject { |r| sb[r] }.uniq).write
|
||||
end
|
||||
|
||||
Rdb.register('paste.rdb', 'Paste together two or more tables', 'paste.rdb table1 table2 ...') do |args|
|
||||
die 'usage: paste.rdb table1 table2 ...' if args.size < 2
|
||||
tabs = args.map { |a| open_table(a) }
|
||||
n = tabs.map { |t| t.rows.size }.max
|
||||
head = tabs.flat_map(&:head)
|
||||
rows = (0...n).map { |i| tabs.flat_map { |t| t.rows[i] || [''] * t.head.size } }
|
||||
Rdb::Table.new(head, rows).write
|
||||
end
|
||||
|
||||
Rdb.register('splittable', 'Divide a table horizontally', 'splittable [-n] table') do |args|
|
||||
n = 50
|
||||
n = args.shift[1..].to_i if args.first&.match?(/\A-\d+\z/)
|
||||
die 'usage: splittable [-n] table' unless args.size == 1
|
||||
t = open_table(args[0])
|
||||
suffix = ('aa'..'zz').each
|
||||
t.rows.each_slice(n) do |chunk|
|
||||
File.write("#{args[0]}#{suffix.next}", t.with(rows: chunk).to_s)
|
||||
end
|
||||
end
|
||||
|
||||
# ---- aggregates ------------------------------------------------------------
|
||||
# total, mean, maximum, minimum, width, precision and datatype are one program
|
||||
# that decides what to compute from the name it was called by.
|
||||
|
||||
STATS = {
|
||||
'total' => 'Sum a column',
|
||||
'mean' => 'Display the mean of a column',
|
||||
'maximum' => 'Display the maximum value in a column',
|
||||
'minimum' => 'Display the minimum of each column selected',
|
||||
'width' => 'Display the width of each column',
|
||||
'precision' => 'Display the precision of a column',
|
||||
'datatype' => 'Display the data type of each column selected'
|
||||
}.freeze
|
||||
|
||||
STATS.each do |cmd, desc|
|
||||
Rdb.register(cmd, desc, "#{cmd} [ -l ] [ column ... ] < table") do |args|
|
||||
list = args.first == '-l'
|
||||
args.shift if list
|
||||
t = table_in
|
||||
sel = args.empty? ? (0...t.head.size).to_a : args.map { |a| t.index_of(a) }
|
||||
result = Array.new(t.head.size, '')
|
||||
sel.each do |i|
|
||||
vals = t.rows.map { |r| r[i] }
|
||||
filled = vals.reject { |v| v.strip.empty? }
|
||||
nums = filled.all? { |v| numeric?(v) } ? vals.map { |v| v.strip.to_f } : nil
|
||||
dec = filled.map { |v| decimals(v) }.max.to_i
|
||||
result[i] =
|
||||
case name
|
||||
when 'width' then (vals.map(&:size) + [t.head[i].size]).max.to_s
|
||||
when 'precision' then nums ? dec.to_s : ''
|
||||
when 'datatype' then filled.empty? || !nums ? '-1' : dec.to_s
|
||||
when 'total' then nums ? fmt(nums.sum, dec) : ''
|
||||
when 'mean' then nums && !nums.empty? ? fmt(nums.sum / nums.size, [dec, 2].max) : ''
|
||||
when 'maximum' then nums && !nums.empty? ? fmt(nums.max, dec) : ''
|
||||
when 'minimum' then nums && !nums.empty? ? fmt(nums.min, dec) : ''
|
||||
end
|
||||
end
|
||||
if list
|
||||
$stdout.write t.to_s
|
||||
puts Rdb::Table.dash_row(t.head)
|
||||
puts result.join("\t")
|
||||
else
|
||||
Rdb::Table.new(t.head, [result]).write
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('widest', 'Output the width of the widest entries in a table', 'widest < table') do |_args|
|
||||
t = table_in
|
||||
res = t.head.each_index.map { |i| (t.rows.map { |r| r[i].size } + [t.head[i].size]).max.to_s }
|
||||
Rdb::Table.new(t.head, [res]).write
|
||||
end
|
||||
|
||||
Rdb.register('subtotal', 'Output subtotals of columns in a table',
|
||||
'subtotal [ -l ] [ break-column column ... ] < table') do |args|
|
||||
list = args.first == '-l'
|
||||
args.shift if list
|
||||
t = table_in
|
||||
brk = args.empty? ? 0 : t.index_of(args.shift)
|
||||
sel = args.empty? ? (0...t.head.size).to_a - [brk] : args.map { |a| t.index_of(a) }
|
||||
out = []
|
||||
group = []
|
||||
flush = lambda do
|
||||
next if group.empty?
|
||||
|
||||
sub = Array.new(t.head.size, '')
|
||||
sub[brk] = group.first[brk]
|
||||
sel.each do |i|
|
||||
vals = group.map { |r| r[i] }.reject { |v| v.strip.empty? }
|
||||
next if vals.empty? || !vals.all? { |v| numeric?(v) }
|
||||
|
||||
sub[i] = fmt(vals.sum { |v| v.strip.to_f }, vals.map { |v| decimals(v) }.max)
|
||||
end
|
||||
if list
|
||||
out.concat(group)
|
||||
out << t.head.map { |h| '-' * [h.size, 1].max }
|
||||
end
|
||||
out << sub
|
||||
group = []
|
||||
end
|
||||
t.rows.each do |r|
|
||||
flush.() if group.any? && group.first[brk] != r[brk]
|
||||
group << r
|
||||
end
|
||||
flush.()
|
||||
t.with(rows: out).write
|
||||
end
|
||||
|
||||
# ---- describing tables ------------------------------------------------------
|
||||
|
||||
Rdb.register('schema', "Print a table's schema", 'schema tableorlist ...') do |args|
|
||||
die 'usage: schema tableorlist ...' if args.empty?
|
||||
rows = args.flat_map do |f|
|
||||
open_table(f).head.each_with_index.map { |h, i| [File.basename(f), (i + 1).to_s, h] }
|
||||
end
|
||||
Rdb::Table.new(%w[Table Field Name], rows).write
|
||||
end
|
||||
|
||||
Rdb.register('dbdict', 'Print a data-base dictionary', 'dbdict < table') do |_args|
|
||||
t = table_in
|
||||
Rdb::Table.new(%w[field name], t.head.each_with_index.map { |h, i| [(i + 1).to_s, h] }).write
|
||||
end
|
||||
|
||||
Rdb.register('tableorlist', 'Report whether a file has table or list format',
|
||||
'tableorlist [tableorlist ...] [< tableorlist]') do |args|
|
||||
texts = args.empty? ? [$stdin.read] : args.map { |a| File.read(a) }
|
||||
status = 0
|
||||
texts.each do |tx|
|
||||
if tx.start_with?("\n")
|
||||
puts 'list'
|
||||
status = 1
|
||||
else
|
||||
puts 'table'
|
||||
end
|
||||
end
|
||||
exit status
|
||||
end
|
||||
|
||||
Rdb.register('check.rdb', 'Report any rows in which columns do not match head line', 'check.rdb < tableorlist') do |_args|
|
||||
lines = $stdin.read.lines.map { |l| l.chomp("\n") }
|
||||
expected = lines.first.to_s.count("\t") + 1
|
||||
bad = 0
|
||||
lines.each_with_index do |l, n|
|
||||
next if n.zero? || l.count("\t") + 1 == expected
|
||||
|
||||
bad += 1
|
||||
warn "check.rdb: Columns (#{l.count("\t") + 1}) do not equal headline columns (#{expected}) in row #{n - 1} (line #{n + 1})."
|
||||
warn l
|
||||
end
|
||||
exit(bad.zero? ? 0 : 1)
|
||||
end
|
||||
|
||||
# ---- head and dash lines -------------------------------------------------
|
||||
|
||||
Rdb.register('headon', 'Add an /rdb header to a table', 'headon [column ...] < table') do |args|
|
||||
rows = $stdin.each_line.map { |l| l.chomp("\n").split("\t", -1) }
|
||||
n = rows.map(&:size).max || 0
|
||||
head = args.dup
|
||||
head << (head.size + 1).to_s while head.size < n
|
||||
Rdb::Table.new(head, rows.map { |r| r.fill('', r.size...head.size) }).write
|
||||
end
|
||||
|
||||
Rdb.register('headoff', 'Remove an /rdb head from both table and list file', 'headoff < tableorlist') do |_args|
|
||||
text = $stdin.read
|
||||
lines = text.lines
|
||||
$stdout.write lines.drop(text.start_with?("\n") ? 1 : 2).join
|
||||
end
|
||||
|
||||
Rdb.register('insertdash', 'Insert dash line as second line in table', 'insertdash < table-without-dashline') do |_args|
|
||||
lines = $stdin.read.lines
|
||||
head = lines.shift.to_s.chomp("\n")
|
||||
puts head
|
||||
puts head.gsub(/[^\t]/, '-')
|
||||
$stdout.write lines.join
|
||||
end
|
||||
|
||||
Rdb.register('display', 'Write table or list file to standard output', 'display < tableorlist') do |_args|
|
||||
$stdout.write $stdin.read
|
||||
end
|
||||
|
||||
Rdb.register('see', 'Display nonprinting as well as printing characters', 'see < tableorlist') do |_args|
|
||||
$stdin.read.each_char do |c|
|
||||
o = c.ord
|
||||
$stdout.write(
|
||||
if c == "\n" then "$\n"
|
||||
elsif o < 32 then "^#{(o + 64).chr}"
|
||||
elsif o == 127 then '^?'
|
||||
else c
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Reports, forms and mailing lists.
|
||||
|
||||
# Fill in a form for one row: <Column> is replaced by the value, <!command!>
|
||||
# by the output of the shell command, and <!cmd <Column>!> passes data to it.
|
||||
def fill_form(form, head, row)
|
||||
values = head.zip(row).to_h { |h, v| [h.strip, v.to_s] }
|
||||
form.gsub(/<!(.*?)!>|<([^<>!\n]*)>/m) do
|
||||
if Regexp.last_match(1)
|
||||
cmd = Regexp.last_match(1).gsub(/<([^<>!\n]*)>/) { values.fetch(Regexp.last_match(1).strip, Regexp.last_match(0)) }
|
||||
`#{cmd}`
|
||||
else
|
||||
name = Regexp.last_match(2).strip
|
||||
values.key?(name) ? values[name] : Regexp.last_match(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('report', 'Write reports using a form and a table', 'report form < tableorlist') do |args|
|
||||
die 'usage: report form < tableorlist' unless args.size == 1
|
||||
ENV['PATH'] = "#{File.join(Rdb::ROOT, 'bin')}:#{ENV['PATH']}"
|
||||
form = File.read(args[0])
|
||||
t = table_in
|
||||
t.rows.each { |r| $stdout.write fill_form(form, t.head, r) }
|
||||
end
|
||||
|
||||
Rdb.register('reportwriter', 'Sample program to write standard reports', 'reportwriter [-lines] [title] < table') do |args|
|
||||
lines = args.first&.match?(/\A-\d+\z/) ? args.shift[1..].to_i : 20
|
||||
title = args.join(' ')
|
||||
t = table_in
|
||||
date = Date.today.strftime('%-d %b %Y')
|
||||
w = t.head.each_index.map { |i| (t.rows.map { |r| r[i].strip.size } + [t.head[i].size]).max }
|
||||
line = ->(cells) { cells.each_with_index.map { |c, i| c.strip.ljust(w[i]) }.join(' ').rstrip }
|
||||
pages = t.rows.each_slice(lines).to_a
|
||||
pages = [[]] if pages.empty?
|
||||
pages.each_with_index do |chunk, n|
|
||||
puts title.center(w.sum + 3 * (w.size - 1)).rstrip unless title.empty?
|
||||
puts "Report Date: #{date}".center(w.sum + 3 * (w.size - 1)).rstrip, ''
|
||||
puts line.(t.head), line.(t.head.map { |h| '-' * h.size })
|
||||
chunk.each { |r| puts line.(r) }
|
||||
puts '', " Page #{n + 1}"
|
||||
puts "\f" if n < pages.size - 1
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('screen', 'Convert a form into a screen-input shell program', 'screen < form > shellprogram') do |_args|
|
||||
names = []
|
||||
reads = []
|
||||
painted = $stdin.read.lines.each_with_index.map do |line, n|
|
||||
line = line.chomp("\n")
|
||||
out = line.dup
|
||||
line.scan(/<!(.*?)!>|<([^<>!]*)>/) do
|
||||
m = Regexp.last_match
|
||||
col = m.begin(0) + 1
|
||||
if m[1]
|
||||
reads << format('cursor %2d %2d ; %s;', n + 1, col, m[1].strip)
|
||||
else
|
||||
names << m[2].strip
|
||||
reads << format('cursor %2d %2d ; read %s;', n + 1, col, m[2].strip)
|
||||
end
|
||||
out[m.begin(0), m[0].size] = ' ' * m[0].size
|
||||
end
|
||||
out
|
||||
end
|
||||
puts ': paint crt screen', 'exec 1>/dev/tty', 'cat <<SCREEN', "${CLEAR}#{painted.join("\n")}", 'SCREEN'
|
||||
puts ': read user input', *reads
|
||||
puts ': output table head', 'exec 1>&2'
|
||||
puts %(echo "#{names.join("\t")}"), %(echo "#{names.map { |x| '-' * x.size }.join("\t")}")
|
||||
puts ': append row', %(echo "#{names.map { |x| "$#{x}" }.join("\t")}")
|
||||
end
|
||||
|
||||
def label_lines(head, row)
|
||||
h = head.zip(row).to_h
|
||||
[h['Name'], h['Company'], h['Street'], "#{h['City']}, #{h['State']} #{h['ZIP']}".strip].compact
|
||||
end
|
||||
|
||||
Rdb.register('label', 'Print mailing labels from a mailing list', 'label < list') do |_args|
|
||||
t = table_in
|
||||
t.rows.each { |r| puts label_lines(t.head, r) }
|
||||
end
|
||||
|
||||
Rdb.register('letter', 'Print form letters from a mailing list', 'letter letter.1 ... < maillist') do |args|
|
||||
die 'usage: letter letter.1 letter.2 < maillist' if args.empty?
|
||||
parts = args.map { |f| File.readlines(f, chomp: true) }
|
||||
t = table_in
|
||||
wrap = lambda do |text|
|
||||
text.split.each_with_object(['']) { |w, ls| (ls.last + ' ' + w).strip.size > 65 ? ls << w : ls[-1] = (ls.last + ' ' + w).strip }
|
||||
end
|
||||
t.rows.each do |r|
|
||||
h = t.head.zip(r).to_h
|
||||
puts label_lines(t.head, r), '', "Dear #{h['Name']}:", ''
|
||||
first = parts.first.join(' ')
|
||||
rest = parts.drop(1).flatten
|
||||
close = rest.index { |l| l.end_with?(',') } || rest.size
|
||||
para = [first, "#{h['Company']}.", rest.first(close).join(' ')].reject(&:empty?).join(' ')
|
||||
puts wrap.(para), *rest.drop(close), "\n"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,238 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'shellwords'
|
||||
|
||||
# Help, manual pages, terminal helpers, menus and housekeeping.
|
||||
|
||||
ESC = "\e"
|
||||
TERM_CAPS = {
|
||||
'cl' => "#{ESC}[H#{ESC}[2J", 'ho' => "#{ESC}[H", 'ce' => "#{ESC}[K", 'cd' => "#{ESC}[J",
|
||||
'cm' => "#{ESC}[%i%d;%dH", 'so' => "#{ESC}[7m", 'se' => "#{ESC}[m", 'us' => "#{ESC}[4m",
|
||||
'ue' => "#{ESC}[m", 'mb' => "#{ESC}[5m", 'md' => "#{ESC}[1m", 'me' => "#{ESC}[m",
|
||||
'as' => "#{ESC}(0", 'ae' => "#{ESC}(B", 'up' => "#{ESC}[A", 'do' => "#{ESC}[B", 'bs' => "\b"
|
||||
}.freeze
|
||||
|
||||
def command_rows
|
||||
Rdb::COMMANDS.values.reject { |c| c.name.include?('.list') || c.name == 'rdb.install' }.sort_by(&:name)
|
||||
end
|
||||
|
||||
def syntax_of(cmd)
|
||||
cmd.syntax.sub(/\A\S+\s*/, '')
|
||||
end
|
||||
|
||||
Rdb.register('rdb.list', 'List all /rdb programs', 'rdb') do |_args|
|
||||
names = command_rows.map(&:name)
|
||||
w = names.map(&:size).max + 2
|
||||
cols = [80 / w, 1].max
|
||||
names.each_slice((names.size / cols.to_f).ceil).to_a.then do |columns|
|
||||
columns.first.each_index { |i| puts columns.map { |c| (c[i] || '').ljust(w) }.join.rstrip }
|
||||
end
|
||||
end
|
||||
Rdb.alias_for('rdb', 'rdb.list')
|
||||
|
||||
Rdb.register('rdb.install', 'Symlink every command into a directory', 'rdb install [directory]') do |args|
|
||||
me = File.realpath(File.join(Rdb::ROOT, 'bin', 'rdb'))
|
||||
dir = File.expand_path(args.first || File.dirname(me))
|
||||
FileUtils.mkdir_p(dir)
|
||||
made = 0
|
||||
command_rows.each do |c|
|
||||
link = File.join(dir, c.name)
|
||||
next if File.exist?(link) || File.symlink?(link)
|
||||
|
||||
File.symlink(me, link)
|
||||
made += 1
|
||||
end
|
||||
puts "linked #{made} new commands into #{dir} (#{command_rows.size} total)"
|
||||
end
|
||||
|
||||
Rdb.register('commands', 'Describe /rdb commands', 'commands') do |_args|
|
||||
Rdb::Table.new(%w[Command Description Syntax], command_rows.map { |c| [c.name, c.desc, syntax_of(c)] }).write
|
||||
end
|
||||
|
||||
Rdb.register('helpme', 'List the help commands available', 'helpme') do |_args|
|
||||
rows = [['menu', 'a menu with some commands'], ['rdb', 'a list of the available commands'],
|
||||
['commands', 'description and syntax of all the commands'],
|
||||
['whatis command', 'description and syntax of command'],
|
||||
['whatwill feature', 'info on commands with that feature'],
|
||||
['rdb man command', 'the manual page for that command']]
|
||||
Rdb::Table.new(%w[Command Description], rows.map { |a, b| [%("#{a}"), b] }).write
|
||||
end
|
||||
|
||||
show_cmd = lambda do |c|
|
||||
puts format(' %-9s - %s', c.name, c.desc.sub(/\A./, &:downcase))
|
||||
puts format(' %-9s %s', c.name, c.syntax)
|
||||
end
|
||||
|
||||
Rdb.register('whatis', 'Display the command description and syntax', 'whatis command') do |args|
|
||||
die 'usage: whatis command' if args.empty?
|
||||
args.each { |a| (c = Rdb::COMMANDS[a]) ? show_cmd.(c) : warn("whatis: #{a}: not an /rdb command") }
|
||||
end
|
||||
|
||||
Rdb.register('whatwill', 'Display commands with functions in description', 'whatwill feature') do |args|
|
||||
die 'usage: whatwill feature' if args.empty?
|
||||
re = Regexp.new(Regexp.escape(args.join(' ')), Regexp::IGNORECASE)
|
||||
command_rows.select { |c| [c.name, c.desc, c.syntax].any? { |s| s.match?(re) } }.each { |c| show_cmd.(c) }
|
||||
end
|
||||
|
||||
Rdb.register('howmany', 'Display the number of commands', 'howmany') do |_args|
|
||||
puts command_rows.size
|
||||
end
|
||||
|
||||
# ---- manual pages ---------------------------------------------------------------
|
||||
|
||||
def troff_clean(str)
|
||||
str.gsub(/\\f[BIRP]/, '').gsub('\\e', '\\').gsub('\\(em', ' -- ').gsub('\\(aa', "'")
|
||||
.gsub('\\-', '-').gsub('\\&', '').gsub('\\*(UX', 'UNIX').gsub('``', '"').gsub("''", '"').gsub('\\ ', ' ')
|
||||
.gsub(/\\\(..|\\\*\(..|\\[a-z]/, '').gsub('\\\\', '\\')
|
||||
end
|
||||
|
||||
def render_man(src)
|
||||
out = []
|
||||
para = []
|
||||
verbatim = false
|
||||
flush = lambda do
|
||||
unless para.empty?
|
||||
words = para.join(' ').split
|
||||
line = ''
|
||||
words.each { |w| line.size + w.size + 1 > 72 ? (out << line; line = w) : line = (line.empty? ? w : "#{line} #{w}") }
|
||||
out << line unless line.empty?
|
||||
para.clear
|
||||
end
|
||||
end
|
||||
src.each_line do |raw|
|
||||
l = raw.chomp
|
||||
if l.start_with?('.')
|
||||
macro, rest = l[1..].split(/\s+/, 2)
|
||||
rest = rest.to_s.strip.delete_prefix('"').delete_suffix('"')
|
||||
case macro
|
||||
when 'TH', 'II', 'ta', 'ne', 'R', 'RE', 'SO', 'B1', 'B2', 'nf', 'fi', 'br' then nil
|
||||
when '\\"' then nil
|
||||
when 'PC' then flush.(); out << ''
|
||||
when 'SH' then flush.(); out << '' << troff_clean(rest).upcase
|
||||
when 'PP', 'sp' then flush.(); out << ''
|
||||
when 'DM', 'DS' then flush.(); verbatim = true
|
||||
when 'DE' then verbatim = false
|
||||
when 'IP' then flush.(); out << ''; para << troff_clean(rest.split(/\s+\d/).first.to_s)
|
||||
when 'B', 'I', 'BR', 'IR', 'RB', 'RI' then para << troff_clean(rest.delete('"')).gsub(/\s+/, ' ') unless verbatim
|
||||
else nil
|
||||
end
|
||||
elsif verbatim
|
||||
out << " #{troff_clean(l)}"
|
||||
elsif l.start_with?("\t", ' ')
|
||||
flush.()
|
||||
out << " #{troff_clean(l.strip)}"
|
||||
elsif l.strip.empty?
|
||||
flush.()
|
||||
else
|
||||
para << troff_clean(l.strip)
|
||||
end
|
||||
end
|
||||
flush.()
|
||||
out.join("\n").squeeze("\n")
|
||||
end
|
||||
|
||||
Rdb.register('man', 'Display the manual page for a command', 'man [-g] command') do |args|
|
||||
groff = args.first == '-g'
|
||||
args.shift if groff
|
||||
die 'usage: rdb man [-g] command (-g formats with groff)' if args.empty?
|
||||
if groff
|
||||
args.each do |a|
|
||||
f = File.join(Rdb::ROOT, 'man', "#{a == 'Number' ? 'number.c' : a}.man")
|
||||
File.exist?(f) or die("no manual page for '#{a}'")
|
||||
cmd = "groff -man -mrdb -M#{Rdb::ROOT}/man -Tutf8 #{f.shellescape}"
|
||||
$stdout.tty? ? system("#{cmd} | #{ENV['PAGER'] || 'less -R'}") : system(cmd)
|
||||
end
|
||||
next
|
||||
end
|
||||
args.each do |a|
|
||||
f = File.join(Rdb::ROOT, 'man', "#{a == 'Number' ? 'number.c' : a}.man")
|
||||
File.exist?(f) or die("no manual page for '#{a}'")
|
||||
desc = File.read(f)[/^\.PC\s+"?(.*?)"?\s*$/, 1]
|
||||
puts "#{a}#{desc ? " -- #{troff_clean(desc)}" : ''}", '=' * 60
|
||||
puts render_man(File.read(f)), ''
|
||||
end
|
||||
end
|
||||
|
||||
# ---- terminal ---------------------------------------------------------------------
|
||||
|
||||
Rdb.register('termput', 'Get terminal capability from /etc/termcap file', 'termput capability') do |args|
|
||||
die 'usage: termput capability' unless args.size == 1
|
||||
$stdout.print TERM_CAPS.fetch(args[0]) { die "no such capability '#{args[0]}'" }
|
||||
end
|
||||
|
||||
Rdb.register('tset', 'Fetch termcap entry for a terminal type', 'tset [ TERM ]') do |args|
|
||||
term = args.first || ENV['TERM'] || 'ansi'
|
||||
puts ":#{TERM_CAPS.map { |k, v| "#{k}=#{v.inspect[1..-2]}" }.join(':')}:co##{ENV['COLUMNS'] || 80}:li##{ENV['LINES'] || 24}:tc=#{term}:"
|
||||
end
|
||||
|
||||
Rdb.register('clear.rdb', "Clear the terminal's screen", 'clear.rdb') do |_args|
|
||||
$stdout.print TERM_CAPS['cl']
|
||||
end
|
||||
|
||||
Rdb.register('cursor', 'Move the cursor to the row and column requested', 'cursor row col [ CURSOR ]') do |args|
|
||||
die 'usage: cursor row col [CURSOR]' if args.size < 2
|
||||
$stdout.print "#{ESC}[#{args[0].to_i};#{args[1].to_i}H"
|
||||
$stdout.flush
|
||||
end
|
||||
|
||||
Rdb.register('menu', 'Root menu with some commands', 'menu') do |_args|
|
||||
items = [['exit', 'leave menu or return to higher menu', nil],
|
||||
['sh', 'get a shell', ENV['SHELL'] || 'sh'],
|
||||
['vi', 'edit a file', ENV['EDITOR'] || 'vi'],
|
||||
['cal', 'see your calendar', 'cal'],
|
||||
['who', 'see who is on the system', 'who'],
|
||||
['ls', 'list the files in this directory', 'ls'],
|
||||
['cat', 'display a file on the screen', 'cat'],
|
||||
['rdb', 'display rdb commands', 'rdb']]
|
||||
loop do
|
||||
Rdb::Table.new(%w[Number Name For], items.each_with_index.map { |(n, d, _), i| [i.to_s, n, d] }).write
|
||||
print 'Enter a number or name for the action you wish or DEL to exit: '
|
||||
$stdout.flush
|
||||
choice = ($stdin.gets || '').strip
|
||||
break if choice.empty? || %w[0 exit].include?(choice)
|
||||
|
||||
item = items[choice.to_i] if choice.match?(/\A\d+\z/)
|
||||
item ||= items.find { |n, _, _| n == choice }
|
||||
next puts('?') unless item
|
||||
|
||||
cmd = item[2]
|
||||
if item[0] == 'cat'
|
||||
print 'file: '
|
||||
f = ($stdin.gets || '').strip
|
||||
system('cat', f) unless f.empty?
|
||||
elsif item[0] == 'vi'
|
||||
print 'file: '
|
||||
f = ($stdin.gets || '').strip
|
||||
system(cmd, f) unless f.empty?
|
||||
elsif item[0] == 'rdb'
|
||||
Rdb.run('rdb.list', [])
|
||||
else
|
||||
system(cmd)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ---- housekeeping --------------------------------------------------------------------
|
||||
|
||||
Rdb.register('rmcore', 'Remove all core files', 'rmcore') do |_args|
|
||||
Dir.glob('**/core').select { |f| File.file?(f) }.each { |f| puts "removed #{f}"; File.delete(f) }
|
||||
end
|
||||
|
||||
Rdb.register('cpdir.rdb', 'Copy one directory tree to another directory', 'cpdir.rdb fromdirectory todirectory') do |args|
|
||||
die 'usage: cpdir.rdb fromdirectory todirectory' unless args.size == 2
|
||||
FileUtils.mkdir_p(args[1])
|
||||
FileUtils.cp_r(File.join(args[0], '.'), args[1])
|
||||
end
|
||||
|
||||
Rdb.register('testall', 'Test all /rdb programs in directory $RDB/demo', 'testall [ > testall.new ]') do |_args|
|
||||
ENV['PATH'] = "#{File.join(Rdb::ROOT, 'bin')}:#{ENV['PATH']}"
|
||||
demo = File.join(ENV['RDB'] || Rdb::ROOT, 'demo')
|
||||
Dir.chdir(demo)
|
||||
script = File.join(demo, 'testall.cmds')
|
||||
File.exist?(script) or die "#{script}: missing"
|
||||
File.readlines(script, chomp: true).reject { |l| l.strip.empty? || l.start_with?('#') }.each do |cmd|
|
||||
puts "$ #{cmd}"
|
||||
$stdout.flush
|
||||
puts `#{cmd} 2>&1`
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,239 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Formatting tables and working with text.
|
||||
|
||||
def justify_numbers(cells)
|
||||
parts = cells.map { |c| c.empty? ? ['', nil] : c.split('.', 2) }
|
||||
int_w = parts.map { |i, _| i.size }.max
|
||||
frac_w = parts.map { |_, f| f ? f.size : 0 }.max
|
||||
cells.zip(parts).map do |c, (i, f)|
|
||||
next ' ' * (int_w + (frac_w.zero? ? 0 : frac_w + 1)) if c.empty?
|
||||
|
||||
s = i.rjust(int_w)
|
||||
s += f ? ".#{f.ljust(frac_w)}" : ' ' * (frac_w.zero? ? 0 : frac_w + 1)
|
||||
s
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('justify', 'Left or right justify the columns of a table', "justify [-elrct'c'] [ column ... ] < table") do |args|
|
||||
expand = false
|
||||
mode = nil
|
||||
sep = "\t"
|
||||
names = []
|
||||
args.each do |a|
|
||||
case a
|
||||
when '-e' then expand = true
|
||||
when '-l', '-r', '-c' then mode = a[1]
|
||||
when /\A-t(.)\z/ then sep = Regexp.last_match(1)
|
||||
else names << a
|
||||
end
|
||||
end
|
||||
t = table_in
|
||||
forced = {}
|
||||
sel = names.empty? ? (0...t.head.size).to_a : names.map { |n| t.index_of(n) }
|
||||
sel.each { |i| forced[i] = mode } if mode
|
||||
cols = t.head.each_index.map { |i| t.rows.map { |r| r[i].strip } }
|
||||
head = t.head.map(&:strip)
|
||||
new_cols = []
|
||||
widths = []
|
||||
t.head.each_index do |i|
|
||||
unless names.empty? || sel.include?(i)
|
||||
new_cols << cols[i]
|
||||
widths << nil
|
||||
next
|
||||
end
|
||||
filled = cols[i].reject(&:empty?)
|
||||
numeric = !filled.empty? && filled.all? { |c| c.match?(Rdb::NUM) }
|
||||
style = forced[i] || (numeric ? 'r' : 'l')
|
||||
cells = (numeric && style == 'r') ? justify_numbers(cols[i]) : cols[i]
|
||||
w = (cells.map(&:size) + [head[i].size]).max
|
||||
w = ((w + 1 + 7) / 8 * 8 - 1) if expand
|
||||
new_cols << cells.map do |c|
|
||||
case style
|
||||
when 'r' then c.rjust(w)
|
||||
when 'c' then c.center(w)
|
||||
else c.ljust(w)
|
||||
end
|
||||
end
|
||||
head[i] = case style
|
||||
when 'r' then head[i].rjust(w)
|
||||
when 'c' then head[i].center(w)
|
||||
else head[i].ljust(w)
|
||||
end
|
||||
widths << w
|
||||
end
|
||||
dash = head.each_index.map { |i| '-' * (widths[i] || [head[i].size, 1].max) }
|
||||
puts head.join(sep)
|
||||
puts dash.join(sep)
|
||||
t.rows.each_index { |r| puts new_cols.map { |c| c[r] }.join(sep) }
|
||||
end
|
||||
|
||||
Rdb.register('trim', 'Trim excess white space from a table', "trim [-[l|n|ln] col...][-r[n] col...][-t'c']... < table") do |args|
|
||||
sep = ' '
|
||||
limits = {}
|
||||
mode = nil
|
||||
n = nil
|
||||
t = table_in
|
||||
args.each do |a|
|
||||
case a
|
||||
when /\A-t(.)\z/ then sep = Regexp.last_match(1)
|
||||
when /\A-(l?)(\d+)\z/ then mode = :left; n = Regexp.last_match(2).to_i
|
||||
when /\A-r(\d+)\z/ then mode = :right; n = Regexp.last_match(1).to_i
|
||||
else limits[t.index_of(a)] = [mode, n] if mode
|
||||
end
|
||||
end
|
||||
cols = t.head.each_index.map do |i|
|
||||
cells = t.rows.map { |r| r[i].strip }
|
||||
if (m, k = limits[i])
|
||||
cells = cells.map { |c| m == :right ? c[-k..] || c : c[0, k] }
|
||||
end
|
||||
cells
|
||||
end
|
||||
heads = t.head.map(&:strip)
|
||||
widths = t.head.each_index.map { |i| (cols[i].map(&:size) + [heads[i].size]).max }
|
||||
puts heads.each_with_index.map { |h, i| h.ljust(widths[i]) }.join(sep)
|
||||
puts widths.map { |w| '-' * w }.join(sep)
|
||||
t.rows.each_index { |r| puts cols.each_with_index.map { |c, i| c[r].ljust(widths[i]) }.join(sep) }
|
||||
end
|
||||
|
||||
Rdb.register('compresss', 'Squeeze out all leading and trailing blanks', 'compresss [-b] < tableorlist') do |args|
|
||||
drop = args.include?('-b')
|
||||
$stdin.each_line do |l|
|
||||
l = l.chomp("\n")
|
||||
next if drop && l.strip.empty?
|
||||
|
||||
puts l.split("\t", -1).map(&:strip).join("\t")
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('pad', 'Add extra spaces at end of last column', 'pad [-number] < tableorlist') do |args|
|
||||
n = args.first&.match?(/\A-\d+\z/) ? args.first[1..].to_i : 70
|
||||
t = table_in
|
||||
if t.list
|
||||
t.with(rows: t.rows.map { |r| r[0...-1] + [r.last + ' ' * n] }).write
|
||||
else
|
||||
$stdout.write t.head.join("\t"), "\n", Rdb::Table.dash_row(t.head), "\n"
|
||||
t.rows.each { |r| puts (r[0...-1] + [r.last + ' ' * n]).join("\t") }
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('rmblank', 'Remove blank rows from a table', 'rmblank < table') do |_args|
|
||||
t = table_in
|
||||
t.with(rows: t.rows.reject { |r| blank_row?(r) }).write
|
||||
end
|
||||
|
||||
Rdb.register('blank', 'Replace all data in a record with spaces', 'blank < tableorlist') do |_args|
|
||||
t = table_in
|
||||
t.with(rows: t.rows.map { |r| r.map { |c| ' ' * c.size } }).write
|
||||
end
|
||||
|
||||
Rdb.register('cap', 'Convert first letter of each word to upper case', 'cap < textfile') do |_args|
|
||||
$stdout.write $stdin.read.gsub(/\b[a-z]/, &:upcase)
|
||||
end
|
||||
|
||||
Rdb.register('uppercase', 'Convert input to all upper-case characters', 'uppercase [string ...] [< file]') do |args|
|
||||
puts args.empty? ? $stdin.read.upcase : args.join(' ').upcase
|
||||
end
|
||||
|
||||
Rdb.register('lowercase', 'Transform text to lower case', 'lowercase [string ...] [< file]') do |args|
|
||||
puts args.empty? ? $stdin.read.downcase : args.join(' ').downcase
|
||||
end
|
||||
|
||||
Rdb.register('substitute', 'Replace old string with new string', 'substitute oldstring newstring file ...') do |args|
|
||||
die 'usage: substitute oldstring newstring file ...' if args.size < 3
|
||||
old, new, *files = args
|
||||
files.each { |f| File.write(f, File.read(f).gsub(old, new)) }
|
||||
end
|
||||
|
||||
Rdb.register('trimblank', 'Remove leading and trailing blanks from a string', 'trimblank [string] [< file]') do |args|
|
||||
puts (args.empty? ? $stdin.read : args.join(' ')).strip
|
||||
end
|
||||
|
||||
Rdb.register('padstring', 'Return string with blanks to fill a field', 'padstring [-]length string') do |args|
|
||||
die 'usage: padstring [-]length string' unless args.size == 2
|
||||
w = args[0].to_i
|
||||
puts w.negative? ? args[1].ljust(-w) : args[1].rjust(w)
|
||||
end
|
||||
|
||||
Rdb.register('length', 'Return the length of its argument or input file', 'length [string] [< file]') do |args|
|
||||
puts args.empty? ? $stdin.read.bytesize : args[0].size
|
||||
end
|
||||
|
||||
Rdb.register('filesize', 'Return the number of characters in a file', 'filesize file ...') do |args|
|
||||
die 'usage: filesize file ...' if args.empty?
|
||||
if args.size == 1
|
||||
puts File.size(args[0])
|
||||
else
|
||||
Rdb::Table.new(%w[Offset Filename], args.map { |f| [File.size(f).to_s, f] }).write
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('ascii', 'Return the ASCII value of a character', 'ascii character...') do |args|
|
||||
die 'usage: ascii character...' if args.empty?
|
||||
puts args[0].bytes.join(' ') + ' '
|
||||
end
|
||||
|
||||
Rdb.register('chr', 'Display the character corresponding to a number', 'chr number ...') do |args|
|
||||
die 'usage: chr number ...' if args.empty?
|
||||
puts args.map { |a| a.to_i.chr }.join
|
||||
end
|
||||
|
||||
Rdb.register('word', 'Convert text file into list of unique words', 'word < text > table') do |_args|
|
||||
puts $stdin.read.scan(/[A-Za-z0-9_']+/).uniq.sort
|
||||
end
|
||||
|
||||
Rdb.register('prompt', 'Echo a string on the standard output', 'prompt string ...') do |args|
|
||||
$stdout.print args.join(' ')
|
||||
end
|
||||
|
||||
Rdb.register('not', 'Logical not, to reverse return status of command', 'not command ...') do |args|
|
||||
die 'usage: not command ...' if args.empty?
|
||||
system(*args)
|
||||
exit($?.success? ? 255 : 0)
|
||||
end
|
||||
|
||||
Rdb.register('path', 'Find the full path of a command', 'path [ command ... ]') do |args|
|
||||
args.each do |c|
|
||||
dir = ENV['PATH'].to_s.split(':').find { |d| File.executable?(File.join(d, c)) && !File.directory?(File.join(d, c)) }
|
||||
puts dir if dir
|
||||
end
|
||||
end
|
||||
|
||||
# ---- translation tables -----------------------------------------------------
|
||||
|
||||
Rdb.register('tabletom4', 'Convert a table to m4 define-file format', 'tabletom4 < table > definefile') do |_args|
|
||||
table_in.rows.each { |r| puts "define(#{r[0].strip},#{r[1].strip})" unless r[1].to_s.strip.empty? }
|
||||
end
|
||||
|
||||
Rdb.register('tabletosed', 'Convert table format to sed file format', 'tabletosed < table > sedfile') do |_args|
|
||||
table_in.rows.each { |r| puts "s/#{r[0].strip}/#{r[1].strip}/g" unless r[1].to_s.strip.empty? }
|
||||
end
|
||||
|
||||
Rdb.register('translate', 'Word-for-word substitution using a translation file', 'translate language < text > translatedtext') do |args|
|
||||
die 'usage: translate language < text' unless args.size == 1
|
||||
lang = args[0]
|
||||
text = $stdin.read
|
||||
sed = lambda do |file, s|
|
||||
File.readlines(file, chomp: true).reduce(s) do |acc, l|
|
||||
m = l.match(%r{\As/(.*?)/(.*?)/g\z})
|
||||
m ? acc.gsub(m[1], m[2]) : acc
|
||||
end
|
||||
end
|
||||
text = sed.("#{lang}.sed.1", text) if File.exist?("#{lang}.sed.1")
|
||||
if File.exist?(lang)
|
||||
defs = File.readlines(lang, chomp: true).filter_map { |l| l.match(/\Adefine\((.*?),(.*)\)\z/)&.captures }.to_h
|
||||
re = Regexp.union(defs.keys.sort_by { |k| -k.size }.map { |k| /\b#{Regexp.escape(k)}\b/ })
|
||||
text = text.gsub(re) { |w| defs[w] } unless defs.empty?
|
||||
end
|
||||
text = sed.("#{lang}.sed.2", text) if File.exist?("#{lang}.sed.2")
|
||||
$stdout.write text
|
||||
end
|
||||
|
||||
Rdb.register('uniondict', 'Combine three tables into translation dictionary', 'uniondict langtolang > definefile') do |args|
|
||||
die 'usage: uniondict langtolang' unless args.size == 1
|
||||
l = args[0]
|
||||
low = Rdb::Table.open("#{l}.low")
|
||||
cap = Rdb::Table.open("#{l}.allcap")
|
||||
initial = low.rows.map { |r| r.map(&:capitalize) }
|
||||
(low.rows + initial + cap.rows).each { |r| puts "define(#{r[0].strip},#{r[1].strip})" unless r[1].to_s.strip.empty? }
|
||||
end
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rdb
|
||||
# Command blocks run inside an Env, which holds the helpers they share.
|
||||
class Env
|
||||
attr_reader :name
|
||||
|
||||
def initialize(name)
|
||||
@name = name
|
||||
end
|
||||
|
||||
def die(msg)
|
||||
Rdb.die(msg)
|
||||
end
|
||||
|
||||
def table_in
|
||||
Table.read($stdin)
|
||||
end
|
||||
|
||||
def open_table(path)
|
||||
File.exist?(path) || path == '-' or die("#{path}: no such table")
|
||||
Table.open(path)
|
||||
end
|
||||
|
||||
def numeric?(str)
|
||||
str.match?(NUM)
|
||||
end
|
||||
|
||||
def decimals(str)
|
||||
str.strip[/\.(\d+)\z/, 1].to_s.size
|
||||
end
|
||||
|
||||
def fmt(num, dec)
|
||||
dec.zero? ? num.round.to_s : format("%.#{dec}f", num)
|
||||
end
|
||||
|
||||
def blank_row?(row)
|
||||
row.all? { |c| c.strip.empty? }
|
||||
end
|
||||
|
||||
# Replace column names in an awk expression with $N, leaving strings,
|
||||
# regexes and other identifiers alone.
|
||||
def to_awk(expr, head)
|
||||
names = head.each_with_index.sort_by { |n, _| -n.size }
|
||||
out = +''
|
||||
i = 0
|
||||
while i < expr.size
|
||||
c = expr[i]
|
||||
prev = out.rstrip[-1].to_s
|
||||
if c == '"'
|
||||
j = i + 1
|
||||
j += expr[j] == '\\' ? 2 : 1 while j < expr.size && expr[j] != '"'
|
||||
out << expr[i..j]
|
||||
i = j + 1
|
||||
elsif c == '/' && (prev.empty? || '~(!&|,={;'.include?(prev))
|
||||
j = i + 1
|
||||
j += expr[j] == '\\' ? 2 : 1 while j < expr.size && expr[j] != '/'
|
||||
out << expr[i..j]
|
||||
i = j + 1
|
||||
elsif c.match?(/[A-Za-z_#]/) && (i.zero? || !expr[i - 1].match?(/[\w$]/))
|
||||
hit = names.find { |n, _| !n.empty? && expr[i, n.size] == n && !expr[i + n.size].to_s.match?(/\w/) }
|
||||
if hit
|
||||
out << "$#{hit[1] + 1}"
|
||||
i += hit[0].size
|
||||
else
|
||||
m = expr[i..][/\A\w+/] || c
|
||||
out << m
|
||||
i += m.size
|
||||
end
|
||||
else
|
||||
out << c
|
||||
i += 1
|
||||
end
|
||||
end
|
||||
out
|
||||
end
|
||||
|
||||
# Run an awk program over rows (tab separated) and return the output rows.
|
||||
def awk(prog, rows)
|
||||
input = rows.map { |r| r.join("\t") }.join("\n")
|
||||
input += "\n" unless input.empty?
|
||||
out, err, st = Open3.capture3('awk', '-F', "\t", prog, stdin_data: input)
|
||||
$stderr.print err unless err.empty?
|
||||
exit 1 unless st.success?
|
||||
out.each_line.map { |l| l.chomp.split("\t", -1) }
|
||||
end
|
||||
|
||||
# Raw output of awk, for programs that print their own text.
|
||||
def awk_text(prog, rows)
|
||||
input = rows.map { |r| r.join("\t") }.join("\n")
|
||||
input += "\n" unless input.empty?
|
||||
out, err, st = Open3.capture3('awk', '-F', "\t", prog, stdin_data: input)
|
||||
$stderr.print err unless err.empty?
|
||||
[out, st.success?]
|
||||
end
|
||||
|
||||
def sh(cmd, stdin: nil)
|
||||
out, _err, _st = Open3.capture3('sh', '-c', cmd, stdin_data: stdin.to_s)
|
||||
out
|
||||
end
|
||||
|
||||
# YYMMDD <-> day number. The day number counts from the same epoch as
|
||||
# /rdb's julian command (1986-12-22 is 1752827).
|
||||
JULIAN_OFFSET = 693_960
|
||||
|
||||
def to_julian(str, fmt = 'c')
|
||||
s = str.strip
|
||||
return '' if s.empty?
|
||||
|
||||
y, m, d =
|
||||
case fmt
|
||||
when 'e' then s.split('/').values_at(2, 1, 0).map(&:to_i)
|
||||
when 'u' then s.split('/').values_at(2, 0, 1).map(&:to_i)
|
||||
else
|
||||
s.size == 8 ? [s[0, 4], s[4, 2], s[6, 2]].map(&:to_i) : [s[0, 2], s[2, 2], s[4, 2]].map(&:to_i)
|
||||
end
|
||||
y += (y >= 50 ? 1900 : 2000) if y < 100
|
||||
(Date.new(y, m, 1) + (d - 1)).jd - JULIAN_OFFSET
|
||||
end
|
||||
|
||||
def from_julian(num, fmt = 'c')
|
||||
s = num.to_s.strip
|
||||
return '' if s.empty?
|
||||
|
||||
dt = Date.jd(s.to_i + JULIAN_OFFSET)
|
||||
case fmt
|
||||
when 'e' then format('%02d/%02d/%02d', dt.day, dt.month, dt.year % 100)
|
||||
when 'u' then format('%02d/%02d/%02d', dt.month, dt.day, dt.year % 100)
|
||||
else format('%02d%02d%02d', dt.year % 100, dt.month, dt.day)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rdb
|
||||
# Fast-access methods for tables. Rows are found by byte offset, so a record
|
||||
# can be blanked or replaced in place without rewriting the file.
|
||||
#
|
||||
# -ms sequential (no index file) -mb binary search on a sorted table
|
||||
# -mr record numbers table.r -mi inverted index table.i
|
||||
# -mh hash table.h
|
||||
#
|
||||
# table.r and table.h hold fixed-width entries (a head line, a dash line,
|
||||
# then "%8d\n" per entry), so one entry can be rewritten in place.
|
||||
module Fast
|
||||
ENTRY_WIDTH = 9
|
||||
SUFFIX = { 'h' => '.h', 'i' => '.i', 'r' => '.r' }.freeze
|
||||
FIXED_HEAD = "Offset\n------\n"
|
||||
|
||||
Row = Struct.new(:from, :to, :line) do
|
||||
def cells
|
||||
@cells ||= line.split("\t", -1)
|
||||
end
|
||||
|
||||
def blank?
|
||||
line.delete("\t ").empty?
|
||||
end
|
||||
end
|
||||
|
||||
Hit = Struct.new(:row, :xfrom, :xto)
|
||||
|
||||
# A table file read as bytes with the offset of every row.
|
||||
class Data
|
||||
attr_reader :path, :head, :rows
|
||||
|
||||
def initialize(path)
|
||||
@path = path
|
||||
File.exist?(path) or Rdb.die("#{path}: no such table")
|
||||
bytes = File.binread(path)
|
||||
lines = []
|
||||
off = 0
|
||||
bytes.each_line("\n") do |l|
|
||||
lines << Row.new(off, off + l.bytesize, l.chomp("\n"))
|
||||
off += l.bytesize
|
||||
end
|
||||
Rdb.die("#{path}: is a list; fast access needs a table") if bytes.start_with?("\n")
|
||||
@head = lines.shift&.line.to_s.split("\t", -1)
|
||||
lines.shift if lines.first && Table.dash_line?(lines.first.cells)
|
||||
@rows = lines
|
||||
@by_from = lines.to_h { |r| [r.from, r] }
|
||||
end
|
||||
|
||||
def live
|
||||
@live ||= @rows.reject(&:blank?)
|
||||
end
|
||||
|
||||
def column(name)
|
||||
@head.index(name) || Rdb.die("#{path}: no column named '#{name}'")
|
||||
end
|
||||
|
||||
def row_at(offset)
|
||||
@by_from[offset]
|
||||
end
|
||||
end
|
||||
|
||||
module_function
|
||||
|
||||
def index_path(path, method)
|
||||
SUFFIX[method] ? path + SUFFIX[method] : nil
|
||||
end
|
||||
|
||||
def key_of(cells, idx)
|
||||
cells[idx].to_s
|
||||
end
|
||||
|
||||
def norm(str, fold)
|
||||
s = str.to_s.strip
|
||||
fold ? s.downcase : s
|
||||
end
|
||||
|
||||
def fmt_entry(num)
|
||||
format("%8d\n", num)
|
||||
end
|
||||
|
||||
def hash_slot(slots, key)
|
||||
key.delete(" \t").bytes.sum % slots
|
||||
end
|
||||
|
||||
def live_rows(data)
|
||||
data.live
|
||||
end
|
||||
|
||||
# ---- building indexes -------------------------------------------------
|
||||
|
||||
def build(path, method, keycol, fold: false)
|
||||
data = Data.new(path)
|
||||
rows = live_rows(data)
|
||||
case method
|
||||
when 'r'
|
||||
File.binwrite(path + '.r', FIXED_HEAD + rows.map { |r| fmt_entry(r.from) }.join)
|
||||
when 'i'
|
||||
k = data.column(keycol)
|
||||
ents = rows.map { |r| [r.cells[k].to_s, r.from] }
|
||||
ents = ents.sort_by { |key, off| [fold ? key.downcase : key, off] }
|
||||
File.binwrite(path + '.i', "#{keycol}\tOffset\n#{'-' * keycol.size}\t------\n" + ents.map { |key, off| "#{key}\t#{off}\n" }.join)
|
||||
when 'h'
|
||||
k = data.column(keycol)
|
||||
slots = [rows.size * 2, 2].max
|
||||
table = Array.new(slots, 0)
|
||||
rows.each { |r| table[free_slot(table, hash_slot(slots, r.cells[k].to_s))] = r.from }
|
||||
File.binwrite(path + '.h', FIXED_HEAD + table.map { |o| fmt_entry(o) }.join)
|
||||
when 'b'
|
||||
k = data.column(keycol)
|
||||
body = live_rows(data).sort_by { |r| r.cells[k].to_s }.map { |r| r.line + "\n" }
|
||||
File.binwrite(path, data.head.join("\t") + "\n" + Table.dash_row(data.head) + "\n" + body.join)
|
||||
end
|
||||
end
|
||||
|
||||
def free_slot(table, start)
|
||||
i = start
|
||||
i = (i + 1) % table.size while table[i] != 0 && table[i] != -1
|
||||
i
|
||||
end
|
||||
|
||||
# ---- searching ----------------------------------------------------------
|
||||
|
||||
def read_fixed(path)
|
||||
File.binread(path).byteslice(FIXED_HEAD.bytesize..).to_s.lines.map { |l| l.to_i }
|
||||
end
|
||||
|
||||
def read_inverted(path)
|
||||
off = 0
|
||||
ents = []
|
||||
File.binread(path).each_line("\n").with_index do |l, n|
|
||||
ents << { from: off, to: off + l.bytesize, key: l.chomp("\n").split("\t", 2)[0], offset: l.chomp("\n").split("\t", 2)[1].to_i } if n >= 2
|
||||
off += l.bytesize
|
||||
end
|
||||
ents
|
||||
end
|
||||
|
||||
def matches?(cell, key, partial, exact)
|
||||
if partial
|
||||
norm(cell, true).start_with?(norm(key, true))
|
||||
elsif exact
|
||||
cell == key
|
||||
else
|
||||
cell.strip == key.strip
|
||||
end
|
||||
end
|
||||
|
||||
# Find rows for one key. method is one of s b h i r; keycols are column names.
|
||||
def search(data, method, keycols, key, first: false, partial: false, exact: false)
|
||||
path = data.path
|
||||
idxs = keycols.map { |c| data.column(c) }
|
||||
keyvals = Array(key)
|
||||
hits = []
|
||||
case method
|
||||
when 'b'
|
||||
rows = live_rows(data)
|
||||
k = idxs[0]
|
||||
lo = rows.bsearch_index { |r| (partial ? norm(r.cells[k], true)[0, keyvals[0].strip.size] : r.cells[k].strip) >= (partial ? norm(keyvals[0], true) : keyvals[0].strip) }
|
||||
if lo
|
||||
rows[lo..].each do |r|
|
||||
break unless matches?(r.cells[k], keyvals[0], partial, exact)
|
||||
|
||||
hits << Hit.new(r, 0, 8)
|
||||
break if first
|
||||
end
|
||||
end
|
||||
when 'r'
|
||||
ents = read_fixed(path + '.r')
|
||||
n = keyvals[0].to_i
|
||||
if n.positive? && n <= ents.size
|
||||
r = data.row_at(ents[n - 1]) and hits << Hit.new(r, FIXED_HEAD.bytesize + ENTRY_WIDTH * (n - 1), FIXED_HEAD.bytesize + ENTRY_WIDTH * n)
|
||||
end
|
||||
when 'i'
|
||||
ents = read_inverted(path + '.i')
|
||||
lo = ents.bsearch_index { |e| (partial ? norm(e[:key], true)[0, keyvals[0].strip.size] : e[:key].strip) >= (partial ? norm(keyvals[0], true) : keyvals[0].strip) }
|
||||
if lo
|
||||
ents[lo..].each do |e|
|
||||
break unless matches?(e[:key], keyvals[0], partial, exact)
|
||||
|
||||
r = data.row_at(e[:offset])
|
||||
hits << Hit.new(r, e[:from], e[:to]) if r
|
||||
break if first
|
||||
end
|
||||
end
|
||||
when 'h'
|
||||
ents = read_fixed(path + '.h')
|
||||
slots = ents.size
|
||||
i = hash_slot(slots, keyvals[0])
|
||||
slots.times do
|
||||
o = ents[i]
|
||||
break if o.zero?
|
||||
|
||||
if o.positive? && (r = data.row_at(o)) && matches?(r.cells[idxs[0]], keyvals[0], false, exact)
|
||||
hits << Hit.new(r, FIXED_HEAD.bytesize + ENTRY_WIDTH * i, FIXED_HEAD.bytesize + ENTRY_WIDTH * (i + 1))
|
||||
break if first
|
||||
end
|
||||
i = (i + 1) % slots
|
||||
end
|
||||
else
|
||||
live_rows(data).each do |r|
|
||||
c = r.cells
|
||||
next unless idxs.each_with_index.all? { |ci, n| matches?(c[ci].to_s, keyvals[n].to_s, partial, exact) }
|
||||
|
||||
hits << Hit.new(r, 0, 8)
|
||||
break if first
|
||||
end
|
||||
end
|
||||
hits
|
||||
end
|
||||
|
||||
# ---- changing rows --------------------------------------------------------
|
||||
|
||||
def blanked(line)
|
||||
line.gsub(/[^\t]/, ' ')
|
||||
end
|
||||
|
||||
def splice(path, from, to, text)
|
||||
b = File.binread(path)
|
||||
File.binwrite(path, b.byteslice(0, from) + text + b.byteslice(to..).to_s)
|
||||
end
|
||||
|
||||
# Remove a record's entry from the secondary index for method.
|
||||
def index_remove(path, method, row_from)
|
||||
case method
|
||||
when 'r'
|
||||
ip = path + '.r'
|
||||
ents = read_fixed(ip)
|
||||
ents.delete(row_from)
|
||||
File.binwrite(ip, FIXED_HEAD + ents.map { |o| fmt_entry(o) }.join)
|
||||
when 'h'
|
||||
ip = path + '.h'
|
||||
ents = read_fixed(ip).map { |o| o == row_from ? -1 : o }
|
||||
File.binwrite(ip, FIXED_HEAD + ents.map { |o| fmt_entry(o) }.join)
|
||||
when 'i'
|
||||
ip = path + '.i'
|
||||
e = read_inverted(ip).find { |x| x[:offset] == row_from }
|
||||
splice(ip, e[:from], e[:to], '') if e
|
||||
end
|
||||
end
|
||||
|
||||
def index_add(path, method, row_from, cells, data_head, keycol)
|
||||
case method
|
||||
when 'r'
|
||||
File.binwrite(path + '.r', File.binread(path + '.r') + fmt_entry(row_from))
|
||||
when 'i'
|
||||
ip = path + '.i'
|
||||
name = File.binread(ip).lines.first.split("\t")[0]
|
||||
key = cells[data_head.index(name)].to_s
|
||||
ents = read_inverted(ip)
|
||||
pos = ents.find { |e| e[:key] > key }
|
||||
line = "#{key}\t#{row_from}\n"
|
||||
if pos then splice(ip, pos[:from], pos[:from], line)
|
||||
else File.binwrite(ip, File.binread(ip) + line)
|
||||
end
|
||||
when 'h'
|
||||
ip = path + '.h'
|
||||
ents = read_fixed(ip)
|
||||
if ents.any? { |o| o <= 0 }
|
||||
key = cells[data_head.index(keycol)].to_s
|
||||
ents[free_slot(ents, hash_slot(ents.size, key))] = row_from
|
||||
File.binwrite(ip, FIXED_HEAD + ents.map { |o| fmt_entry(o) }.join)
|
||||
else
|
||||
build(path, 'h', keycol)
|
||||
end
|
||||
when 'b'
|
||||
build(path, 'b', keycol)
|
||||
end
|
||||
end
|
||||
|
||||
def append_row(path, cells, method, keycol)
|
||||
line = cells.join("\t")
|
||||
b = File.binread(path)
|
||||
b += "\n" unless b.empty? || b.end_with?("\n")
|
||||
from = b.bytesize
|
||||
File.binwrite(path, b + line + "\n")
|
||||
index_add(path, method, from, cells, Data.new(path).head, keycol) if SUFFIX[method] || method == 'b'
|
||||
from
|
||||
end
|
||||
|
||||
def delete_hit(path, hit, method)
|
||||
r = hit.row
|
||||
splice(path, r.from, r.to, blanked(r.line) + "\n")
|
||||
index_remove(path, method, r.from)
|
||||
end
|
||||
|
||||
# Put cells where the record at hit was. If the new record is too long
|
||||
# even after trimming blanks, append it (unless append is false).
|
||||
def replace_row(path, from, to, cells, method, xfrom, xto, keycol, allow_append: true)
|
||||
room = to - from - 1
|
||||
line = cells.join("\t")
|
||||
line = cells.map(&:strip).join("\t") if line.bytesize > room
|
||||
if line.bytesize <= room
|
||||
splice(path, from, to, line + ' ' * (room - line.bytesize) + "\n")
|
||||
return from
|
||||
end
|
||||
return nil unless allow_append
|
||||
|
||||
old = File.binread(path).byteslice(from, room)
|
||||
splice(path, from, to, blanked(old) + "\n")
|
||||
newfrom = append_row(path, cells.map(&:strip), 'x', keycol)
|
||||
reindex_entry(path, method, xfrom, xto, newfrom, cells, keycol)
|
||||
newfrom
|
||||
end
|
||||
|
||||
def reindex_entry(path, method, xfrom, xto, newfrom, cells, keycol)
|
||||
case method
|
||||
when 'r', 'h'
|
||||
splice(path + SUFFIX[method], xfrom, xto, fmt_entry(newfrom))
|
||||
when 'i'
|
||||
ip = path + '.i'
|
||||
name = File.binread(ip).lines.first.split("\t")[0]
|
||||
head = Data.new(path).head
|
||||
splice(ip, xfrom, xto, "#{cells[head.index(name)].to_s.strip}\t#{newfrom}\n")
|
||||
end
|
||||
end
|
||||
|
||||
# Read keys: a table of keys, or one key per line.
|
||||
def read_keys(io, ncols)
|
||||
text = io.read.to_s
|
||||
return [] if text.empty?
|
||||
|
||||
lines = text.lines.map { |l| l.chomp("\n") }
|
||||
if lines.size >= 2 && Table.dash_line?(lines[1].split("\t", -1))
|
||||
lines.drop(2).reject(&:empty?).map { |l| l.split("\t", -1).first(ncols) }
|
||||
else
|
||||
lines.reject(&:empty?).map { |l| l.split("\t", -1).first(ncols) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,97 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rdb
|
||||
# A table is a head line of column names, a dash line, then one
|
||||
# tab-separated row per line. A list is the same data turned sideways:
|
||||
# it starts with a newline and holds "Column<tab>value" lines.
|
||||
class Table
|
||||
attr_reader :head, :rows
|
||||
attr_accessor :list
|
||||
|
||||
def initialize(head, rows, list: false)
|
||||
@head = head
|
||||
@rows = rows
|
||||
@list = list
|
||||
end
|
||||
|
||||
def self.dash_line?(cells)
|
||||
cells.all? { |c| c.match?(/\A\s*-*\s*\z/) } && cells.any? { |c| c.include?('-') }
|
||||
end
|
||||
|
||||
def self.parse(text)
|
||||
return parse_list(text) if text.start_with?("\n")
|
||||
|
||||
lines = text.lines.map { |l| l.chomp("\n") }
|
||||
head = (lines.shift || '').split("\t", -1)
|
||||
lines.shift if lines.first && dash_line?(lines.first.split("\t", -1))
|
||||
rows = lines.map { |l| l.split("\t", -1) }
|
||||
new(head, rows.map { |r| r.fill('', r.size...head.size) })
|
||||
end
|
||||
|
||||
def self.parse_list(text)
|
||||
head = []
|
||||
records = []
|
||||
rec = nil
|
||||
text.sub(/\A\n/, '').each_line do |line|
|
||||
line = line.chomp("\n")
|
||||
if line.strip.empty?
|
||||
rec = nil
|
||||
next
|
||||
end
|
||||
name, value = line.split("\t", 2)
|
||||
value ||= ''
|
||||
head << name unless head.include?(name)
|
||||
rec = nil if rec && name == head.first && rec.key?(name)
|
||||
unless rec
|
||||
rec = {}
|
||||
records << rec
|
||||
end
|
||||
rec[name] = value
|
||||
end
|
||||
new(head, records.map { |r| head.map { |h| r.fetch(h, '') } }, list: true)
|
||||
end
|
||||
|
||||
def self.read(io)
|
||||
parse(io.read)
|
||||
end
|
||||
|
||||
def self.open(path)
|
||||
path == '-' ? read($stdin) : parse(File.read(path))
|
||||
end
|
||||
|
||||
def index_of(name)
|
||||
@head.index(name) || Rdb.die("no column named '#{name}'")
|
||||
end
|
||||
|
||||
def to_s
|
||||
return list_text if @list
|
||||
|
||||
out = +''
|
||||
out << @head.join("\t") << "\n"
|
||||
out << Table.dash_row(@head) << "\n"
|
||||
@rows.each { |r| out << r.join("\t") << "\n" }
|
||||
out
|
||||
end
|
||||
|
||||
def self.dash_row(head)
|
||||
head.map { |h| '-' * [h.size, 1].max }.join("\t")
|
||||
end
|
||||
|
||||
def list_text
|
||||
out = +"\n"
|
||||
@rows.each do |r|
|
||||
@head.each_with_index { |h, i| out << h << "\t" << r[i].to_s << "\n" }
|
||||
out << "\n"
|
||||
end
|
||||
out
|
||||
end
|
||||
|
||||
def write(out = $stdout)
|
||||
out.write to_s
|
||||
end
|
||||
|
||||
def with(head: @head, rows: @rows)
|
||||
Table.new(head, rows, list: @list)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user