1# -*- coding: utf-8 -*-
2# Copyright: Ankitects Pty Ltd and contributors
3# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
4
5import re
6import os
7import random
8import time
9import math
10from html.entities import name2codepoint
11import subprocess
12import tempfile
13import shutil
14import string
15import sys
16import locale
17from hashlib import sha1
18import platform
19import traceback
20from contextlib import contextmanager
21from anki.lang import _, ngettext
22
23# some add-ons expect json to be in the utils module
24import json # pylint: disable=unused-import
25
26# Time handling
27##############################################################################
28
29def intTime(scale=1):
30    "The time in integer seconds. Pass scale=1000 to get milliseconds."
31    return int(time.time()*scale)
32
33timeTable = {
34    "years": lambda n: ngettext("%s year", "%s years", n),
35    "months": lambda n: ngettext("%s month", "%s months", n),
36    "days": lambda n: ngettext("%s day", "%s days", n),
37    "hours": lambda n: ngettext("%s hour", "%s hours", n),
38    "minutes": lambda n: ngettext("%s minute", "%s minutes", n),
39    "seconds": lambda n: ngettext("%s second", "%s seconds", n),
40    }
41
42inTimeTable = {
43    "years": lambda n: ngettext("in %s year", "in %s years", n),
44    "months": lambda n: ngettext("in %s month", "in %s months", n),
45    "days": lambda n: ngettext("in %s day", "in %s days", n),
46    "hours": lambda n: ngettext("in %s hour", "in %s hours", n),
47    "minutes": lambda n: ngettext("in %s minute", "in %s minutes", n),
48    "seconds": lambda n: ngettext("in %s second", "in %s seconds", n),
49    }
50
51def shortTimeFmt(type):
52    return {
53#T: year is an abbreviation for year. %s is a number of years
54    "years": _("%sy"),
55#T: m is an abbreviation for month. %s is a number of months
56    "months": _("%smo"),
57#T: d is an abbreviation for day. %s is a number of days
58    "days": _("%sd"),
59#T: h is an abbreviation for hour. %s is a number of hours
60    "hours": _("%sh"),
61#T: m is an abbreviation for minute. %s is a number of minutes
62    "minutes": _("%sm"),
63#T: s is an abbreviation for second. %s is a number of seconds
64    "seconds": _("%ss"),
65    }[type]
66
67def fmtTimeSpan(time, pad=0, point=0, short=False, inTime=False, unit=99):
68    "Return a string representing a time span (eg '2 days')."
69    (type, point) = optimalPeriod(time, point, unit)
70    time = convertSecondsTo(time, type)
71    if not point:
72        time = int(round(time))
73    if short:
74        fmt = shortTimeFmt(type)
75    else:
76        if inTime:
77            fmt = inTimeTable[type](_pluralCount(time, point))
78        else:
79            fmt = timeTable[type](_pluralCount(time, point))
80    timestr = "%%%(a)d.%(b)df" % {'a': pad, 'b': point}
81    return locale.format_string(fmt % timestr, time)
82
83def optimalPeriod(time, point, unit):
84    if abs(time) < 60 or unit < 1:
85        type = "seconds"
86        point -= 1
87    elif abs(time) < 3600 or unit < 2:
88        type = "minutes"
89    elif abs(time) < 60 * 60 * 24 or unit < 3:
90        type = "hours"
91    elif abs(time) < 60 * 60 * 24 * 30 or unit < 4:
92        type = "days"
93    elif abs(time) < 60 * 60 * 24 * 365 or unit < 5:
94        type = "months"
95        point += 1
96    else:
97        type = "years"
98        point += 1
99    return (type, max(point, 0))
100
101def convertSecondsTo(seconds, type):
102    if type == "seconds":
103        return seconds
104    elif type == "minutes":
105        return seconds / 60
106    elif type == "hours":
107        return seconds / 3600
108    elif type == "days":
109        return seconds / 86400
110    elif type == "months":
111        return seconds / 2592000
112    elif type == "years":
113        return seconds / 31536000
114    assert False
115
116def _pluralCount(time, point):
117    if point:
118        return 2
119    return math.floor(time)
120
121# Locale
122##############################################################################
123
124def fmtPercentage(float_value, point=1):
125    "Return float with percentage sign"
126    fmt = '%' + "0.%(b)df" % {'b': point}
127    return locale.format_string(fmt, float_value) + "%"
128
129def fmtFloat(float_value, point=1):
130    "Return a string with decimal separator according to current locale"
131    fmt = '%' + "0.%(b)df" % {'b': point}
132    return locale.format_string(fmt, float_value)
133
134# HTML
135##############################################################################
136reComment = re.compile("(?s)<!--.*?-->")
137reStyle = re.compile("(?si)<style.*?>.*?</style>")
138reScript = re.compile("(?si)<script.*?>.*?</script>")
139reTag = re.compile("(?s)<.*?>")
140reEnts = re.compile(r"&#?\w+;")
141reMedia = re.compile("(?i)<img[^>]+src=[\"']?([^\"'>]+)[\"']?[^>]*>")
142
143def stripHTML(s):
144    s = reComment.sub("", s)
145    s = reStyle.sub("", s)
146    s = reScript.sub("", s)
147    s = reTag.sub("", s)
148    s = entsToTxt(s)
149    return s
150
151def stripHTMLMedia(s):
152    "Strip HTML but keep media filenames"
153    s = reMedia.sub(" \\1 ", s)
154    return stripHTML(s)
155
156def minimizeHTML(s):
157    "Correct Qt's verbose bold/underline/etc."
158    s = re.sub('<span style="font-weight:600;">(.*?)</span>', '<b>\\1</b>',
159               s)
160    s = re.sub('<span style="font-style:italic;">(.*?)</span>', '<i>\\1</i>',
161               s)
162    s = re.sub('<span style="text-decoration: underline;">(.*?)</span>',
163               '<u>\\1</u>', s)
164    return s
165
166def htmlToTextLine(s):
167    s = s.replace("<br>", " ")
168    s = s.replace("<br />", " ")
169    s = s.replace("<div>", " ")
170    s = s.replace("\n", " ")
171    s = re.sub(r"\[sound:[^]]+\]", "", s)
172    s = re.sub(r"\[\[type:[^]]+\]\]", "", s)
173    s = stripHTMLMedia(s)
174    s = s.strip()
175    return s
176
177def entsToTxt(html):
178    # entitydefs defines nbsp as \xa0 instead of a standard space, so we
179    # replace it first
180    html = html.replace("&nbsp;", " ")
181    def fixup(m):
182        text = m.group(0)
183        if text[:2] == "&#":
184            # character reference
185            try:
186                if text[:3] == "&#x":
187                    return chr(int(text[3:-1], 16))
188                else:
189                    return chr(int(text[2:-1]))
190            except ValueError:
191                pass
192        else:
193            # named entity
194            try:
195                text = chr(name2codepoint[text[1:-1]])
196            except KeyError:
197                pass
198        return text # leave as is
199    return reEnts.sub(fixup, html)
200
201def bodyClass(col, card):
202    bodyclass = "card card%d" % (card.ord+1)
203    if col.conf.get("nightMode"):
204        bodyclass += " nightMode"
205    return bodyclass
206
207# IDs
208##############################################################################
209
210def hexifyID(id):
211    return "%x" % int(id)
212
213def dehexifyID(id):
214    return int(id, 16)
215
216def ids2str(ids):
217    """Given a list of integers, return a string '(int1,int2,...)'."""
218    return "(%s)" % ",".join(str(i) for i in ids)
219
220def timestampID(db, table):
221    "Return a non-conflicting timestamp for table."
222    # be careful not to create multiple objects without flushing them, or they
223    # may share an ID.
224    t = intTime(1000)
225    while db.scalar("select id from %s where id = ?" % table, t):
226        t += 1
227    return t
228
229def maxID(db):
230    "Return the first safe ID to use."
231    now = intTime(1000)
232    for tbl in "cards", "notes":
233        now = max(now, db.scalar("select max(id) from %s" % tbl) or 0)
234    return now + 1
235
236# used in ankiweb
237def base62(num, extra=""):
238    s = string; table = s.ascii_letters + s.digits + extra
239    buf = ""
240    while num:
241        num, i = divmod(num, len(table))
242        buf = table[i] + buf
243    return buf
244
245_base91_extra_chars = "!#$%&()*+,-./:;<=>?@[]^_`{|}~"
246def base91(num):
247    # all printable characters minus quotes, backslash and separators
248    return base62(num, _base91_extra_chars)
249
250def guid64():
251    "Return a base91-encoded 64bit random number."
252    return base91(random.randint(0, 2**64-1))
253
254# increment a guid by one, for note type conflicts
255def incGuid(guid):
256    return _incGuid(guid[::-1])[::-1]
257
258def _incGuid(guid):
259    s = string; table = s.ascii_letters + s.digits + _base91_extra_chars
260    idx = table.index(guid[0])
261    if idx + 1 == len(table):
262        # overflow
263        guid = table[0] + _incGuid(guid[1:])
264    else:
265        guid = table[idx+1] + guid[1:]
266    return guid
267
268# Fields
269##############################################################################
270
271def joinFields(list):
272    return "\x1f".join(list)
273
274def splitFields(string):
275    return string.split("\x1f")
276
277# Checksums
278##############################################################################
279
280def checksum(data):
281    if isinstance(data, str):
282        data = data.encode("utf-8")
283    return sha1(data).hexdigest()
284
285def fieldChecksum(data):
286    # 32 bit unsigned number from first 8 digits of sha1 hash
287    return int(checksum(stripHTMLMedia(data).encode("utf-8"))[:8], 16)
288
289# Temp files
290##############################################################################
291
292_tmpdir = None
293
294def tmpdir():
295    "A reusable temp folder which we clean out on each program invocation."
296    global _tmpdir
297    if not _tmpdir:
298        def cleanup():
299            shutil.rmtree(_tmpdir)
300        import atexit
301        atexit.register(cleanup)
302        _tmpdir = os.path.join(tempfile.gettempdir(), "anki_temp")
303    if not os.path.exists(_tmpdir):
304        os.mkdir(_tmpdir)
305    return _tmpdir
306
307def tmpfile(prefix="", suffix=""):
308    (fd, name) = tempfile.mkstemp(dir=tmpdir(), prefix=prefix, suffix=suffix)
309    os.close(fd)
310    return name
311
312def namedtmp(name, rm=True):
313    "Return tmpdir+name. Deletes any existing file."
314    path = os.path.join(tmpdir(), name)
315    if rm:
316        try:
317            os.unlink(path)
318        except (OSError, IOError):
319            pass
320    return path
321
322# Cmd invocation
323##############################################################################
324
325@contextmanager
326def noBundledLibs():
327    oldlpath = os.environ.pop("LD_LIBRARY_PATH", None)
328    yield
329    if oldlpath is not None:
330        os.environ["LD_LIBRARY_PATH"] = oldlpath
331
332def call(argv, wait=True, **kwargs):
333    "Execute a command. If WAIT, return exit code."
334    # ensure we don't open a separate window for forking process on windows
335    if isWin:
336        si = subprocess.STARTUPINFO()
337        try:
338            si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
339        except:
340            # pylint: disable=no-member
341            si.dwFlags |= subprocess._subprocess.STARTF_USESHOWWINDOW
342    else:
343        si = None
344    # run
345    try:
346        with noBundledLibs():
347            o = subprocess.Popen(argv, startupinfo=si, **kwargs)
348    except OSError:
349        # command not found
350        return -1
351    # wait for command to finish
352    if wait:
353        while 1:
354            try:
355                ret = o.wait()
356            except OSError:
357                # interrupted system call
358                continue
359            break
360    else:
361        ret = 0
362    return ret
363
364# OS helpers
365##############################################################################
366
367isMac = sys.platform.startswith("darwin")
368isWin = sys.platform.startswith("win32")
369isLin = not isMac and not isWin
370devMode = os.getenv("ANKIDEV", "")
371
372invalidFilenameChars = ":*?\"<>|"
373
374def invalidFilename(str, dirsep=True):
375    for c in invalidFilenameChars:
376        if c in str:
377            return c
378    if (dirsep or isWin) and "/" in str:
379        return "/"
380    elif (dirsep or not isWin) and "\\" in str:
381        return "\\"
382    elif str.strip().startswith("."):
383        return "."
384
385def platDesc():
386    # we may get an interrupted system call, so try this in a loop
387    n = 0
388    theos = "unknown"
389    while n < 100:
390        n += 1
391        try:
392            system = platform.system()
393            if isMac:
394                theos = "mac:%s" % (platform.mac_ver()[0])
395            elif isWin:
396                theos = "win:%s" % (platform.win32_ver()[0])
397            elif system == "Linux":
398                import distro
399                r = distro.linux_distribution(full_distribution_name=False)
400                theos = "lin:%s:%s" % (r[0], r[1])
401            else:
402                theos = system
403            break
404        except:
405            continue
406    return theos
407
408# Debugging
409##############################################################################
410
411class TimedLog:
412    def __init__(self):
413        self._last = time.time()
414    def log(self, s):
415        path, num, fn, y = traceback.extract_stack(limit=2)[0]
416        sys.stderr.write("%5dms: %s(): %s\n" % ((time.time() - self._last)*1000, fn, s))
417        self._last = time.time()
418
419# Version
420##############################################################################
421
422def versionWithBuild():
423    from anki import version
424    try:
425        from anki.buildhash import build
426    except:
427        build = "dev"
428    return "%s (%s)" % (version, build)
429