#!/usr/bin/env lua local version = 'v1.0.0' local socket = require("socket") local urlparser = require("socket.url") local separator = '- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -\n' local valid_types = {'0', '1', 'i', '7'} local download_types = {I = 'IMG', h = 'HTM', ['9'] = 'BIN', g = 'GIF', s = 'SND'} session = { tabs = { }, favorites = {} } local save_file_location = string.format('%s/.stubbfaves', assert(os.getenv('HOME'), 'Unable to get home directory')) local search_provider = 'gopher://gopher.floodgap.com:70/7/v2/vs' --||__||--||__||--||__||--||__||--||__||--||__||--||__||--|| ------------------------------------------------------------ -- Helper methods ------------------------------------------ ------------------------------------------------------------ function string.split(str, sep) assert(str and sep, 'sting.split requires both an input string and a field separator string') local start = 1 local t = {} while true do local out local point = string.find(str, sep, start) if point == start then out = '' elseif not point then out = string.sub(str, start) else out = string.sub(str, start, point - 1) end table.insert(t, out) start = point and point + 1 or nil if not start then break end end return t end function table.out(t) for i, v in pairs(t) do print(i, string.format('%q', v)) end end -- Checks to see if an autoindexed table has a value table.has = function(t, item) for _, v in ipairs(t) do if v == item then return true end end return false end table.truncate = function(t, i) while t[i] do table.remove(t, i) end end function invalid_key(key) if key == 'enter' then io.write('\27[2A\27[200D\27[K\27[1A\27[200D\27[K') elseif key == 'other' then io.write('\27[1A\27[200D\27[K') else io.write('\27[1A\27[200D\27[K\27[1A\27[200D\27[K') end end --||__||--||__||--||__||--||__||--||__||--||__||--||__||--|| ------------------------------------------------------------ -- TCP Requests and processing ----------------------------- ------------------------------------------------------------ function request_gopher(host, port, query, gtype) local tcp = assert(socket.tcp()) local response = {} tcp:settimeout(4, 't') local connection = tcp:connect(host, port); local request if connection then request = tcp:send(query.."\n"); end if request then if gtype ~= 'h' and download_types[gtype] then local s = tcp:receive('*a') response = s else while true do local s, status, partial = tcp:receive() if status == "closed" then break end if s ~= '.' then table.insert(response, (s or partial)) end end end elseif connection then response[1] = 'iConnection error. Bad request.' tcp:close() else response[1] = 'iConnection error. Bad host.' end tcp:close() return response end function parse_url(u) -- takes URL string if not string.find(u, '://', 1, true) then u = 'gopher://'..u end local default = {port=70, scheme='gopher', path=''} local parsed = urlparser.parse(u, default) if string.find(parsed.path, '^/[%d%a]') then parsed.gophertype = string.sub(parsed.path, 2, 2) parsed.path = string.sub(parsed.path, 3) elseif string.sub(parsed.path, -1) == '/' or parsed.path == '' then parsed.gophertype = '1' else parsed.gophertype = '0' end parsed.full = parsed.scheme .. '://' .. parsed.host .. ':' .. parsed.port .. '/' .. parsed.gophertype .. parsed.path return parsed end function go_to_url(u, add2history, noprint) local url = parse_url(u) if url.scheme == 'http' then print('Attempting to open http link in "lynx"...') os.execute(string.format('lynx %s', url.scheme .. '://' .. url.authority .. url.path)) print('Operation complete') return true end local t = session.tabs[session.current_tab] if not url.host or url.scheme ~= 'gopher' then print(separator) print('Invalid url') elseif table.has(valid_types, url.gophertype) or url.gophertype == 'h' then if url.query ~= '' and url.query then url.path = url.path .. '?' .. url.query end t.filedata = request_gopher(url.host, url.port, url.path, url.gophertype) print(separator) local res = handle_response(t.filedata, url.gophertype) if noprint then return res else print(res) end local o = url.scheme .. '://' .. url.host .. ':' .. url.port .. '/' .. url.gophertype .. url.path print('\n\n') print(o) if add2history then table.truncate(t.history.items, t.history.loc + 1) table.insert(t.history.items, o) t.history.loc = t.history.loc + 1 end elseif download_types[url.gophertype] then download_file(url) end end function download_file(u) print(separator) local save_path = assert(os.getenv('HOME')) local fn = nil io.write('Save file as: ') fn = io.read() if not fn or fn == '' then return print('Cancelled') end io.write(string.format('Saving as %q, is this correct? ', fn)) local r = string.lower(io.read()) if r ~= 'y' and r ~= 'yes' then return false end print(string.format('Downloading %q...', fn)) local data = request_gopher(u.host, u.port, u.path, u.gophertype) if not data then return false end local f = io.open(save_path .. '/' .. fn, 'wb') f:write(data) f:close() print(string.format('%q saved to %s/%s', fn, save_path, fn)) end function handle_response(res_table, gtype) local out = '' if gtype == '0' or gtype == 0 or gtype == 'h' then out = table.concat(res_table, '\n') else session.tabs[session.current_tab].current_links = {} for i, v in ipairs(res_table) do out = out .. (display_gophermap_row(v) or '').. '\n' end end return out end function display_gophermap_row(row) local t = session.tabs[session.current_tab] local val = string.split(row, '\t') local gophertype = string.sub(val[1], 1, 1) local text = string.sub(val[1], 2) local spacer = ' ' if gophertype == 'i' then return spacer .. (text or '') elseif table.has(valid_types, gophertype) and gophertpe ~= '7' then local leader = gophertype == '0' and 'TXT' or 'MAP' local url = string.format('%s:%s/%s%s', val[3] or '', val[4] or '', gophertype, val[2] or '/') table.insert(t.current_links, url) local linknum = string.format('(%d)', #t.current_links) return string.format(' %s %5s %s', leader, linknum, text) or '' else local leader = download_types[gophertype] if not leader then return '' end if gophertype == 'h' and string.sub(val[2],1,3) == 'URL' then table.insert(t.current_links, string.sub(val[2], 5)) else local url = string.format('%s:%s/%s%s', val[3] or '', val[4] or '', gophertype, val[2] or '/') table.insert(t.current_links, url) end local linknum = string.format('(%d)', #t.current_links) return string.format(' %s %5s %s', leader, linknum, text) or '' end end function getch() -- taken from: http://lua.2524044.n2.nabble.com/How-to-get-one-keystroke-without-hitting-Enter-td5858614.html os.execute("stty cbreak /dev/tty 2>&1") local key = io.read(1) if string.byte(key) == 27 then io.read(2) end os.execute("stty -cbreak /dev/tty 2>&1"); print('\27[1D ') key = string.byte(key) == string.byte('\n') and 'enter' or key return string.lower(key); end --||__||--||__||--||__||--||__||--||__||--||__||--||__||--|| ------------------------------------------------------------ -- Initialize and run application -------------------------- ------------------------------------------------------------ function init() if arg[1] == '-h' or arg[1] == '--help' then print_help() os.exit(0) end new_tab() if file_exists(save_file_location) then dofile(save_file_location) end mainloop() end function mainloop() local th = session.tabs[session.current_tab].history print('\n') while true do local bar = string.format('\27[7m o--[ S t u b b ]--> (H)elp, (Q)uit TAB:%d/%d HST: %d/%d \27[0m', session.current_tab, #session.tabs,th.loc, #th.items) print(bar) local key = getch() io.write(string.char(27) .. "[100D") io.write(string.char(27) .. "[1A") io.write(string.char(27) .. "[K") if key == 'q' then save_favorites(); os.exit(0) elseif key == 'h' then print_help() elseif key == 's' then search() elseif key == 'b' then back() elseif key == 'f' then forward() elseif key == 'u' then update_favorite() elseif key == 'r' then refresh() elseif key == 'p' then pipe() elseif key == 'a' then add() elseif key == 'l' then list() elseif key == 'd' then delete() elseif key == 'g' then go_to() elseif key == 'c' then clean_screen() elseif key == 'enter' then io.write('\27[2A\27[200D\27[K') else print('\27[2A\27[200D\27[K') end end end --||__||--||__||--||__||--||__||--||__||--||__||--||__||--|| ------------------------------------------------------------ -- Top Level Menu Options ---------------------------------- ------------------------------------------------------------ function save_favorites() local file = io.open(save_file_location, 'w+') file:write("session.favorites = {\n") for _,v in ipairs(session.favorites) do file:write(string.format(" {[%q] = %q, [%q] = %q},\n", 'display', v.display, 'link', v.link)) end file:write("}\n") file:close() end function print_help() print(separator) print('\nStubb ' .. version) local helpstring = [[ Stubb is a text based gopher client gophertypes 0 (text) and 1 (gopher map) are rendered to the screen, all other types are downloaded to a user's home folder. The application is named for the character Stubb from Moby Dick known for his imaginative patter and good humor. The command bar lists the current tab number, out of how many total tabs. It also lists the current place in history (for the tab you are currently on). Command map: (B)ack (F)orward (G)o to url - (B)ookmark - (L)ink - (T)ab - (U)rl (R)eload current address (P)ipe to - (R)eader - (C)urrent - (L)ink - (T)ab - (C)urrent - (L)ink - (D)isk - (C)urrent - (L)ink (S)earch (A)dd - (B)ookmark - (T)ab (D)elete - (B)ookmark - (T)ab (L)ist - (B)ookmarks - (T)abs (U)pdate bookmark title (C)lean the screen (H)elp (Q)uit Stubb For more information, source code, or to get a copy: http://tildegit.org/sloum/stubb Stubb is free, as in freedom, software. Modify it as you see fit, give it to whoever you want. It would be great if you referenced the original or cross linked, but it is not required. ]] print(helpstring) end function search() io.write('Enter your search terms: ') local st = io.read() if not st or st == '' then return print('Invalid search entry') end if url ~= '!' and url ~= '' then go_to_url(search_provider .. '\t' .. st, true) else print('Cancelled') end end function back() local th = session.tabs[session.current_tab].history if th.loc > 1 and #th.items > 1 then th.loc = th.loc - 1 go_to_url(th.items[th.loc]) else invalid_key('other') end end function forward() local th = session.tabs[session.current_tab].history if th.loc < #th.items then th.loc = th.loc + 1 go_to_url(th.items[th.loc]) else invalid_key('other') end end function update_favorite() local title io.write('Enter the bookmark id (! to cancel): ') local favid = io.read() if favid == '!' then return print('Cancelled') end local id = tonumber(favid) or tonumber(string.sub(favid,2)) if id then local item = session.favorites[id] if item then print(string.format('Old title: %s', item.display)) while not title or title == '' do io.write('Enter new title (! to cancel): ') title = io.read() if title == '!' then return print('Cancelled') end end item.display = title print('Bookmark has been updated') save_favorites() else print('Invalid bookmark id') end else print('Invalid bookmark id') end end function refresh() local th = session.tabs[session.current_tab].history if th.loc > 0 then go_to_url(th.items[th.loc]) end end function pipe() io.write('Pipe to: (D)isk, (R)eader, New (T)ab') local subkey = getch() if subkey == 'd' then save_file() elseif subkey == 't' then open_in_tab() elseif subkey == 'r' then open_in_less() else invalid_key(subkey) end end function add() io.write('Add: (B)ookmark, (T)ab') local subkey = getch() if subkey == 'b' then add_favorite() elseif subkey == 't' then new_tab() else invalid_key(subkey) end end function list() io.write('List: (B)ookmarks, (T)abs') local subkey = getch() if subkey == 'b' then show_favorites() elseif subkey == 't' then show_tabs() else invalid_key(subkey) end end function delete() io.write('Delete: (B)ookmark, (T)ab') local subkey = getch() if subkey == 'b' then remove_favorite() elseif subkey == 't' then remove_tab() else invalid_key(subkey) end end function go_to() io.write('Go to: (B)ookmark, (L)ink, (T)ab, (U)rl') local subkey = getch() if subkey == 'b' then go_to_link(true) elseif subkey == 'l' then go_to_link() elseif subkey == 't' then io.write('Enter tab id (! to cancel): ') local t = io.read() local tabid = tonumber(t) if t ~= '!' and tabid and session.tabs[tabid] then session.current_tab = tabid refresh() else local x = tabid and print('Invalid tab id') or print('Cancelled') end elseif subkey == 'u' then io.write('Enter gopher url (! to cancel): ') local url = io.read() if url ~= '!' and url ~= '' then go_to_url(url, true) else print('Cancelled') end else invalid_key(subkey) end end function clean_screen() os.execute('clear') end --||__||--||__||--||__||--||__||--||__||--||__||--||__||--|| ------------------------------------------------------------ -- Secondary actions --------------------------------------- ------------------------------------------------------------ function go_to_link(favorite) local t = session.tabs[session.current_tab] local linkid repeat io.write('Enter link id (! to cancel): ') local inp = io.read() if inp == '!' then return print('Cancelled') end linkid = tonumber(inp) if not linkid and string.sub(inp, 1, 1) == 'f' then linkid = tonumber(string.sub(inp, 2)) end until linkid local linkurl if favorite then selection = session.favorites[linkid] if selection then linkurl = session.favorites[linkid].link else return print('Invalid bookmark id') end else linkurl = t.current_links[linkid] end if linkurl then table.truncate(t.history.items, t.history.loc + 1) go_to_url(linkurl, true) else local out = string.format('Link id %d does not exist...', linkid) print_gopher_error(out) end end function open_in_tab() local t = session.tabs[session.current_tab] local sub_menu = 'Pipe what: (C)urrent, (L)ink' local favorite io.write('Pipe what: (C)urrent, (L)ink') local subkey = getch() if subkey == 'c' then local target_url = t.history.items[t.history.loc] if not target_url then return print('No current page. Cancelled.') end new_tab() go_to_url(target_url, true) elseif subkey == 'l' then local linkid repeat io.write('Enter link id (! to cancel): ') local inp = io.read() if inp == '!' then return print('Cancelled') end linkid = tonumber(inp) if not linkid and string.sub(inp, 1, 1) == 'f' then linkid = tonumber(string.sub(inp, 2)) favorite = true end until linkid local linkurl if favorite then selection = session.favorites[linkid] if selection then linkurl = session.favorites[linkid].link else return print('Invalid bookmark id') end else linkurl = t.current_links[linkid] end if linkurl then new_tab() go_to_url(linkurl, true) else local out = string.format('Link id %d does not exist...', linkid) print_gopher_error(out) end end end function open_in_less() local th = session.tabs[session.current_tab].history local str if th.loc > 0 then str = go_to_url(th.items[th.loc], false, true) end if str then local less = io.popen(os.getenv('PAGER') or'less', 'w') less:write(str) less:close() else print('No current URL to open in less') end end function add_favorite() local th = session.tabs[session.current_tab].history if #th.items == 0 then return false end local title repeat io.write('Enter title for bookmark (! to cancel): ') title = io.read() until title if title == '!' then return print('Cancelled') end table.insert(session.favorites,{display = title, link = th.items[th.loc]}) print('Bookmark added') save_favorites() end function remove_favorite() io.write('Enter the bookmark id to remove (! to cancel): ') local favid = io.read() if favid == '!' then return print('Cancelled') end local id = tonumber(favid) or tonumber(string.sub(favid,2)) if id then local item = session.favorites[id] if item then table.remove(session.favorites, id) print('Bookmark has been removed') save_favorites() else print('Invalid bookmark id') end else print('Invalid bookmark id') end end function remove_tab() io.write('Enter the tab id to remove (! to cancel): ') local tabid = io.read() if tabid == '!' then return print('Cancelled') end local id = tonumber(tabid) if id then local item = session.tabs[id] if item then table.remove(session.tabs, id) if #session.tabs < session.current_tab then session.current_tab = #session.tabs refresh() end print('Tab has been removed') else print('Invalid tab id') end else print('Invalid tab id') end end function print_gopher_error(text) print(separator) print(text) end function save_file() io.write('Save to disk: (C)urrent, (L)ink') local subkey = getch() local out local t = session.tabs[session.current_tab] if subkey == 'c' then local t = session.tabs[session.current_tab] out = t.filedata if not out then return print('No file available. Save cancelled.') end elseif subkey == 'l' then io.write('Enter link id (! to cancel): ') local linkid = tonumber(io.read()) if linkid == '!' then return print('Cancelled') end local location = t.current_links[linkid] if location then local parsed = parse_url(location) out = request_gopher(parsed.host, parsed.port, parsed.path) if not out then return print('No file data available. Save cancelled.') end else return print('Invalid link id. Save cancelled.') end else return print('Invalid entry') end local homedir = assert(os.getenv('HOME'), 'Unable to find home directory') io.write('Enter the filename to save as (! to cancel): ' .. homedir .. '/') local filename = io.read() if filename == '!' or filename == '' then return print('Cancelled') elseif string.match(filename, '^[%.%w_-]+$') then print('Saving...') local filepath = homedir .. '/' .. filename local f = io.open(filepath, 'w+') f:write(table.concat(out,'\n')) f:close() print(string.format('File saved as: %s', filepath)) else print('Invalid filename') end end -- function handle_response(res_table, gtype) -- local out = '' -- if gtype == '0' or gtype == 0 then -- out = table.concat(res_table, '\n') -- else -- session.tabs[session.current_tab].current_links = {} -- for i, v in ipairs(res_table) do -- out = out .. (display_gophermap_row(v) or '').. '\n' -- end -- end -- return out -- end function show_favorites() print(separator) print('\n >---B O O K M A R K S--->\n\n') for i, v in ipairs(session.favorites) do print(string.format(' (f%d) %s', i, v.display)) end print('\n' .. separator) end function show_tabs() print(separator) print('\n [ T A B S ]\n\n') for i, v in ipairs(session.tabs) do local u = v.history.items[v.history.loc] or 'Empty tab' local t = session.current_tab == i and ' --> ' or ' ' print(string.format('%s(%d) %s', t, i, u)) end print('\n' .. separator) end function file_exists(name) local f=io.open(name,"r") if f~=nil then io.close(f) return true else return false end end function new_tab() local t = { history = { loc=0, items={} }, current_links = {}, favorites={} } table.insert(session.tabs, t) session.current_tab = #session.tabs end -- run the program init()