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.
Files changed (80) hide show
  1. lino/__init__.py +1 -1
  2. lino/api/doctest.py +11 -10
  3. lino/api/rt.py +2 -3
  4. lino/config/admin_main_base.html +2 -2
  5. lino/core/actions.py +2 -4
  6. lino/core/actors.py +70 -35
  7. lino/core/choicelists.py +2 -2
  8. lino/core/dashboard.py +2 -1
  9. lino/core/dbtables.py +15 -15
  10. lino/core/elems.py +8 -4
  11. lino/core/fields.py +12 -3
  12. lino/core/inject.py +9 -2
  13. lino/core/kernel.py +11 -11
  14. lino/core/layouts.py +1 -1
  15. lino/core/model.py +25 -36
  16. lino/core/plugin.py +1 -0
  17. lino/core/renderer.py +21 -21
  18. lino/core/requests.py +94 -83
  19. lino/core/site.py +9 -90
  20. lino/core/store.py +16 -19
  21. lino/core/tables.py +0 -17
  22. lino/core/utils.py +32 -2
  23. lino/core/views.py +2 -1
  24. lino/help_texts.py +10 -5
  25. lino/locale/bn/LC_MESSAGES/django.po +1210 -907
  26. lino/locale/de/LC_MESSAGES/django.po +1760 -1375
  27. lino/locale/django.pot +1136 -906
  28. lino/locale/es/LC_MESSAGES/django.po +1709 -1347
  29. lino/locale/et/LC_MESSAGES/django.po +1206 -906
  30. lino/locale/fr/LC_MESSAGES/django.mo +0 -0
  31. lino/locale/fr/LC_MESSAGES/django.po +1193 -923
  32. lino/locale/nl/LC_MESSAGES/django.po +1247 -942
  33. lino/locale/pt_BR/LC_MESSAGES/django.po +1190 -903
  34. lino/locale/zh_Hant/LC_MESSAGES/django.po +1190 -903
  35. lino/management/commands/show.py +2 -4
  36. lino/mixins/periods.py +15 -7
  37. lino/mixins/polymorphic.py +3 -3
  38. lino/mixins/ref.py +6 -3
  39. lino/modlib/checkdata/__init__.py +3 -3
  40. lino/modlib/comments/choicelists.py +1 -1
  41. lino/modlib/comments/fixtures/demo2.py +4 -1
  42. lino/modlib/comments/mixins.py +9 -10
  43. lino/modlib/comments/models.py +4 -4
  44. lino/modlib/comments/ui.py +5 -0
  45. lino/modlib/extjs/ext_renderer.py +1 -1
  46. lino/modlib/linod/consumers.py +2 -3
  47. lino/modlib/linod/mixins.py +3 -2
  48. lino/modlib/memo/mixins.py +11 -209
  49. lino/modlib/notify/mixins.py +33 -32
  50. lino/modlib/periods/__init__.py +12 -1
  51. lino/modlib/periods/fixtures/std.py +2 -1
  52. lino/modlib/periods/mixins.py +0 -1
  53. lino/modlib/periods/models.py +79 -75
  54. lino/modlib/printing/actions.py +2 -0
  55. lino/modlib/printing/choicelists.py +3 -3
  56. lino/modlib/publisher/ui.py +2 -2
  57. lino/modlib/search/models.py +17 -11
  58. lino/modlib/system/__init__.py +0 -2
  59. lino/modlib/system/choicelists.py +55 -1
  60. lino/modlib/system/fixtures/__init__.py +0 -0
  61. lino/modlib/system/fixtures/std.py +5 -0
  62. lino/modlib/system/models.py +4 -2
  63. lino/modlib/uploads/__init__.py +10 -1
  64. lino/modlib/uploads/choicelists.py +3 -3
  65. lino/modlib/uploads/mixins.py +30 -32
  66. lino/modlib/uploads/models.py +89 -56
  67. lino/modlib/uploads/ui.py +12 -6
  68. lino/modlib/uploads/utils.py +107 -0
  69. lino/modlib/users/models.py +2 -2
  70. lino/modlib/weasyprint/__init__.py +2 -0
  71. lino/utils/__init__.py +14 -9
  72. lino/utils/djangotest.py +2 -1
  73. lino/utils/html.py +32 -1
  74. lino/utils/media.py +2 -3
  75. lino/utils/soup.py +311 -0
  76. {lino-24.10.3.dist-info → lino-24.11.1.dist-info}/METADATA +1 -3
  77. {lino-24.10.3.dist-info → lino-24.11.1.dist-info}/RECORD +80 -76
  78. {lino-24.10.3.dist-info → lino-24.11.1.dist-info}/WHEEL +1 -1
  79. {lino-24.10.3.dist-info → lino-24.11.1.dist-info}/licenses/AUTHORS.rst +0 -0
  80. {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 user
69
+ file
70
70
  volume:10 library_file:40
71
- upload_area type description
72
- owner
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
- url = m.get_file_url()
201
- if url:
206
+ mf = m.get_media_file()
207
+ if mf:
202
208
  show = ar.renderer.href_button(
203
- url,
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"
@@ -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=20, blank=True)
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 if self.nickname else self.get_full_name()
147
+ return self.nickname or self.get_full_name()
148
148
 
149
149
  @property
150
150
  def is_active(self):
@@ -29,6 +29,8 @@ class Plugin(ad.Plugin):
29
29
 
30
30
  verbose_name = _("WeasyPrint")
31
31
 
32
+ needs_plugins = ["lino.modlib.jinja"]
33
+
32
34
  header_height = 20
33
35
  """Height of header in mm. Set to `None` if you want no header."""
34
36
 
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 contextlib
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
- @contextlib.contextmanager
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 = settings.SITE.login(username)
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(TmpMediaFile, self).__init__(
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.10.3
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