Mercurial > libervia-web
comparison libervia/web/pages/blog/page_meta.py @ 1624:fd421f1be8f5 default tip
browser (blog): blog redesign first draft:
Redesign the blog in the same spirit as the chat, with a left panel to search/open new
blogs, so there is no "selection" page anymore.
Reactions are now managed.
author | Goffi <goffi@goffi.org> |
---|---|
date | Wed, 21 May 2025 15:58:56 +0200 |
parents | eb00d593801d |
children |
comparison
equal
deleted
inserted
replaced
1623:fdb5689fb826 | 1624:fd421f1be8f5 |
---|---|
1 #!/usr/bin/env python3 | 1 #!/usr/bin/env python3 |
2 | 2 |
3 from libervia.backend.core.i18n import _ | 3 import html |
4 from typing import Any, Dict, Optional | |
5 | |
6 from libervia.backend.core.i18n import D_, _ | |
7 from libervia.backend.core.log import getLogger | |
8 from libervia.backend.tools.common import uri | |
9 from libervia.backend.tools.common import data_format | |
10 from libervia.backend.tools.common import regex | |
11 from libervia.backend.tools.common.template import safe | |
12 from twisted.web import server | |
13 from twisted.words.protocols.jabber import jid | |
14 | |
15 from libervia.web.server import utils | |
4 from libervia.web.server.constants import Const as C | 16 from libervia.web.server.constants import Const as C |
5 from twisted.words.protocols.jabber import jid | 17 from libervia.web.server.utils import SubPage |
6 from twisted.internet import defer | |
7 from libervia.web.server import session_iface | |
8 from libervia.backend.tools.common import data_format | |
9 from libervia.backend.core.log import getLogger | |
10 | 18 |
11 log = getLogger(__name__) | 19 log = getLogger(__name__) |
12 | 20 |
13 name = "blog" | 21 """generic blog (with service/node provided)""" |
14 access = C.PAGES_ACCESS_PUBLIC | 22 name = 'blog' |
15 template = "blog/discover.html" | 23 access = C.PAGES_ACCESS_PROFILE |
16 | 24 template = "blog/blog.html" |
25 uri_handlers = {('pubsub', 'microblog'): 'microblog_uri'} | |
26 | |
27 URL_LIMIT_MARK = 90 # if canonical URL is longer than that, text will not be appended | |
28 | |
29 | |
30 def microblog_uri(self, uri_data): | |
31 args = [uri_data['path'], uri_data['node']] | |
32 if 'item' in uri_data: | |
33 args.extend(['id', uri_data['item']]) | |
34 return self.get_url(*args) | |
35 | |
36 def parse_url(self, request): | |
37 """URL is /[service]/[node]/[filter_keyword]/[item]|[other] | |
38 | |
39 if [node] is '@', default namespace is used | |
40 if a value is unset, default one will be used | |
41 keyword can be one of: | |
42 id: next value is a item id | |
43 tag: next value is a blog tag | |
44 """ | |
45 data = self.get_r_data(request) | |
46 | |
47 try: | |
48 service = self.next_path(request) | |
49 except IndexError: | |
50 service = self.get_jid(request).userhost() | |
51 | |
52 try: | |
53 data["service"] = jid.JID(service) | |
54 except Exception: | |
55 log.warning(_("bad service entered: {}").format(service)) | |
56 self.page_error(request, C.HTTP_BAD_REQUEST) | |
57 | |
58 try: | |
59 node = self.next_path(request) | |
60 except IndexError: | |
61 node = '@' | |
62 data['node'] = '' if node == '@' else node | |
63 | |
64 try: | |
65 filter_kw = data['filter_keyword'] = self.next_path(request) | |
66 except IndexError: | |
67 filter_kw = '@' | |
68 else: | |
69 if filter_kw == '@': | |
70 # No filter, this is used when a subpage is needed, notably Atom feed | |
71 pass | |
72 elif filter_kw == 'id': | |
73 try: | |
74 data['item'] = self.next_path(request) | |
75 except IndexError: | |
76 self.page_error(request, C.HTTP_BAD_REQUEST) | |
77 # we get one more argument in case text has been added to have a nice URL | |
78 try: | |
79 self.next_path(request) | |
80 except IndexError: | |
81 pass | |
82 elif filter_kw == 'tag': | |
83 try: | |
84 data['tag'] = self.next_path(request) | |
85 except IndexError: | |
86 self.page_error(request, C.HTTP_BAD_REQUEST) | |
87 else: | |
88 # invalid filter keyword | |
89 log.warning(_("invalid filter keyword: {filter_kw}").format( | |
90 filter_kw=filter_kw)) | |
91 self.page_error(request, C.HTTP_BAD_REQUEST) | |
92 | |
93 # if URL is parsed here, we'll have atom.xml available and we need to | |
94 # add the link to the page | |
95 atom_url = self.get_url_by_path( | |
96 SubPage('blog_view'), | |
97 service, | |
98 node, | |
99 filter_kw, | |
100 SubPage('blog_feed_atom'), | |
101 ) | |
102 request.template_data['atom_url'] = atom_url | |
103 request.template_data.setdefault('links', []).append({ | |
104 "href": atom_url, | |
105 "type": "application/atom+xml", | |
106 "rel": "alternate", | |
107 "title": "{service}'s blog".format(service=service)}) | |
108 | |
109 | |
110 def add_breadcrumb(self, request, breadcrumbs): | |
111 data = self.get_r_data(request) | |
112 breadcrumbs.append({ | |
113 "label": D_("Feed"), | |
114 "url": self.get_url(data["service"].full(), data.get("node", "@")) | |
115 }) | |
116 if "item" in data: | |
117 breadcrumbs.append({ | |
118 "label": D_("Post"), | |
119 }) | |
120 | |
121 | |
122 async def append_comments( | |
123 self, | |
124 request: server.Request, | |
125 blog_items: dict, | |
126 profile: str, | |
127 _seen: Optional[set] = None | |
128 ) -> None: | |
129 """Recursively download and append comments of items | |
130 | |
131 @param blog_items: items data | |
132 @param profile: Libervia profile | |
133 @param _seen: used to avoid infinite recursion. For internal use only | |
134 """ | |
135 if _seen is None: | |
136 _seen = set() | |
137 await self.fill_missing_identities( | |
138 request, [i['author_jid'] for i in blog_items['items']]) | |
139 extra: Dict[str, Any] = {C.KEY_ORDER_BY: C.ORDER_BY_CREATION} | |
140 if not self.use_cache(request): | |
141 extra[C.KEY_USE_CACHE] = False | |
142 for blog_item in blog_items['items']: | |
143 for comment_data in blog_item['comments']: | |
144 service = comment_data['service'] | |
145 node = comment_data['node'] | |
146 service_node = (service, node) | |
147 if service_node in _seen: | |
148 log.warning( | |
149 f"Items from {node!r} at {service} have already been retrieved, " | |
150 "there is a recursion at this service" | |
151 ) | |
152 comment_data["items"] = [] | |
153 continue | |
154 else: | |
155 _seen.add(service_node) | |
156 try: | |
157 comments_data = await self.host.bridge_call('mb_get', | |
158 service, | |
159 node, | |
160 C.NO_LIMIT, | |
161 [], | |
162 data_format.serialise( | |
163 extra | |
164 ), | |
165 profile) | |
166 except Exception as e: | |
167 log.warning( | |
168 _("Can't get comments at {node} (service: {service}): {msg}").format( | |
169 service=service, | |
170 node=node, | |
171 msg=e)) | |
172 comment_data['items'] = [] | |
173 continue | |
174 | |
175 comments = data_format.deserialise(comments_data) | |
176 if comments is None: | |
177 log.error(f"Comments should not be None: {comment_data}") | |
178 comment_data["items"] = [] | |
179 continue | |
180 comment_data['items'] = comments['items'] | |
181 await append_comments(self, request, comments, profile, _seen=_seen) | |
182 | |
183 async def get_blog_items( | |
184 self, | |
185 request: server.Request, | |
186 service: jid.JID, | |
187 node: str, | |
188 item_id, | |
189 extra: Dict[str, Any], | |
190 profile: str | |
191 ) -> dict: | |
192 try: | |
193 if item_id: | |
194 items_id = [item_id] | |
195 else: | |
196 items_id = [] | |
197 if not self.use_cache(request): | |
198 extra[C.KEY_USE_CACHE] = False | |
199 blog_data = await self.host.bridge_call('mb_get', | |
200 service.userhost(), | |
201 node, | |
202 C.NO_LIMIT, | |
203 items_id, | |
204 data_format.serialise(extra), | |
205 profile) | |
206 except Exception as e: | |
207 # FIXME: need a better way to test errors in bridge errback | |
208 if "forbidden" in str(e): | |
209 self.page_error(request, 401) | |
210 else: | |
211 log.warning(_("can't retrieve blog for [{service}]: {msg}".format( | |
212 service = service.userhost(), msg=e))) | |
213 blog_data = {"items": []} | |
214 else: | |
215 blog_data = data_format.deserialise(blog_data) | |
216 | |
217 return blog_data | |
17 | 218 |
18 async def prepare_render(self, request): | 219 async def prepare_render(self, request): |
220 data = self.get_r_data(request) | |
221 template_data = request.template_data | |
222 page_max = data.get("page_max", 10) | |
223 # if the comments are not explicitly hidden, we show them | |
224 service, node, item_id, show_comments = ( | |
225 data.get('service', ''), | |
226 data.get('node', ''), | |
227 data.get('item'), | |
228 data.get('show_comments', True) | |
229 ) | |
19 profile = self.get_profile(request) | 230 profile = self.get_profile(request) |
20 template_data = request.template_data | 231 if profile is None: |
21 if profile is not None: | 232 profile = C.SERVICE_PROFILE |
22 __, entities_own, entities_roster = await self.host.bridge_call( | 233 profile_connected = False |
23 "disco_find_by_features", | 234 else: |
24 [], | 235 profile_connected = True |
25 [("pubsub", "pep")], | 236 |
26 True, | 237 ## pagination/filtering parameters |
27 False, | 238 if item_id: |
28 True, | 239 extra = {} |
29 True, | 240 else: |
30 True, | 241 extra = self.get_pubsub_extra(request, page_max=page_max) |
31 profile, | 242 tag = data.get('tag') |
32 ) | 243 if tag: |
33 entities = template_data["disco_entities"] = ( | 244 extra[f'mam_filter_{C.MAM_FILTER_CATEGORY}'] = tag |
34 list(entities_own.keys()) + list(entities_roster.keys()) | 245 self.handle_search(request, extra) |
35 ) | 246 |
36 entities_url = template_data["entities_url"] = {} | 247 ## main data ## |
37 identities = self.host.get_session_data( | 248 # we get data from backend/XMPP here |
38 request, session_iface.IWebSession | 249 blog_items = await get_blog_items(self, request, service, node, item_id, extra, profile) |
39 ).identities | 250 |
40 d_list = {} | 251 ## navigation ## |
41 for entity_jid_s in entities: | 252 # no let's fill service, node and pagination URLs |
42 entities_url[entity_jid_s] = self.get_page_by_name("blog_view").get_url( | 253 if 'service' not in template_data: |
43 entity_jid_s | 254 template_data['service'] = service |
255 if 'node' not in template_data: | |
256 template_data['node'] = node | |
257 target_profile = template_data.get('target_profile') | |
258 | |
259 if blog_items: | |
260 if item_id: | |
261 template_data["previous_page_url"] = self.get_url( | |
262 service.full(), | |
263 node, | |
264 before=item_id, | |
265 page_max=1 | |
44 ) | 266 ) |
45 if entity_jid_s not in identities: | 267 template_data["next_page_url"] = self.get_url( |
46 d_list[entity_jid_s] = self.host.bridge_call( | 268 service.full(), |
47 "identity_get", | 269 node, |
48 entity_jid_s, | 270 after=item_id, |
49 [], | 271 page_max=1 |
50 True, | 272 ) |
51 profile) | 273 blog_items["rsm"] = { |
52 identities_data = await defer.DeferredList(d_list.values()) | 274 "last": item_id, |
53 entities_idx = list(d_list.keys()) | 275 "first": item_id, |
54 for idx, (success, identity_raw) in enumerate(identities_data): | 276 } |
55 entity_jid_s = entities_idx[idx] | 277 blog_items["complete"] = False |
56 if not success: | 278 else: |
57 log.warning(_("Can't retrieve identity of {entity}") | 279 self.set_pagination(request, blog_items) |
58 .format(entity=entity_jid_s)) | 280 else: |
281 if item_id: | |
282 # if item id has been specified in URL and it's not found, | |
283 # we must return an error | |
284 self.page_error(request, C.HTTP_NOT_FOUND) | |
285 | |
286 ## identities ## | |
287 # identities are used to show nice nickname or avatars | |
288 await self.fill_missing_identities(request, [i['author_jid'] for i in blog_items['items']]) | |
289 | |
290 ## Comments ## | |
291 # if comments are requested, we need to take them | |
292 if show_comments: | |
293 await append_comments(self, request, blog_items, profile) | |
294 | |
295 ## URLs ## | |
296 # We will fill items_http_uri and tags_http_uri in template_data with suitable urls | |
297 # if we know the profile, we use it instead of service + blog (nicer url) | |
298 if target_profile is None: | |
299 blog_base_url_item = self.get_page_by_name('blog_view').get_url(service.full(), node or '@', 'id') | |
300 blog_base_url_tag = self.get_page_by_name('blog_view').get_url(service.full(), node or '@', 'tag') | |
301 else: | |
302 blog_base_url_item = self.get_url_by_names([('user', [target_profile]), ('user_blog', ['id'])]) | |
303 blog_base_url_tag = self.get_url_by_names([('user', [target_profile]), ('user_blog', ['tag'])]) | |
304 # we also set the background image if specified by user | |
305 bg_img = await self.host.bridge_call('param_get_a_async', 'Background', 'Blog page', 'value', -1, template_data['target_profile']) | |
306 if bg_img: | |
307 template_data['dynamic_style'] = safe(""" | |
308 :root { | |
309 --bg-img: url("%s"); | |
310 } | |
311 """ % html.escape(bg_img, True)) | |
312 | |
313 template_data['blog_items'] = data['blog_items'] = blog_items | |
314 if request.args.get(b'reverse') == ['1']: | |
315 template_data['blog_items'].items.reverse() | |
316 template_data['items_http_uri'] = items_http_uri = {} | |
317 template_data['tags_http_uri'] = tags_http_uri = {} | |
318 | |
319 | |
320 for item in blog_items['items']: | |
321 blog_canonical_url = '/'.join([blog_base_url_item, utils.quote(item['id'])]) | |
322 if len(blog_canonical_url) > URL_LIMIT_MARK: | |
323 blog_url = blog_canonical_url | |
324 elif '-' not in item['id']: | |
325 # we add text from title or body at the end of URL | |
326 # to make it more human readable | |
327 # we do it only if there is no "-", as a "-" probably means that | |
328 # item's id is already user friendly. | |
329 # TODO: to be removed, this is only kept for a transition period until | |
330 # user friendly item IDs are more common. | |
331 text = regex.url_friendly_text(item.get('title', item['content'])) | |
332 if text: | |
333 blog_url = blog_canonical_url + '/' + text | |
59 else: | 334 else: |
60 identities[entity_jid_s] = data_format.deserialise(identity_raw) | 335 blog_url = blog_canonical_url |
61 | 336 else: |
62 template_data["url_blog_edit"] = self.get_sub_page_url(request, "blog_edit") | 337 blog_url = blog_canonical_url |
63 | 338 |
64 | 339 items_http_uri[item['id']] = self.host.get_ext_base_url(request, blog_url) |
65 def on_data_post(self, request): | 340 for tag in item['tags']: |
66 jid_str = self.get_posted_data(request, "jid") | 341 if tag not in tags_http_uri: |
67 try: | 342 tag_url = '/'.join([blog_base_url_tag, utils.quote(tag)]) |
68 jid_ = jid.JID(jid_str) | 343 tags_http_uri[tag] = self.host.get_ext_base_url(request, tag_url) |
69 except RuntimeError: | 344 |
70 self.page_error(request, C.HTTP_BAD_REQUEST) | 345 # if True, page should display a comment box |
71 url = self.get_page_by_name("blog_view").get_url(jid_.full()) | 346 template_data['allow_commenting'] = data.get('allow_commenting', profile_connected) |
72 self.http_redirect(request, url) | 347 |
348 # last but not least, we add a xmpp: link to the node | |
349 uri_args = {'path': service.full()} | |
350 if node: | |
351 uri_args['node'] = node | |
352 if item_id: | |
353 uri_args['item'] = item_id | |
354 template_data['xmpp_uri'] = uri.build_xmpp_uri( | |
355 'pubsub', subtype='microblog', **uri_args | |
356 ) | |
357 self.expose_to_scripts( | |
358 request, | |
359 blog_url=self.url, | |
360 service = service.full(), | |
361 node = node or self.host.ns_map["microblog"] | |
362 ) | |
363 | |
364 | |
365 async def on_data_post(self, request): | |
366 profile = self.get_profile(request) | |
367 if profile is None: | |
368 self.page_error(request, C.HTTP_FORBIDDEN) | |
369 type_ = self.get_posted_data(request, 'type') | |
370 if type_ == 'comment': | |
371 service, node, body = self.get_posted_data(request, ('service', 'node', 'body')) | |
372 | |
373 if not body: | |
374 self.page_error(request, C.HTTP_BAD_REQUEST) | |
375 comment_data = {"content_rich": body} | |
376 try: | |
377 await self.host.bridge_call('mb_send', | |
378 service, | |
379 node, | |
380 data_format.serialise(comment_data), | |
381 profile) | |
382 except Exception as e: | |
383 if "forbidden" in str(e): | |
384 self.page_error(request, 401) | |
385 else: | |
386 raise e | |
387 else: | |
388 log.warning(_("Unhandled data type: {}").format(type_)) |