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.
Files changed (72) hide show
  1. lino/__init__.py +1 -1
  2. lino/core/actions.py +2 -4
  3. lino/core/actors.py +35 -14
  4. lino/core/dbtables.py +12 -12
  5. lino/core/fields.py +27 -18
  6. lino/core/inject.py +9 -2
  7. lino/core/kernel.py +11 -12
  8. lino/core/model.py +25 -39
  9. lino/core/renderer.py +3 -4
  10. lino/core/requests.py +91 -92
  11. lino/core/site.py +5 -46
  12. lino/core/store.py +4 -4
  13. lino/core/tables.py +0 -16
  14. lino/core/utils.py +5 -2
  15. lino/help_texts.py +7 -5
  16. lino/locale/bn/LC_MESSAGES/django.po +1225 -909
  17. lino/locale/de/LC_MESSAGES/django.mo +0 -0
  18. lino/locale/de/LC_MESSAGES/django.po +1748 -1392
  19. lino/locale/django.pot +1150 -909
  20. lino/locale/es/LC_MESSAGES/django.po +1721 -1347
  21. lino/locale/et/LC_MESSAGES/django.po +1267 -954
  22. lino/locale/fr/LC_MESSAGES/django.mo +0 -0
  23. lino/locale/fr/LC_MESSAGES/django.po +1207 -925
  24. lino/locale/nl/LC_MESSAGES/django.po +1261 -942
  25. lino/locale/pt_BR/LC_MESSAGES/django.po +1204 -906
  26. lino/locale/zh_Hant/LC_MESSAGES/django.po +1204 -906
  27. lino/mixins/dupable.py +8 -7
  28. lino/mixins/periods.py +10 -8
  29. lino/modlib/checkdata/__init__.py +1 -1
  30. lino/modlib/comments/choicelists.py +1 -1
  31. lino/modlib/comments/fixtures/demo2.py +4 -1
  32. lino/modlib/comments/mixins.py +9 -10
  33. lino/modlib/comments/models.py +4 -4
  34. lino/modlib/comments/ui.py +11 -2
  35. lino/modlib/dupable/models.py +10 -14
  36. lino/modlib/gfks/mixins.py +8 -1
  37. lino/modlib/jinja/choicelists.py +3 -3
  38. lino/modlib/jinja/renderer.py +2 -0
  39. lino/modlib/languages/fixtures/all_languages.py +4 -6
  40. lino/modlib/linod/mixins.py +3 -2
  41. lino/modlib/memo/mixins.py +17 -215
  42. lino/modlib/memo/models.py +41 -12
  43. lino/modlib/memo/parser.py +7 -3
  44. lino/modlib/notify/mixins.py +35 -34
  45. lino/modlib/periods/choicelists.py +46 -0
  46. lino/modlib/periods/mixins.py +26 -1
  47. lino/modlib/periods/models.py +17 -45
  48. lino/modlib/printing/choicelists.py +3 -3
  49. lino/modlib/publisher/choicelists.py +1 -4
  50. lino/modlib/search/models.py +17 -11
  51. lino/modlib/system/fixtures/__init__.py +0 -0
  52. lino/modlib/system/fixtures/std.py +5 -0
  53. lino/modlib/system/models.py +3 -2
  54. lino/modlib/uploads/__init__.py +10 -1
  55. lino/modlib/uploads/choicelists.py +3 -3
  56. lino/modlib/uploads/mixins.py +49 -51
  57. lino/modlib/uploads/models.py +144 -61
  58. lino/modlib/uploads/ui.py +12 -6
  59. lino/modlib/uploads/utils.py +113 -0
  60. lino/modlib/users/mixins.py +9 -6
  61. lino/modlib/weasyprint/choicelists.py +17 -7
  62. lino/utils/__init__.py +6 -0
  63. lino/utils/choosers.py +21 -8
  64. lino/utils/html.py +1 -1
  65. lino/utils/instantiator.py +9 -0
  66. lino/utils/media.py +2 -3
  67. lino/utils/soup.py +311 -0
  68. {lino-24.11.0.dist-info → lino-25.1.0.dist-info}/METADATA +2 -2
  69. {lino-24.11.0.dist-info → lino-25.1.0.dist-info}/RECORD +72 -67
  70. {lino-24.11.0.dist-info → lino-25.1.0.dist-info}/WHEEL +1 -1
  71. {lino-24.11.0.dist-info → lino-25.1.0.dist-info}/licenses/AUTHORS.rst +0 -0
  72. {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(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", "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.3
1
+ Metadata-Version: 2.4
2
2
  Name: lino
3
- Version: 24.11.0
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