1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394
|
#!/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 argparse
import fnmatch
import os
import shutil
import sys
import tempfile
import time
import ansicat
import netcache
import offutils
from offutils import GREPCMD, is_local, mode_url, run, term_width, unmode_url, init_config
_HAS_XDGOPEN = shutil.which("xdg-open")
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(
'(I’m 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:
# On macOS the version can be something like 581.2 not just an int:
if all(_.isdigit() for _ in w.split(".")):
less_version = int(w.split(".", 1)[0])
# 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 and not grep:
cmd_str = _DEFAULT_CAT
elif grep:
grep_cmd = GREPCMD
# 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,file_extension=None):
# 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
#we try to match the file extension, with a starting dot or not
elif file_extension == handled_mime.strip("."):
break
else:
# Use "xdg-open" as a last resort.
if _HAS_XDGOPEN:
cmd_str = "xdg-open %s"
else:
cmd_str = 'echo "Can’t 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):
if "%s" not in handler:
#if no %s, we automatically add one. I can’t think of any usecase
# where it should not be part of the handler
handler += " %s"
previous = None
if mime in self.mime_handlers.keys():
previous = self.mime_handlers[mime]
self.mime_handlers[mime] = handler
def get_renderer(self, inpath, mode=None, theme=None,**kwargs):
# We remove the ##offpunk_mode= from the URL
# If mode is already set, we don’t use the part from the URL
inpath, newmode = unmode_url(inpath)
if not mode:
mode = newmode
# If we still doesn’t 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]
if last_cached and last_downloaded:
usecache = last_cached > last_downloaded
else:
usecache = False
else:
usecache = False
if not usecache:
renderer = ansicat.renderer_from_file(path, url=inpath, theme=theme,\
**kwargs)
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="readable", terminal=True, grep=None, theme=None, link=None,\
direct_open_unsupported=False, **kwargs):
# Return True if inpath opened in Terminal
# False otherwise
# also returns the url in case it has been modified
# if terminal = False, we don’t try to open in the terminal,
# we immediately fallback to xdg-open.
# netcache currently provide the path if it’s a file.
# If link is a digit, we open that link number instead of the inpath
# If direct_open_unsupported, we don’t print the "unsupported warning"
# and, instead, immediately fallback to external open
if not offutils.is_local(inpath):
if mode:
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, **kwargs)
if link and link.isdigit():
inpath = renderer.get_link(int(link))
renderer = self.get_renderer(inpath, mode=mode, theme=theme, **kwargs)
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 renderer and not renderer.is_format_supported() and direct_open_unsupported:
terminal = False
if terminal and renderer:
# If this is an image and we have chafa/timg, we
# don’t 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 there’s 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 don’t 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)
#we find the file extension by taking the last part of the path
#and finding a dot.
last_part = cachepath.split("/")[-1]
extension = None
if last_part and "." in last_part:
extension = last_part.split(".")[-1]
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,file_extension=extension)
change_cmd = "\"handler %s MY_PREFERED_APP %%s\""%mimetype
try:
#we don’t write the info if directly opening to avoid
#being verbose in opnk
if not direct_open_unsupported:
print("External open of type %s with \"%s\""%(mimetype,cmd_str))
print("You can change the default handler with %s"%change_cmd)
run(
cmd_str,
parameter=netcache.get_cache_path(inpath),
direct_output=True,
)
return True, inpath
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.")
print("You can change the default handler with %s"%change_cmd)
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 = {}
# Clean only a specificif url cache
def clean_url(self,url,mode=None):
def delfile(path):
if os.path.isfile(path):
os.remove(path)
# First, we take the full URL
if mode:
url = mode_url(url,mode)
delfile(self.temp_files.pop(url))
delfile(self.less_histfile.pop(url))
url, newmode = unmode_url(url)
if url in self.rendererdic:
self.rendererdic.pop(url)
if url in self.renderer_time:
self.renderer_time.pop(url)
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()
#we read the startup config and we only care about the "handler" command
cmds = init_config(skip_go=True,interactive=False,verbose=False)
for cmd in cmds:
splitted = cmd.split(maxsplit=2)
if len(splitted) >= 3 and splitted[0] == "handler":
cache.set_handler(splitted[1],splitted[2])
# if the second argument is an integer, we associate it with the previous url
# to use as a link_id
if len(args.content) == 2 and args.content[1].isdigit():
url = args.content[0]
link_id = args.content[1]
cache.opnk(url, mode=args.mode, validity=args.cache_validity, link=link_id,\
direct_open_unsupported=True)
else:
for f in args.content:
cache.opnk(f, mode=args.mode, validity=args.cache_validity,\
direct_open_unsupported=True)
if __name__ == "__main__":
main()
|