lino 24.11.0__py3-none-any.whl → 25.1.0__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/core/actions.py +2 -4
- lino/core/actors.py +35 -14
- lino/core/dbtables.py +12 -12
- lino/core/fields.py +27 -18
- lino/core/inject.py +9 -2
- lino/core/kernel.py +11 -12
- lino/core/model.py +25 -39
- lino/core/renderer.py +3 -4
- lino/core/requests.py +91 -92
- lino/core/site.py +5 -46
- lino/core/store.py +4 -4
- lino/core/tables.py +0 -16
- lino/core/utils.py +5 -2
- lino/help_texts.py +7 -5
- lino/locale/bn/LC_MESSAGES/django.po +1225 -909
- lino/locale/de/LC_MESSAGES/django.mo +0 -0
- lino/locale/de/LC_MESSAGES/django.po +1748 -1392
- lino/locale/django.pot +1150 -909
- lino/locale/es/LC_MESSAGES/django.po +1721 -1347
- lino/locale/et/LC_MESSAGES/django.po +1267 -954
- lino/locale/fr/LC_MESSAGES/django.mo +0 -0
- lino/locale/fr/LC_MESSAGES/django.po +1207 -925
- lino/locale/nl/LC_MESSAGES/django.po +1261 -942
- lino/locale/pt_BR/LC_MESSAGES/django.po +1204 -906
- lino/locale/zh_Hant/LC_MESSAGES/django.po +1204 -906
- lino/mixins/dupable.py +8 -7
- lino/mixins/periods.py +10 -8
- lino/modlib/checkdata/__init__.py +1 -1
- 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 +11 -2
- lino/modlib/dupable/models.py +10 -14
- lino/modlib/gfks/mixins.py +8 -1
- lino/modlib/jinja/choicelists.py +3 -3
- lino/modlib/jinja/renderer.py +2 -0
- lino/modlib/languages/fixtures/all_languages.py +4 -6
- lino/modlib/linod/mixins.py +3 -2
- lino/modlib/memo/mixins.py +17 -215
- lino/modlib/memo/models.py +41 -12
- lino/modlib/memo/parser.py +7 -3
- lino/modlib/notify/mixins.py +35 -34
- lino/modlib/periods/choicelists.py +46 -0
- lino/modlib/periods/mixins.py +26 -1
- lino/modlib/periods/models.py +17 -45
- lino/modlib/printing/choicelists.py +3 -3
- lino/modlib/publisher/choicelists.py +1 -4
- lino/modlib/search/models.py +17 -11
- lino/modlib/system/fixtures/__init__.py +0 -0
- lino/modlib/system/fixtures/std.py +5 -0
- lino/modlib/system/models.py +3 -2
- lino/modlib/uploads/__init__.py +10 -1
- lino/modlib/uploads/choicelists.py +3 -3
- lino/modlib/uploads/mixins.py +49 -51
- lino/modlib/uploads/models.py +144 -61
- lino/modlib/uploads/ui.py +12 -6
- lino/modlib/uploads/utils.py +113 -0
- lino/modlib/users/mixins.py +9 -6
- lino/modlib/weasyprint/choicelists.py +17 -7
- lino/utils/__init__.py +6 -0
- lino/utils/choosers.py +21 -8
- lino/utils/html.py +1 -1
- lino/utils/instantiator.py +9 -0
- lino/utils/media.py +2 -3
- lino/utils/soup.py +311 -0
- {lino-24.11.0.dist-info → lino-25.1.0.dist-info}/METADATA +2 -2
- {lino-24.11.0.dist-info → lino-25.1.0.dist-info}/RECORD +72 -67
- {lino-24.11.0.dist-info → lino-25.1.0.dist-info}/WHEEL +1 -1
- {lino-24.11.0.dist-info → lino-25.1.0.dist-info}/licenses/AUTHORS.rst +0 -0
- {lino-24.11.0.dist-info → lino-25.1.0.dist-info}/licenses/COPYING +0 -0
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", "style"}
|
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
|
-
Metadata-Version: 2.
|
1
|
+
Metadata-Version: 2.4
|
2
2
|
Name: lino
|
3
|
-
Version:
|
3
|
+
Version: 25.1.0
|
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
|