lino 24.10.3__py3-none-any.whl → 24.11.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lino/__init__.py +1 -1
- lino/api/doctest.py +11 -10
- lino/api/rt.py +2 -3
- lino/config/admin_main_base.html +2 -2
- lino/core/actions.py +2 -4
- lino/core/actors.py +70 -35
- lino/core/choicelists.py +2 -2
- lino/core/dashboard.py +2 -1
- lino/core/dbtables.py +15 -15
- lino/core/elems.py +8 -4
- lino/core/fields.py +12 -3
- lino/core/inject.py +9 -2
- lino/core/kernel.py +11 -11
- lino/core/layouts.py +1 -1
- lino/core/model.py +25 -36
- lino/core/plugin.py +1 -0
- lino/core/renderer.py +21 -21
- lino/core/requests.py +94 -83
- lino/core/site.py +9 -90
- lino/core/store.py +16 -19
- lino/core/tables.py +0 -17
- lino/core/utils.py +32 -2
- lino/core/views.py +2 -1
- lino/help_texts.py +10 -5
- lino/locale/bn/LC_MESSAGES/django.po +1210 -907
- lino/locale/de/LC_MESSAGES/django.po +1760 -1375
- lino/locale/django.pot +1136 -906
- lino/locale/es/LC_MESSAGES/django.po +1709 -1347
- lino/locale/et/LC_MESSAGES/django.po +1206 -906
- lino/locale/fr/LC_MESSAGES/django.mo +0 -0
- lino/locale/fr/LC_MESSAGES/django.po +1193 -923
- lino/locale/nl/LC_MESSAGES/django.po +1247 -942
- lino/locale/pt_BR/LC_MESSAGES/django.po +1190 -903
- lino/locale/zh_Hant/LC_MESSAGES/django.po +1190 -903
- lino/management/commands/show.py +2 -4
- lino/mixins/periods.py +15 -7
- lino/mixins/polymorphic.py +3 -3
- lino/mixins/ref.py +6 -3
- lino/modlib/checkdata/__init__.py +3 -3
- lino/modlib/comments/choicelists.py +1 -1
- lino/modlib/comments/fixtures/demo2.py +4 -1
- lino/modlib/comments/mixins.py +9 -10
- lino/modlib/comments/models.py +4 -4
- lino/modlib/comments/ui.py +5 -0
- lino/modlib/extjs/ext_renderer.py +1 -1
- lino/modlib/linod/consumers.py +2 -3
- lino/modlib/linod/mixins.py +3 -2
- lino/modlib/memo/mixins.py +11 -209
- lino/modlib/notify/mixins.py +33 -32
- lino/modlib/periods/__init__.py +12 -1
- lino/modlib/periods/fixtures/std.py +2 -1
- lino/modlib/periods/mixins.py +0 -1
- lino/modlib/periods/models.py +79 -75
- lino/modlib/printing/actions.py +2 -0
- lino/modlib/printing/choicelists.py +3 -3
- lino/modlib/publisher/ui.py +2 -2
- lino/modlib/search/models.py +17 -11
- lino/modlib/system/__init__.py +0 -2
- lino/modlib/system/choicelists.py +55 -1
- lino/modlib/system/fixtures/__init__.py +0 -0
- lino/modlib/system/fixtures/std.py +5 -0
- lino/modlib/system/models.py +4 -2
- lino/modlib/uploads/__init__.py +10 -1
- lino/modlib/uploads/choicelists.py +3 -3
- lino/modlib/uploads/mixins.py +30 -32
- lino/modlib/uploads/models.py +89 -56
- lino/modlib/uploads/ui.py +12 -6
- lino/modlib/uploads/utils.py +107 -0
- lino/modlib/users/models.py +2 -2
- lino/modlib/weasyprint/__init__.py +2 -0
- lino/utils/__init__.py +14 -9
- lino/utils/djangotest.py +2 -1
- lino/utils/html.py +32 -1
- lino/utils/media.py +2 -3
- lino/utils/soup.py +311 -0
- {lino-24.10.3.dist-info → lino-24.11.1.dist-info}/METADATA +1 -3
- {lino-24.10.3.dist-info → lino-24.11.1.dist-info}/RECORD +80 -76
- {lino-24.10.3.dist-info → lino-24.11.1.dist-info}/WHEEL +1 -1
- {lino-24.10.3.dist-info → lino-24.11.1.dist-info}/licenses/AUTHORS.rst +0 -0
- {lino-24.10.3.dist-info → lino-24.11.1.dist-info}/licenses/COPYING +0 -0
lino/modlib/uploads/ui.py
CHANGED
@@ -66,10 +66,11 @@ class UploadDetail(dd.DetailLayout):
|
|
66
66
|
"""
|
67
67
|
|
68
68
|
left = """
|
69
|
-
file
|
69
|
+
file
|
70
70
|
volume:10 library_file:40
|
71
|
-
|
72
|
-
|
71
|
+
user owner
|
72
|
+
upload_area type
|
73
|
+
description
|
73
74
|
source
|
74
75
|
"""
|
75
76
|
|
@@ -82,6 +83,11 @@ class Uploads(dd.Table):
|
|
82
83
|
required_roles = dd.login_required(UploadsReader)
|
83
84
|
column_names = "file type user owner description id *"
|
84
85
|
order_by = ["-id"]
|
86
|
+
default_display_modes = {
|
87
|
+
70: constants.DISPLAY_MODE_LIST,
|
88
|
+
None: constants.DISPLAY_MODE_GALLERY
|
89
|
+
}
|
90
|
+
# extra_display_modes = {constants.DISPLAY_MODE_LIST, constants.DISPLAY_MODE_GALLERY}
|
85
91
|
|
86
92
|
detail_layout = "uploads.UploadDetail"
|
87
93
|
|
@@ -197,10 +203,10 @@ class AreaUploads(Uploads):
|
|
197
203
|
# icon_name='application_form',
|
198
204
|
title=_("Edit metadata of the uploaded file."),
|
199
205
|
)
|
200
|
-
|
201
|
-
if
|
206
|
+
mf = m.get_media_file()
|
207
|
+
if mf:
|
202
208
|
show = ar.renderer.href_button(
|
203
|
-
|
209
|
+
mf.get_download_url(),
|
204
210
|
# u"\u21A7", # DOWNWARDS ARROW FROM BAR (↧)
|
205
211
|
# u"\u21E8",
|
206
212
|
"\u21f2", # SOUTH EAST ARROW TO CORNER (⇲)
|
@@ -0,0 +1,107 @@
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
2
|
+
# Copyright 2010-2024 Rumma & Ko Ltd
|
3
|
+
# License: GNU Affero General Public License v3 (see file COPYING for details)
|
4
|
+
|
5
|
+
from os.path import splitext
|
6
|
+
from django.conf import settings
|
7
|
+
from django.utils.text import format_lazy
|
8
|
+
from lino.api import dd, rt, _
|
9
|
+
from lino.utils import needs_update
|
10
|
+
|
11
|
+
if (with_thumbnails := dd.get_plugin_setting('uploads', 'with_thumbnails', False)):
|
12
|
+
from PIL import Image # pip install Pillow
|
13
|
+
import pymupdf # pip install PyMuPDF
|
14
|
+
|
15
|
+
class UploadMediaFile:
|
16
|
+
|
17
|
+
def __init__(self, url):
|
18
|
+
self.url = url
|
19
|
+
assert url is not None
|
20
|
+
root, suffix = splitext(url)
|
21
|
+
self.suffix = suffix.lower()
|
22
|
+
|
23
|
+
def get_image_name(self):
|
24
|
+
if self.suffix not in previewer.PREVIEW_SUFFIXES:
|
25
|
+
return None
|
26
|
+
if previewer.base_dir is None:
|
27
|
+
if self.suffix == ".pdf":
|
28
|
+
return None
|
29
|
+
return self.url
|
30
|
+
url = self.url
|
31
|
+
if self.suffix == ".pdf":
|
32
|
+
url += ".png"
|
33
|
+
return previewer.base_dir + "/" + url
|
34
|
+
|
35
|
+
def get_mimetype_description(self):
|
36
|
+
if self.suffix == ".pdf":
|
37
|
+
return _("PDF file")
|
38
|
+
if self.get_image_name():
|
39
|
+
return _("picture")
|
40
|
+
return _("media file")
|
41
|
+
|
42
|
+
def get_image_url(self):
|
43
|
+
url = self.get_image_name()
|
44
|
+
if url is not None:
|
45
|
+
return settings.SITE.build_media_url(url)
|
46
|
+
|
47
|
+
def get_download_url(self):
|
48
|
+
return settings.SITE.build_media_url(self.url)
|
49
|
+
|
50
|
+
|
51
|
+
class Previewer:
|
52
|
+
# The bare media previewer. It doesn't do any real work.
|
53
|
+
base_dir = None
|
54
|
+
max_width = None
|
55
|
+
PREVIEW_SUFFIXES = {'.png', '.jpg'}
|
56
|
+
|
57
|
+
def check_preview(self, obj, fix=False):
|
58
|
+
return []
|
59
|
+
|
60
|
+
|
61
|
+
class FilePreviewer(Previewer):
|
62
|
+
# A media previewer that builds thumbnails in a separate directory tree
|
63
|
+
PREVIEW_SUFFIXES = {'.png', '.jpg', '.pdf'}
|
64
|
+
|
65
|
+
def __init__(self, base_dir=None, max_width=None):
|
66
|
+
self.base_dir = base_dir
|
67
|
+
self.max_width = max_width
|
68
|
+
super().__init__()
|
69
|
+
|
70
|
+
def check_preview(self, obj, fix=False):
|
71
|
+
mf = obj.get_media_file()
|
72
|
+
if mf is None:
|
73
|
+
return
|
74
|
+
if (dst := mf.get_image_name()) is None:
|
75
|
+
return
|
76
|
+
if dst == mf.url:
|
77
|
+
raise Exception("20241113 should never happen")
|
78
|
+
return
|
79
|
+
src = settings.SITE.media_root / mf.url
|
80
|
+
dst = settings.SITE.media_root / dst
|
81
|
+
|
82
|
+
if needs_update(src, dst):
|
83
|
+
yield (True, format_lazy(_("Must build thumbnail for {}"), mf.url))
|
84
|
+
if fix:
|
85
|
+
if src.suffix.lower() == ".pdf":
|
86
|
+
doc = pymupdf.open(src)
|
87
|
+
page = doc.load_page(0)
|
88
|
+
pixmap = page.get_pixmap(dpi=120)
|
89
|
+
pixmap.save(dst)
|
90
|
+
return
|
91
|
+
with Image.open(src) as im:
|
92
|
+
im.thumbnail((self.max_width, self.max_width))
|
93
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
94
|
+
im.save(dst)
|
95
|
+
|
96
|
+
if with_thumbnails:
|
97
|
+
previewer = FilePreviewer("thumbs", 720)
|
98
|
+
else:
|
99
|
+
previewer = Previewer()
|
100
|
+
|
101
|
+
# full = Previewer()
|
102
|
+
# if with_thumbnails:
|
103
|
+
# small = FilePreviewer("thumbs", 720)
|
104
|
+
# else:
|
105
|
+
# small = full
|
106
|
+
|
107
|
+
# Lino currently offers only two previewers, "full" and "small"
|
lino/modlib/users/models.py
CHANGED
@@ -99,7 +99,7 @@ class User(AbstractBaseUser, Contactable, CreatedModified, Publishable, DateRang
|
|
99
99
|
user_type = UserTypes.field(blank=True)
|
100
100
|
initials = models.CharField(_("Initials"), max_length=10, blank=True)
|
101
101
|
if dd.plugins.users.with_nickname:
|
102
|
-
nickname = models.CharField(_("Nickname"), max_length=
|
102
|
+
nickname = models.CharField(_("Nickname"), max_length=50, blank=True)
|
103
103
|
else:
|
104
104
|
nickname = dd.DummyField()
|
105
105
|
first_name = models.CharField(_("First name"), max_length=30, blank=True)
|
@@ -144,7 +144,7 @@ class User(AbstractBaseUser, Contactable, CreatedModified, Publishable, DateRang
|
|
144
144
|
my_settings = MySettings()
|
145
145
|
|
146
146
|
def __str__(self):
|
147
|
-
return self.nickname
|
147
|
+
return self.nickname or self.get_full_name()
|
148
148
|
|
149
149
|
@property
|
150
150
|
def is_active(self):
|
lino/utils/__init__.py
CHANGED
@@ -67,7 +67,7 @@ from urllib.parse import urlencode
|
|
67
67
|
# import locale
|
68
68
|
import dateparser
|
69
69
|
from io import StringIO
|
70
|
-
import
|
70
|
+
from contextlib import redirect_stdout, contextmanager
|
71
71
|
from pathlib import Path
|
72
72
|
|
73
73
|
from etgen.utils import join_elems
|
@@ -77,13 +77,6 @@ from lino.utils.code import codefiles, codetime
|
|
77
77
|
|
78
78
|
from rstgen.utils import confirm, i2d, i2t
|
79
79
|
|
80
|
-
try:
|
81
|
-
import lino_book
|
82
|
-
DEMO_DATA = Path(lino_book.__file__).parent.parent.absolute() / 'demo_data'
|
83
|
-
"""The root directory with demo data included in the Developer Guide."""
|
84
|
-
except ImportError:
|
85
|
-
DEMO_DATA = None
|
86
|
-
|
87
80
|
DATE_TO_DIR_TPL = "%Y/%m"
|
88
81
|
|
89
82
|
def read_exception(excinfo):
|
@@ -105,6 +98,12 @@ def buildurl(root, *args, **kw):
|
|
105
98
|
return url
|
106
99
|
|
107
100
|
|
101
|
+
def needs_update(src, dest):
|
102
|
+
if dest.exists() and dest.stat().st_mtime >= src.stat().st_mtime:
|
103
|
+
return False
|
104
|
+
return True
|
105
|
+
|
106
|
+
|
108
107
|
class AttrDict(dict):
|
109
108
|
"""
|
110
109
|
Dictionary-like helper object.
|
@@ -366,6 +365,12 @@ curry = lambda func, *args, **kw: lambda *p, **n: func(
|
|
366
365
|
*args + p, **dict(list(kw.items()) + list(n.items()))
|
367
366
|
)
|
368
367
|
|
368
|
+
def capture_output(func, *args, **kwargs):
|
369
|
+
s = StringIO()
|
370
|
+
with redirect_stdout(s):
|
371
|
+
func(*args, **kwargs)
|
372
|
+
return s.getvalue()
|
373
|
+
|
369
374
|
|
370
375
|
class IncompleteDate(object):
|
371
376
|
"""Naive representation of a potentially incomplete gregorian date.
|
@@ -694,7 +699,7 @@ class MissingRow:
|
|
694
699
|
return "MissingRow({!r})".format(self.message)
|
695
700
|
|
696
701
|
|
697
|
-
@
|
702
|
+
@contextmanager
|
698
703
|
def logging_disabled(level):
|
699
704
|
try:
|
700
705
|
logging.disable(level)
|
lino/utils/djangotest.py
CHANGED
@@ -20,6 +20,7 @@ from django.db import connection, reset_queries, connections, DEFAULT_DB_ALIAS
|
|
20
20
|
from django.utils import translation
|
21
21
|
|
22
22
|
from lino.utils import AttrDict
|
23
|
+
from lino.api import rt
|
23
24
|
from lino.core.signals import testcase_setup # , database_ready
|
24
25
|
from lino.core.callbacks import applyCallbackChoice
|
25
26
|
from .test import CommonTestCase
|
@@ -112,7 +113,7 @@ class DjangoManageTestCase(DjangoTestCase, CommonTestCase):
|
|
112
113
|
the response's content (which is expected to contain a dict), convert
|
113
114
|
this dict to an AttrDict before returning it.
|
114
115
|
"""
|
115
|
-
ar =
|
116
|
+
ar = rt.login(username)
|
116
117
|
self.client.force_login(ar.user)
|
117
118
|
extra[settings.SITE.remote_user_header] = username
|
118
119
|
# extra.update(REMOTE_USER=username)
|
lino/utils/html.py
CHANGED
@@ -11,9 +11,10 @@ from etgen.html import E, to_rst, fromstring, iselement, join_elems, forcetext,
|
|
11
11
|
|
12
12
|
# from etgen.html import tostring as et_tostring
|
13
13
|
from html2text import HTML2Text
|
14
|
-
from django.utils.html import SafeString, mark_safe, escape
|
14
|
+
from django.utils.html import SafeString, mark_safe, escape, format_html
|
15
15
|
# from lino.utils import tostring
|
16
16
|
|
17
|
+
SAFE_EMPTY = mark_safe("")
|
17
18
|
|
18
19
|
def html2text(html, **kwargs):
|
19
20
|
"""
|
@@ -69,3 +70,33 @@ def assert_safe(s):
|
|
69
70
|
if not isinstance(s, SafeString):
|
70
71
|
raise Exception("%r is not a safe string" % s)
|
71
72
|
# assert isinstance(s, SafeString)
|
73
|
+
|
74
|
+
|
75
|
+
class Grouper:
|
76
|
+
|
77
|
+
def __init__(self, ar):
|
78
|
+
self.ar = ar
|
79
|
+
if ar.actor.group_by is None: return
|
80
|
+
self.last_values = [None for f in ar.actor.group_by]
|
81
|
+
|
82
|
+
def begin(self):
|
83
|
+
if self.ar.actor.group_by is None: return SAFE_EMPTY
|
84
|
+
return SAFE_EMPTY
|
85
|
+
|
86
|
+
def stop(self):
|
87
|
+
if self.ar.actor.group_by is None: return SAFE_EMPTY
|
88
|
+
return SAFE_EMPTY
|
89
|
+
|
90
|
+
def before_row(self, obj):
|
91
|
+
if self.ar.actor.group_by is None: return SAFE_EMPTY
|
92
|
+
self.current_values = [f(obj) for f in self.ar.actor.group_by]
|
93
|
+
if self.current_values == self.last_values:
|
94
|
+
return SAFE_EMPTY
|
95
|
+
return self.ar.actor.before_group_change(self, obj)
|
96
|
+
|
97
|
+
def after_row(self, obj):
|
98
|
+
if self.ar.actor.group_by is None: return SAFE_EMPTY
|
99
|
+
if self.current_values == self.last_values:
|
100
|
+
return SAFE_EMPTY
|
101
|
+
self.last_values = self.current_values
|
102
|
+
return self.ar.actor.after_group_change(self, obj)
|
lino/utils/media.py
CHANGED
@@ -55,6 +55,5 @@ class MediaFile(object):
|
|
55
55
|
class TmpMediaFile(MediaFile):
|
56
56
|
def __init__(self, ar, fmt):
|
57
57
|
ip = ar.request.META.get("REMOTE_ADDR", "unknown_ip")
|
58
|
-
super(
|
59
|
-
False, "cache", "appy" + fmt, ip, str(ar.actor) + "." + fmt
|
60
|
-
)
|
58
|
+
super().__init__(
|
59
|
+
False, "cache", "appy" + fmt, ip, str(ar.actor) + "." + fmt)
|
lino/utils/soup.py
ADDED
@@ -0,0 +1,311 @@
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
2
|
+
# Copyright 2016-2024 Rumma & Ko Ltd
|
3
|
+
# License: GNU Affero General Public License v3 (see file COPYING for details)
|
4
|
+
|
5
|
+
# See https://dev.lino-framework.org/dev/bleach.html
|
6
|
+
|
7
|
+
import re
|
8
|
+
from bs4 import BeautifulSoup, NavigableString, Comment, Doctype
|
9
|
+
from bs4.element import Tag
|
10
|
+
import logging; logger = logging.getLogger(__file__)
|
11
|
+
from lino.api import dd
|
12
|
+
|
13
|
+
|
14
|
+
PARAGRAPH_TAGS = {
|
15
|
+
"p",
|
16
|
+
"h1",
|
17
|
+
"h2",
|
18
|
+
"h3",
|
19
|
+
"h4",
|
20
|
+
"h5",
|
21
|
+
"h6",
|
22
|
+
"h7",
|
23
|
+
"h8",
|
24
|
+
"h9",
|
25
|
+
"pre",
|
26
|
+
"li",
|
27
|
+
"div",
|
28
|
+
}
|
29
|
+
|
30
|
+
WHITESPACE_TAGS = PARAGRAPH_TAGS | {
|
31
|
+
"[document]",
|
32
|
+
"span",
|
33
|
+
"ul",
|
34
|
+
"html",
|
35
|
+
"head",
|
36
|
+
"body",
|
37
|
+
"base",
|
38
|
+
}
|
39
|
+
|
40
|
+
|
41
|
+
class Style:
|
42
|
+
# TODO: Extend rstgen.sphinxconf.sigal_image.Format to incoroporate this.
|
43
|
+
def __init__(self, s):
|
44
|
+
self._map = {}
|
45
|
+
if s:
|
46
|
+
for i in s.split(";"):
|
47
|
+
k, v = i.split(":", maxsplit=1)
|
48
|
+
self._map[k.strip()] = v.strip()
|
49
|
+
self.is_dirty = False
|
50
|
+
|
51
|
+
def __contains__(self, *args):
|
52
|
+
return self._map.__contains__(*args)
|
53
|
+
|
54
|
+
def __setitem__(self, k, v):
|
55
|
+
if k in self._map and self._map[k] == v:
|
56
|
+
return
|
57
|
+
self._map[k] = v
|
58
|
+
self.is_dirty = True
|
59
|
+
|
60
|
+
def __delitem__(self, k):
|
61
|
+
if k in self._map:
|
62
|
+
self.is_dirty = True
|
63
|
+
return self._map.__delitem__(k)
|
64
|
+
|
65
|
+
def adjust_size(self):
|
66
|
+
# if self['float'] == "none":
|
67
|
+
# return
|
68
|
+
if "width" in self._map:
|
69
|
+
del self["width"]
|
70
|
+
self["height"] = dd.plugins.memo.short_preview_image_height
|
71
|
+
|
72
|
+
def as_string(self):
|
73
|
+
return ";".join(["{}:{}".format(*kv) for kv in self._map.items()])
|
74
|
+
|
75
|
+
|
76
|
+
class TextCollector:
|
77
|
+
def __init__(self, max_length=None):
|
78
|
+
self.text = ""
|
79
|
+
self.sep = "" # becomes "\n\n" after a PARAGRAPH_TAGS
|
80
|
+
self.remaining = max_length or settings.SITE.plugins.memo.short_preview_length
|
81
|
+
self.image = None
|
82
|
+
|
83
|
+
def add_chunk(self, ch):
|
84
|
+
# print("20230712 add_chunk", ch.name, ch)
|
85
|
+
|
86
|
+
if ch.name in WHITESPACE_TAGS:
|
87
|
+
for c in ch.children:
|
88
|
+
if not self.add_chunk(c):
|
89
|
+
return False
|
90
|
+
if ch.name in PARAGRAPH_TAGS:
|
91
|
+
self.sep = "\n\n"
|
92
|
+
else:
|
93
|
+
self.sep = " "
|
94
|
+
return True
|
95
|
+
|
96
|
+
assert ch.name != "IMG"
|
97
|
+
|
98
|
+
if ch.name == "img":
|
99
|
+
if self.image is not None:
|
100
|
+
# Ignore all images except the first one.
|
101
|
+
self.text += self.sep
|
102
|
+
return True
|
103
|
+
style = Style(ch.get("style", None))
|
104
|
+
if not "float" in style:
|
105
|
+
style["float"] = "right"
|
106
|
+
style.adjust_size()
|
107
|
+
if style.is_dirty:
|
108
|
+
ch["style"] = style.as_string()
|
109
|
+
self.image = ch
|
110
|
+
# print("20231023 a", ch)
|
111
|
+
|
112
|
+
we_want_more = True
|
113
|
+
if ch.string is not None:
|
114
|
+
if len(ch.string) > self.remaining:
|
115
|
+
# print("20231023", len(ch.string), '>', self.remaining)
|
116
|
+
ch.string = ch.string[: self.remaining] + "..."
|
117
|
+
we_want_more = False
|
118
|
+
# print("20230927", ch.string, ch)
|
119
|
+
# self.text += str(ch.string) + "..."
|
120
|
+
# return False
|
121
|
+
self.remaining -= len(ch.string)
|
122
|
+
|
123
|
+
if isinstance(ch, NavigableString):
|
124
|
+
self.text += self.sep + ch.string
|
125
|
+
else:
|
126
|
+
self.text += self.sep + str(ch)
|
127
|
+
|
128
|
+
self.remaining -= len(self.sep)
|
129
|
+
self.sep = ""
|
130
|
+
return we_want_more
|
131
|
+
|
132
|
+
|
133
|
+
def truncate_comment(html_str, max_length=300):
|
134
|
+
# Returns a single paragraph with a maximum number of visible chars.
|
135
|
+
# new implementation since 20230713
|
136
|
+
html_str = html_str.strip() # remove leading or trailing newlines
|
137
|
+
|
138
|
+
if not html_str.startswith("<"):
|
139
|
+
# print("20231023 c", html_str)
|
140
|
+
if len(html_str) > max_length:
|
141
|
+
return html_str[:max_length] + "..."
|
142
|
+
return html_str
|
143
|
+
|
144
|
+
# if "choose one or the other" in html_str:
|
145
|
+
# print(html_str)
|
146
|
+
# raise Exception("20230928 {} {}".format(len(html_str), max_length))
|
147
|
+
|
148
|
+
soup = BeautifulSoup(html_str, features="html.parser")
|
149
|
+
tc = TextCollector(max_length)
|
150
|
+
tc.add_chunk(soup)
|
151
|
+
return tc.text
|
152
|
+
|
153
|
+
|
154
|
+
|
155
|
+
def old_truncate_comment(html_str, max_p_len=None):
|
156
|
+
# returns a single paragraph with a maximum number of visible chars.
|
157
|
+
# No longer used. Replaced by new truncate_comment() below
|
158
|
+
if max_p_len is None:
|
159
|
+
max_p_len = settings.SITE.plugins.memo.short_preview_length
|
160
|
+
html_str = html_str.strip() # remove leading or trailing newlines
|
161
|
+
|
162
|
+
if not html_str.startswith("<"):
|
163
|
+
if len(html_str) > max_p_len:
|
164
|
+
txt = html_str[:max_p_len] + "..."
|
165
|
+
else:
|
166
|
+
txt = html_str
|
167
|
+
return txt
|
168
|
+
soup = BeautifulSoup(html_str, "html.parser")
|
169
|
+
ps = soup.find_all(
|
170
|
+
["p", "h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "h9", "pre"]
|
171
|
+
)
|
172
|
+
if len(ps) > 0:
|
173
|
+
anchor_end = "</a>"
|
174
|
+
txt = ""
|
175
|
+
for p in ps:
|
176
|
+
text = ""
|
177
|
+
for c in p.contents:
|
178
|
+
if isinstance(c, Tag):
|
179
|
+
if c.name == "a":
|
180
|
+
text += str(c)
|
181
|
+
max_p_len = max_p_len + len(text) - len(c.text)
|
182
|
+
else:
|
183
|
+
# text += str(c)
|
184
|
+
text += c.text
|
185
|
+
else:
|
186
|
+
text += str(c)
|
187
|
+
|
188
|
+
if len(txt) + len(text) > max_p_len:
|
189
|
+
txt += text
|
190
|
+
if anchor_end in txt:
|
191
|
+
ae_index = txt.index(anchor_end) + len(anchor_end)
|
192
|
+
if ae_index >= max_p_len:
|
193
|
+
txt = txt[:ae_index]
|
194
|
+
txt += "..."
|
195
|
+
break
|
196
|
+
txt = txt[:max_p_len]
|
197
|
+
txt += "..."
|
198
|
+
break
|
199
|
+
else:
|
200
|
+
txt += text + "\n\n"
|
201
|
+
return txt
|
202
|
+
return html_str
|
203
|
+
|
204
|
+
# remove these tags including their content.
|
205
|
+
blacklist = frozenset(["script", "style", "head"])
|
206
|
+
|
207
|
+
# unwrap these tags (remove the wrapper and leave the content)
|
208
|
+
unwrap = frozenset(["html", "body"])
|
209
|
+
|
210
|
+
useless_main_tags = frozenset(["p", "div", "span"])
|
211
|
+
|
212
|
+
ALLOWED_TAGS = frozenset([
|
213
|
+
"a",
|
214
|
+
"b",
|
215
|
+
"i",
|
216
|
+
"em",
|
217
|
+
"ul",
|
218
|
+
"ol",
|
219
|
+
"li",
|
220
|
+
"strong",
|
221
|
+
"p",
|
222
|
+
"br",
|
223
|
+
"span",
|
224
|
+
"pre",
|
225
|
+
"def",
|
226
|
+
"div",
|
227
|
+
"img",
|
228
|
+
"table",
|
229
|
+
"th",
|
230
|
+
"tr",
|
231
|
+
"td",
|
232
|
+
"thead",
|
233
|
+
"tfoot",
|
234
|
+
"tbody",
|
235
|
+
])
|
236
|
+
|
237
|
+
|
238
|
+
# Map of allowed attributes by tag. Copied from bleach.sanitizer:
|
239
|
+
ALLOWED_ATTRIBUTES = {
|
240
|
+
"a": {"href", "title"},
|
241
|
+
"abbr": {"title"},
|
242
|
+
"acronym": {"title"},
|
243
|
+
}
|
244
|
+
|
245
|
+
ALLOWED_ATTRIBUTES["span"] = {
|
246
|
+
"class",
|
247
|
+
"data-index",
|
248
|
+
"data-denotation-char",
|
249
|
+
"data-link",
|
250
|
+
"data-title",
|
251
|
+
"data-value",
|
252
|
+
"contenteditable",
|
253
|
+
}
|
254
|
+
ALLOWED_ATTRIBUTES["p"] = {"align"}
|
255
|
+
|
256
|
+
def safe_css(attr, css):
|
257
|
+
if attr == "style":
|
258
|
+
return re.sub("(width|height):[^;]+;", "", css)
|
259
|
+
return css
|
260
|
+
|
261
|
+
def sanitize(old):
|
262
|
+
|
263
|
+
# Inspired by https://chase-seibert.github.io/blog/2011/01/28/sanitize-html-with-beautiful-soup.html
|
264
|
+
|
265
|
+
old = old.strip()
|
266
|
+
if not old:
|
267
|
+
return old
|
268
|
+
|
269
|
+
try:
|
270
|
+
soup = BeautifulSoup(old, features="lxml")
|
271
|
+
except HTMLParseError as e:
|
272
|
+
logger.info("Could not sanitize %r : %s", old, e)
|
273
|
+
return f"Could not sanitize content ({e})"
|
274
|
+
|
275
|
+
for tag in soup.findAll():
|
276
|
+
# print(tag)
|
277
|
+
tag_name = tag.name.lower()
|
278
|
+
if tag_name in blacklist:
|
279
|
+
# blacklisted tags are removed in their entirety
|
280
|
+
tag.extract()
|
281
|
+
elif tag_name in unwrap:
|
282
|
+
tag.unwrap()
|
283
|
+
elif tag_name in ALLOWED_TAGS:
|
284
|
+
# tag is allowed. Make sure all the attributes are allowed.
|
285
|
+
allowed = ALLOWED_ATTRIBUTES.get(tag_name, None)
|
286
|
+
if allowed is None:
|
287
|
+
tag.attrs = dict()
|
288
|
+
else:
|
289
|
+
tag.attrs = {k: v for k, v in tag.attrs.items() if k in allowed}
|
290
|
+
else:
|
291
|
+
# print(tag.name)
|
292
|
+
# tag.decompose()
|
293
|
+
# tag.extract()
|
294
|
+
# not a whitelisted tag. I'd like to remove it from the tree
|
295
|
+
# and replace it with its children. But that's hard. It's much
|
296
|
+
# easier to just replace it with an empty span tag.
|
297
|
+
tag.name = "span"
|
298
|
+
tag.attrs = dict()
|
299
|
+
|
300
|
+
# remove all comments because they might contain scripts
|
301
|
+
comments = soup.findAll(text=lambda text:isinstance(text, (Comment, Doctype)))
|
302
|
+
for comment in comments:
|
303
|
+
comment.extract()
|
304
|
+
|
305
|
+
# remove the wrapper tag if it is useless
|
306
|
+
if len(soup.contents) == 1:
|
307
|
+
main_tag = soup.contents[0]
|
308
|
+
if main_tag.name in useless_main_tags and not main_tag.attrs:
|
309
|
+
main_tag.unwrap()
|
310
|
+
|
311
|
+
return str(soup).strip()
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.3
|
2
2
|
Name: lino
|
3
|
-
Version: 24.
|
3
|
+
Version: 24.11.1
|
4
4
|
Summary: A framework for writing desktop-like web applications using Django and ExtJS or React
|
5
5
|
Project-URL: Homepage, https://www.lino-framework.org
|
6
6
|
Project-URL: Repository, https://gitlab.com/lino-framework/lino
|
@@ -666,8 +666,6 @@ License: GNU AFFERO GENERAL PUBLIC LICENSE
|
|
666
666
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
667
667
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
668
668
|
<https://www.gnu.org/licenses/>.
|
669
|
-
License-File: AUTHORS.rst
|
670
|
-
License-File: COPYING
|
671
669
|
Keywords: Django,React,customized,framework
|
672
670
|
Classifier: Development Status :: 5 - Production/Stable
|
673
671
|
Classifier: Environment :: Web Environment
|