File: opnk.py

package info (click to toggle)
offpunk 2.8-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 1,548 kB
  • sloc: python: 5,078; sh: 110; makefile: 2
file content (394 lines) | stat: -rwxr-xr-x 16,524 bytes parent folder | download
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()