opnk.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. #!/usr/bin/env python3
  2. #opnk stand for "Open like a PuNK".
  3. #It will open any file or URL and display it nicely in less.
  4. #If not possible, it will fallback to xdg-open
  5. #URL are retrieved through netcache
  6. import os
  7. import sys
  8. import tempfile
  9. import argparse
  10. import netcache
  11. import ansicat
  12. import offutils
  13. import shutil
  14. import time
  15. import fnmatch
  16. from offutils import run,term_width,mode_url,unmode_url,is_local
  17. _HAS_XDGOPEN = shutil.which('xdg-open')
  18. _GREP = "grep --color=auto"
  19. less_version = 0
  20. if not shutil.which("less"):
  21. print("Please install the pager \"less\" to run Offpunk.")
  22. print("If you wish to use another pager, send me an email !")
  23. print("(I’m really curious to hear about people not having \"less\" on their system.)")
  24. sys.exit()
  25. output = run("less --version")
  26. # We get less Version (which is the only integer on the first line)
  27. words = output.split("\n")[0].split()
  28. less_version = 0
  29. for w in words:
  30. if w.isdigit():
  31. less_version = int(w)
  32. # restoring position only works for version of less > 572
  33. if less_version >= 572:
  34. _LESS_RESTORE_POSITION = True
  35. else:
  36. _LESS_RESTORE_POSITION = False
  37. #_DEFAULT_LESS = "less -EXFRfM -PMurl\ lines\ \%lt-\%lb/\%L\ \%Pb\%$ %s"
  38. # -E : quit when reaching end of file (to behave like "cat")
  39. # -F : quit if content fits the screen (behave like "cat")
  40. # -X : does not clear the screen
  41. # -R : interpret ANSI colors correctly
  42. # -f : suppress warning for some contents
  43. # -M : long prompt (to have info about where you are in the file)
  44. # -W : hilite the new first line after a page skip (space)
  45. # -i : ignore case in search
  46. # -S : do not wrap long lines. Wrapping is done by offpunk, longlines
  47. # are there on purpose (surch in asciiart)
  48. #--incsearch : incremental search starting rev581
  49. def less_cmd(file, histfile=None,cat=False,grep=None):
  50. less_prompt = "page %%d/%%D- lines %%lb/%%L - %%Pb\\%%"
  51. if less_version >= 581:
  52. less_base = "less --incsearch --save-marks -~ -XRfWiS -P \"%s\""%less_prompt
  53. elif less_version >= 572:
  54. less_base = "less --save-marks -XRfMWiS"
  55. else:
  56. less_base = "less -XRfMWiS"
  57. _DEFAULT_LESS = less_base + " \"+''\" %s"
  58. _DEFAULT_CAT = less_base + " -EF %s"
  59. if histfile:
  60. env = {"LESSHISTFILE": histfile}
  61. else:
  62. env = {}
  63. if cat:
  64. cmd_str = _DEFAULT_CAT
  65. elif grep:
  66. grep_cmd = _GREP
  67. #case insensitive for lowercase search
  68. if grep.islower():
  69. grep_cmd += " -i"
  70. cmd_str = _DEFAULT_CAT + "|" + grep_cmd + " %s"%grep
  71. else:
  72. cmd_str = _DEFAULT_LESS
  73. run(cmd_str, parameter=file, direct_output=True, env=env)
  74. class opencache():
  75. def __init__(self):
  76. # We have a cache of the rendering of file and, for each one,
  77. # a less_histfile containing the current position in the file
  78. self.temp_files = {}
  79. self.less_histfile = {}
  80. # This dictionary contains an url -> ansirenderer mapping. This allows
  81. # to reuse a renderer when visiting several times the same URL during
  82. # the same session
  83. # We save the time at which the renderer was created in renderer_time
  84. # This way, we can invalidate the renderer if a new version of the source
  85. # has been downloaded
  86. self.rendererdic = {}
  87. self.renderer_time = {}
  88. self.mime_handlers = {}
  89. self.last_mode = {}
  90. self.last_width = term_width(absolute=True)
  91. def _get_handler_cmd(self, mimetype):
  92. # Now look for a handler for this mimetype
  93. # Consider exact matches before wildcard matches
  94. exact_matches = []
  95. wildcard_matches = []
  96. for handled_mime, cmd_str in self.mime_handlers.items():
  97. if "*" in handled_mime:
  98. wildcard_matches.append((handled_mime, cmd_str))
  99. else:
  100. exact_matches.append((handled_mime, cmd_str))
  101. for handled_mime, cmd_str in exact_matches + wildcard_matches:
  102. if fnmatch.fnmatch(mimetype, handled_mime):
  103. break
  104. else:
  105. # Use "xdg-open" as a last resort.
  106. if _HAS_XDGOPEN:
  107. cmd_str = "xdg-open %s"
  108. else:
  109. cmd_str = "echo \"Can’t find how to open \"%s"
  110. print("Please install xdg-open (usually from xdg-util package)")
  111. return cmd_str
  112. # Return the handler for a specific mimetype.
  113. # Return the whole dic if no specific mime provided
  114. def get_handlers(self,mime=None):
  115. if mime and mime in self.mime_handlers.keys():
  116. return self.mime_handlers[mime]
  117. elif mime:
  118. return None
  119. else:
  120. return self.mime_handlers
  121. def set_handler(self,mime,handler):
  122. previous = None
  123. if mime in self.mime_handlers.keys():
  124. previous = self.mime_handlers[mime]
  125. self.mime_handlers[mime] = handler
  126. if "%s" not in handler:
  127. print("WARNING: this handler has no %%s, no filename will be provided to the command")
  128. if previous:
  129. print("Previous handler was %s"%previous)
  130. def get_renderer(self,inpath,mode=None,theme=None):
  131. # We remove the ##offpunk_mode= from the URL
  132. # If mode is already set, we don’t use the part from the URL
  133. inpath,newmode = unmode_url(inpath)
  134. if not mode: mode = newmode
  135. # If we still doesn’t have a mode, we see if we used one before
  136. if not mode and inpath in self.last_mode.keys():
  137. mode = self.last_mode[inpath]
  138. elif not mode:
  139. #default mode is readable
  140. mode = "readable"
  141. renderer = None
  142. path = netcache.get_cache_path(inpath)
  143. if path:
  144. usecache = inpath in self.rendererdic.keys() and not is_local(inpath)
  145. #Screen size may have changed
  146. width = term_width(absolute=True)
  147. if usecache and self.last_width != width:
  148. self.cleanup()
  149. usecache = False
  150. self.last_width = width
  151. if usecache:
  152. if inpath in self.renderer_time.keys():
  153. last_downloaded = netcache.cache_last_modified(inpath)
  154. last_cached = self.renderer_time[inpath]
  155. if last_cached and last_downloaded:
  156. usecache = last_cached > last_downloaded
  157. else:
  158. usecache = False
  159. else:
  160. usecache = False
  161. if not usecache:
  162. renderer = ansicat.renderer_from_file(path,url=inpath,theme=theme)
  163. if renderer:
  164. self.rendererdic[inpath] = renderer
  165. self.renderer_time[inpath] = int(time.time())
  166. else:
  167. renderer = self.rendererdic[inpath]
  168. return renderer
  169. def get_temp_filename(self,url):
  170. if url in self.temp_files.keys():
  171. return self.temp_files[url]
  172. else:
  173. return None
  174. def opnk(self,inpath,mode=None,terminal=True,grep=None,theme=None,**kwargs):
  175. #Return True if inpath opened in Terminal
  176. # False otherwise
  177. # also returns the url in case it has been modified
  178. #if terminal = False, we don’t try to open in the terminal,
  179. #we immediately fallback to xdg-open.
  180. #netcache currently provide the path if it’s a file.
  181. if not offutils.is_local(inpath):
  182. kwargs["images_mode"] = mode
  183. cachepath,inpath = netcache.fetch(inpath,**kwargs)
  184. if not cachepath:
  185. return False, inpath
  186. # folowing line is for :// which are locals (file,list)
  187. elif "://" in inpath:
  188. cachepath,inpath = netcache.fetch(inpath,**kwargs)
  189. elif inpath.startswith("mailto:"):
  190. cachepath = inpath
  191. elif os.path.exists(inpath):
  192. cachepath = inpath
  193. else:
  194. print("%s does not exist"%inpath)
  195. return False, inpath
  196. renderer = self.get_renderer(inpath,mode=mode,theme=theme)
  197. if renderer and mode:
  198. renderer.set_mode(mode)
  199. self.last_mode[inpath] = mode
  200. if not mode and inpath in self.last_mode.keys():
  201. mode = self.last_mode[inpath]
  202. renderer.set_mode(mode)
  203. #we use the full moded url as key for the dictionary
  204. key = mode_url(inpath,mode)
  205. if terminal and renderer:
  206. #If this is an image and we have chafa/timg, we
  207. #don’t use less, we call it directly
  208. if renderer.has_direct_display():
  209. renderer.display(mode=mode,directdisplay=True)
  210. return True, inpath
  211. else:
  212. body = renderer.display(mode=mode)
  213. #Should we use the cache ? only if it is not local and there’s a cache
  214. usecache = key in self.temp_files and not is_local(inpath)
  215. if usecache:
  216. #and the cache is still valid!
  217. last_downloaded = netcache.cache_last_modified(inpath)
  218. last_cached = os.path.getmtime(self.temp_files[key])
  219. if last_downloaded > last_cached:
  220. usecache = False
  221. self.temp_files.pop(key)
  222. self.less_histfile.pop(key)
  223. # We actually put the body in a tmpfile before giving it to less
  224. if not usecache:
  225. tmpf = tempfile.NamedTemporaryFile("w", encoding="UTF-8", delete=False)
  226. self.temp_files[key] = tmpf.name
  227. tmpf.write(body)
  228. tmpf.close()
  229. if key not in self.less_histfile:
  230. firsttime = True
  231. tmpf = tempfile.NamedTemporaryFile("w", encoding="UTF-8", delete=False)
  232. self.less_histfile[key] = tmpf.name
  233. else:
  234. #We don’t want to restore positions in lists
  235. firsttime = is_local(inpath)
  236. less_cmd(self.temp_files[key], histfile=self.less_histfile[key],cat=firsttime,grep=grep)
  237. return True, inpath
  238. #maybe, we have no renderer. Or we want to skip it.
  239. else:
  240. mimetype = ansicat.get_mime(cachepath)
  241. if mimetype == "mailto":
  242. mail = inpath[7:]
  243. resp = input("Send an email to %s Y/N? " %mail)
  244. if resp.strip().lower() in ("y", "yes"):
  245. if _HAS_XDGOPEN :
  246. run("xdg-open mailto:%s", parameter=mail,direct_output=True)
  247. else:
  248. print("Cannot find a mail client to send mail to %s" %inpath)
  249. print("Please install xdg-open (usually from xdg-util package)")
  250. return False, inpath
  251. else:
  252. cmd_str = self._get_handler_cmd(mimetype)
  253. try:
  254. run(cmd_str, parameter=netcache.get_cache_path(inpath), direct_output=True)
  255. except FileNotFoundError:
  256. print("Handler program %s not found!" % shlex.split(cmd_str)[0])
  257. print("You can use the ! command to specify another handler program or pipeline.")
  258. return False, inpath
  259. #We remove the renderers from the cache and we also delete temp files
  260. def cleanup(self):
  261. while len(self.temp_files) > 0:
  262. os.remove(self.temp_files.popitem()[1])
  263. while len(self.less_histfile) > 0:
  264. os.remove(self.less_histfile.popitem()[1])
  265. self.last_width = None
  266. self.rendererdic = {}
  267. self.renderer_time = {}
  268. self.last_mode = {}
  269. def main():
  270. descri = "opnk is an universal open command tool that will try to display any file \
  271. in the pager less after rendering its content with ansicat. If that fails, \
  272. opnk will fallback to opening the file with xdg-open. If given an URL as input \
  273. instead of a path, opnk will rely on netcache to get the networked content."
  274. parser = argparse.ArgumentParser(prog="opnk",description=descri)
  275. parser.add_argument("--mode", metavar="MODE",
  276. help="Which mode should be used to render: normal (default), full or source.\
  277. With HTML, the normal mode try to extract the article.")
  278. parser.add_argument("content",metavar="INPUT", nargs="*",
  279. default=sys.stdin, help="Path to the file or URL to open")
  280. parser.add_argument("--cache-validity",type=int, default=0,
  281. help="maximum age, in second, of the cached version before \
  282. redownloading a new version")
  283. args = parser.parse_args()
  284. cache = opencache()
  285. for f in args.content:
  286. cache.opnk(f,mode=args.mode,validity=args.cache_validity)
  287. if __name__ == "__main__":
  288. main()