offpunk/opnk.py

298 lines
13 KiB
Python
Executable File
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
#opnk stand for "Open like a PuNK".
#It will open any file or URL and display it nicely in less.
#If not possible, it will fallback to xdg-open
#URL are retrieved through netcache
import os
import sys
import tempfile
import argparse
import netcache
import ansicat
import offutils
import shutil
import time
import fnmatch
from offutils import run,term_width,mode_url,unmode_url,is_local
_HAS_XDGOPEN = shutil.which('xdg-open')
_GREP = "grep --color=auto"
less_version = 0
if not shutil.which("less"):
print("Please install the pager \"less\" to run Offpunk.")
print("If you wish to use another pager, send me an email!")
print("(Im really curious to hear about people not having \"less\" on their system.)")
sys.exit()
output = run("less --version")
# We get less Version (which is the only integer on the first line)
words = output.split("\n")[0].split()
less_version = 0
for w in words:
if w.isdigit():
less_version = int(w)
# restoring position only works for version of less > 572
if less_version >= 572:
_LESS_RESTORE_POSITION = True
else:
_LESS_RESTORE_POSITION = False
#_DEFAULT_LESS = "less -EXFRfM -PMurl\ lines\ \%lt-\%lb/\%L\ \%Pb\%$ %s"
# -E : quit when reaching end of file (to behave like "cat")
# -F : quit if content fits the screen (behave like "cat")
# -X : does not clear the screen
# -R : interpret ANSI colors correctly
# -f : suppress warning for some contents
# -M : long prompt (to have info about where you are in the file)
# -W : hilite the new first line after a page skip (space)
# -i : ignore case in search
# -S : do not wrap long lines. Wrapping is done by offpunk, longlines
# are there on purpose (surch in asciiart)
#--incsearch : incremental search starting rev581
def less_cmd(file, histfile=None,cat=False,grep=None):
less_prompt = "page %%d/%%D- lines %%lb/%%L - %%Pb\\%%"
if less_version >= 581:
less_base = "less --incsearch --save-marks -~ -XRfWiS -P \"%s\""%less_prompt
elif less_version >= 572:
less_base = "less --save-marks -XRfMWiS"
else:
less_base = "less -XRfMWiS"
_DEFAULT_LESS = less_base + " \"+''\" %s"
_DEFAULT_CAT = less_base + " -EF %s"
if histfile:
env = {"LESSHISTFILE": histfile}
else:
env = {}
if cat:
cmd_str = _DEFAULT_CAT
elif grep:
grep_cmd = _GREP
#case insensitive for lowercase search
if grep.islower():
grep_cmd += " -i"
cmd_str = _DEFAULT_CAT + "|" + grep_cmd + " %s"%grep
else:
cmd_str = _DEFAULT_LESS
run(cmd_str, parameter=file, direct_output=True, env=env)
class opencache():
def __init__(self):
# We have a cache of the rendering of file and, for each one,
# a less_histfile containing the current position in the file
self.temp_files = {}
self.less_histfile = {}
# This dictionary contains an url -> ansirenderer mapping. This allows
# to reuse a renderer when visiting several times the same URL during
# the same session
# We save the time at which the renderer was created in renderer_time
# This way, we can invalidate the renderer if a new version of the source
# has been downloaded
self.rendererdic = {}
self.renderer_time = {}
self.mime_handlers = {}
self.last_mode = {}
self.last_width = term_width(absolute=True)
def _get_handler_cmd(self, mimetype):
# Now look for a handler for this mimetype
# Consider exact matches before wildcard matches
exact_matches = []
wildcard_matches = []
for handled_mime, cmd_str in self.mime_handlers.items():
if "*" in handled_mime:
wildcard_matches.append((handled_mime, cmd_str))
else:
exact_matches.append((handled_mime, cmd_str))
for handled_mime, cmd_str in exact_matches + wildcard_matches:
if fnmatch.fnmatch(mimetype, handled_mime):
break
else:
# Use "xdg-open" as a last resort.
if _HAS_XDGOPEN:
cmd_str = "xdg-open %s"
else:
cmd_str = "echo \"Cant find how to open \"%s"
print("Please install xdg-open (usually from xdg-util package)")
return cmd_str
# Return the handler for a specific mimetype.
# Return the whole dic if no specific mime provided
def get_handlers(self,mime=None):
if mime and mime in self.mime_handlers.keys():
return self.mime_handlers[mime]
elif mime:
return None
else:
return self.mime_handlers
def set_handler(self,mime,handler):
previous = None
if mime in self.mime_handlers.keys():
previous = self.mime_handlers[mime]
self.mime_handlers[mime] = handler
if "%s" not in handler:
print("WARNING: this handler has no %%s, no filename will be provided to the command")
if previous:
print("Previous handler was %s"%previous)
def get_renderer(self,inpath,mode=None,theme=None):
# We remove the ##offpunk_mode= from the URL
# If mode is already set, we dont use the part from the URL
inpath,newmode = unmode_url(inpath)
if not mode: mode = newmode
# If we still doesnt have a mode, we see if we used one before
if not mode and inpath in self.last_mode.keys():
mode = self.last_mode[inpath]
elif not mode:
#default mode is readable
mode = "readable"
renderer = None
path = netcache.get_cache_path(inpath)
if path:
usecache = inpath in self.rendererdic.keys() and not is_local(inpath)
#Screen size may have changed
width = term_width(absolute=True)
if usecache and self.last_width != width:
self.cleanup()
usecache = False
self.last_width = width
if usecache:
if inpath in self.renderer_time.keys():
last_downloaded = netcache.cache_last_modified(inpath)
last_cached = self.renderer_time[inpath]
usecache = last_cached > last_downloaded
else:
usecache = False
if not usecache:
renderer = ansicat.renderer_from_file(path,url=inpath,theme=theme)
if renderer:
self.rendererdic[inpath] = renderer
self.renderer_time[inpath] = int(time.time())
else:
renderer = self.rendererdic[inpath]
return renderer
def get_temp_filename(self,url):
if url in self.temp_files.keys():
return self.temp_files[url]
else:
return None
def opnk(self,inpath,mode=None,terminal=True,grep=None,theme=None,**kwargs):
#Return True if inpath opened in Terminal
# False otherwise
# also returns the url in case it has been modified
#if terminal = False, we dont try to open in the terminal,
#we immediately fallback to xdg-open.
#netcache currently provide the path if its a file.
if not offutils.is_local(inpath):
kwargs["images_mode"] = mode
cachepath,inpath = netcache.fetch(inpath,**kwargs)
if not cachepath:
return False, inpath
# folowing line is for :// which are locals (file,list)
elif "://" in inpath:
cachepath,inpath = netcache.fetch(inpath,**kwargs)
elif inpath.startswith("mailto:"):
cachepath = inpath
elif os.path.exists(inpath):
cachepath = inpath
else:
print("%s does not exist"%inpath)
return False, inpath
renderer = self.get_renderer(inpath,mode=mode,theme=theme)
if renderer and mode:
renderer.set_mode(mode)
self.last_mode[inpath] = mode
if not mode and inpath in self.last_mode.keys():
mode = self.last_mode[inpath]
renderer.set_mode(mode)
#we use the full moded url as key for the dictionary
key = mode_url(inpath,mode)
if terminal and renderer:
#If this is an image and we have chafa/timg, we
#dont use less, we call it directly
if renderer.has_direct_display():
renderer.display(mode=mode,directdisplay=True)
return True, inpath
else:
body = renderer.display(mode=mode)
#Should we use the cache? only if it is not local and theres a cache
usecache = key in self.temp_files and not is_local(inpath)
if usecache:
#and the cache is still valid!
last_downloaded = netcache.cache_last_modified(inpath)
last_cached = os.path.getmtime(self.temp_files[key])
if last_downloaded > last_cached:
usecache = False
self.temp_files.pop(key)
self.less_histfile.pop(key)
# We actually put the body in a tmpfile before giving it to less
if not usecache:
tmpf = tempfile.NamedTemporaryFile("w", encoding="UTF-8", delete=False)
self.temp_files[key] = tmpf.name
tmpf.write(body)
tmpf.close()
if key not in self.less_histfile:
firsttime = True
tmpf = tempfile.NamedTemporaryFile("w", encoding="UTF-8", delete=False)
self.less_histfile[key] = tmpf.name
else:
#We dont want to restore positions in lists
firsttime = is_local(inpath)
less_cmd(self.temp_files[key], histfile=self.less_histfile[key],cat=firsttime,grep=grep)
return True, inpath
#maybe, we have no renderer. Or we want to skip it.
else:
mimetype = ansicat.get_mime(cachepath)
if mimetype == "mailto":
mail = inpath[7:]
resp = input("Send an email to %s Y/N? " %mail)
if resp.strip().lower() in ("y", "yes"):
if _HAS_XDGOPEN :
run("xdg-open mailto:%s", parameter=mail,direct_output=True)
else:
print("Cannot find a mail client to send mail to %s" %inpath)
print("Please install xdg-open (usually from xdg-util package)")
return False, inpath
else:
cmd_str = self._get_handler_cmd(mimetype)
try:
run(cmd_str, parameter=netcache.get_cache_path(inpath), direct_output=True)
except FileNotFoundError:
print("Handler program %s not found!" % shlex.split(cmd_str)[0])
print("You can use the ! command to specify another handler program or pipeline.")
return False, inpath
#We remove the renderers from the cache and we also delete temp files
def cleanup(self):
while len(self.temp_files) > 0:
os.remove(self.temp_files.popitem()[1])
while len(self.less_histfile) > 0:
os.remove(self.less_histfile.popitem()[1])
self.last_width = None
self.rendererdic = {}
self.renderer_time = {}
self.last_mode = {}
def main():
descri = "opnk is an universal open command tool that will try to display any file \
in the pager less after rendering its content with ansicat. If that fails, \
opnk will fallback to opening the file with xdg-open. If given an URL as input \
instead of a path, opnk will rely on netcache to get the networked content."
parser = argparse.ArgumentParser(prog="opnk",description=descri)
parser.add_argument("--mode", metavar="MODE",
help="Which mode should be used to render: normal (default), full or source.\
With HTML, the normal mode try to extract the article.")
parser.add_argument("content",metavar="INPUT", nargs="*",
default=sys.stdin, help="Path to the file or URL to open")
parser.add_argument("--cache-validity",type=int, default=0,
help="maximum age, in second, of the cached version before \
redownloading a new version")
args = parser.parse_args()
cache = opencache()
for f in args.content:
cache.opnk(f,mode=args.mode,validity=args.cache_validity)
if __name__ == "__main__":
main()