]> git.ipfire.org Git - ipfire.org.git/blame - src/backend/blog.py
wiki: Hide breadcrumbs on lower levels
[ipfire.org.git] / src / backend / blog.py
CommitLineData
0a6875dc
MT
1#!/usr/bin/python
2
541c952b 3import datetime
c70a7c29 4import feedparser
2de5ad8a 5import markdown2
c70a7c29 6import re
7e64f6a3 7import textile
c70a7c29 8import unicodedata
7e64f6a3 9
0a6875dc 10from . import misc
a3a850a4 11from .decorators import *
0a6875dc 12
2de5ad8a
MT
13# Used to automatically link some things
14link_patterns = (
15 # Find bug reports
16 (re.compile(r"(?:#(\d+))", re.I), r"https://bugzilla.ipfire.org/show_bug.cgi?id=\1"),
17
18 # Email Addresses
19 (re.compile(r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"), r"mailto:\1"),
20
21 # CVE Numbers
5c6aa47c 22 (re.compile(r"(?:CVE)[\s\-](\d{4}\-\d+)"), r"https://cve.mitre.org/cgi-bin/cvename.cgi?name=\1"),
2de5ad8a
MT
23)
24
0a6875dc
MT
25class Blog(misc.Object):
26 def _get_post(self, query, *args):
27 res = self.db.get(query, *args)
28
29 if res:
30 return Post(self.backend, res.id, data=res)
31
32 def _get_posts(self, query, *args):
33 res = self.db.query(query, *args)
34
35 for row in res:
36 yield Post(self.backend, row.id, data=row)
37
487417ad
MT
38 def get_by_id(self, id):
39 return self._get_post("SELECT * FROM blog \
40 WHERE id = %s", id)
41
df157ede
MT
42 def get_by_slug(self, slug, published=True):
43 if published:
44 return self._get_post("SELECT * FROM blog \
45 WHERE slug = %s AND published_at <= NOW()", slug)
46
0a6875dc 47 return self._get_post("SELECT * FROM blog \
df157ede
MT
48 WHERE slug = %s", slug)
49
0a6875dc
MT
50 def get_newest(self, limit=None):
51 return self._get_posts("SELECT * FROM blog \
52 WHERE published_at IS NOT NULL \
53 AND published_at <= NOW() \
54 ORDER BY published_at DESC LIMIT %s", limit)
55
56 def get_by_tag(self, tag, limit=None):
57 return self._get_posts("SELECT * FROM blog \
58 WHERE published_at IS NOT NULL \
59 AND published_at <= NOW() \
60 AND %s = ANY(tags) \
4bde7f18 61 ORDER BY published_at DESC LIMIT %s", tag, limit)
0a6875dc 62
cdf85ee7 63 def get_by_author(self, author, limit=None):
0a6875dc 64 return self._get_posts("SELECT * FROM blog \
cdf85ee7 65 WHERE (author = %s OR author_uid = %s) \
0a6875dc
MT
66 AND published_at IS NOT NULL \
67 AND published_at <= NOW() \
cdf85ee7
MT
68 ORDER BY published_at DESC LIMIT %s",
69 author.name, author.uid, limit)
0a6875dc 70
7e64f6a3
MT
71 def get_by_year(self, year):
72 return self._get_posts("SELECT * FROM blog \
73 WHERE EXTRACT(year FROM published_at) = %s \
74 AND published_at IS NOT NULL \
75 AND published_at <= NOW() \
76 ORDER BY published_at DESC", year)
77
0b342a05
MT
78 def get_drafts(self, author=None, limit=None):
79 if author:
80 return self._get_posts("SELECT * FROM blog \
81 WHERE author_uid = %s \
82 AND (published_at IS NULL OR published_at > NOW()) \
83 ORDER BY COALESCE(updated_at, created_at) DESC LIMIT %s",
84 author.uid, limit)
85
86 return self._get_posts("SELECT * FROM blog \
87 WHERE (published_at IS NULL OR published_at > NOW()) \
88 ORDER BY COALESCE(updated_at, created_at) DESC LIMIT %s", limit)
89
0a6875dc 90 def search(self, query, limit=None):
a47ddc3d
MT
91 query = self._parse_search_query(query)
92
0a6875dc
MT
93 return self._get_posts("SELECT blog.* FROM blog \
94 LEFT JOIN blog_search_index search_index ON blog.id = search_index.post_id \
95 WHERE search_index.document @@ to_tsquery('english', %s) \
96 ORDER BY ts_rank(search_index.document, to_tsquery('english', %s)) DESC \
97 LIMIT %s", query, query, limit)
98
a47ddc3d
MT
99 def _parse_search_query(self, query):
100 q = []
101 for word in query.split():
102 # Is this lexeme negated?
103 negated = word.startswith("!")
104
105 # Remove any special characters
106 word = re.sub(r"\W+", "", word, flags=re.UNICODE)
107 if not word:
108 continue
109
110 # Restore negation
111 if negated:
112 word = "!%s" % word
113
114 q.append(word)
115
116 return " & ".join(q)
117
694c4f08 118 def create_post(self, title, text, author, tags=[], lang="markdown"):
541c952b
MT
119 """
120 Creates a new post and returns the resulting Post object
121 """
694c4f08
MT
122 # Pre-render HTML
123 html = self._render_text(text, lang=lang)
124
125 return self._get_post("INSERT INTO blog(title, slug, text, html, lang, author_uid, tags) \
126 VALUES(%s, %s, %s, %s, %s, %s, %s) RETURNING *", title, self._make_slug(title), text,
127 html, lang, author.uid, list(tags))
541c952b 128
c70a7c29
MT
129 def _make_slug(self, s):
130 # Remove any non-ASCII characters
131 try:
132 s = unicodedata.normalize("NFKD", s)
133 except TypeError:
134 pass
135
136 # Remove excessive whitespace
137 s = re.sub(r"[^\w]+", " ", s)
138
139 slug = "-".join(s.split()).lower()
140
141 while True:
142 e = self.db.get("SELECT 1 FROM blog WHERE slug = %s", slug)
143 if not e:
144 break
145
146 slug += "-"
147
148 return slug
149
2de5ad8a
MT
150 def _render_text(self, text, lang="markdown"):
151 if lang == "markdown":
152 return markdown2.markdown(text, link_patterns=link_patterns,
e0ef6d39
MT
153 extras=[
154 "code-friendly",
155 "cuddled-lists",
156 "fenced-code-blocks",
157 "footnotes",
158 "header-ids",
159 "link-patterns",
160 "tables",
161 ])
2de5ad8a
MT
162
163 elif lang == "textile":
164 return textile.textile(text)
165
166 return text
167
0a6875dc
MT
168 def refresh(self):
169 """
170 Needs to be called after a post has been changed
171 and updates the search index.
172 """
173 self.db.execute("REFRESH MATERIALIZED VIEW blog_search_index")
174
7e64f6a3
MT
175 @property
176 def years(self):
177 res = self.db.query("SELECT DISTINCT EXTRACT(year FROM published_at)::integer AS year \
178 FROM blog WHERE published_at IS NOT NULL AND published_at <= NOW() \
179 ORDER BY year DESC")
180
181 for row in res:
182 yield row.year
183
c70a7c29
MT
184 def update_feeds(self):
185 """
186 Updates all enabled feeds
187 """
188 for feed in self.db.query("SELECT * FROM blog_feeds WHERE enabled IS TRUE"):
189 try:
190 f = feedparser.parse(feed.url)
191 except Exception as e:
192 raise e
193
194 with self.db.transaction():
195 # Update name
196 self.db.execute("UPDATE blog_feeds SET name = %s \
197 WHERE id = %s", f.feed.title, feed.id)
198
199 # Walk through all entries
200 for entry in f.entries:
201 # Skip everything without the "blog.ipfire.org" tag
202 try:
203 tags = list((t.term for t in entry.tags))
204
205 if not "blog.ipfire.org" in tags:
206 continue
207 except AttributeError:
208 continue
209
210 # Get link to the posting site
211 link = entry.links[0].href
212
213 # Check if the entry has already been imported
214 res = self.db.get("SELECT id, (updated_at < %s) AS needs_update \
215 FROM blog WHERE feed_id = %s AND foreign_id = %s",
216 entry.updated, feed.id, entry.id)
217 if res:
218 # If the post needs to be updated, we do so
219 if res.needs_update:
220 self.db.execute("UPDATE blog SET title = %s, author = %s, \
221 published_at = %s, updated_at = %s, html = %s, link = %s, \
222 tags = %s WHERE id = %s", entry.title, entry.author,
223 entry.published, entry.updated, entry.summary, link,
224 feed.tags + tags, res.id)
225
226 # Done here
227 continue
228
229 # Insert the new post
230 self.db.execute("INSERT INTO blog(title, slug, author, \
231 published_at, html, link, tags, updated_at, feed_id, foreign_id) \
232 VALUES(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
233 entry.title, self._make_slug(entry.title), entry.author,
234 entry.published, entry.summary, link, feed.tags + tags,
235 entry.updated, feed.id, entry.id)
236
20277bf5
MT
237 # Mark feed as updated
238 self.db.execute("UPDATE blog_feeds SET last_updated_at = CURRENT_TIMESTAMP \
239 WHERE id = %s" % feed.id)
240
c70a7c29
MT
241 # Refresh the search index
242 with self.db.transaction():
243 self.refresh()
244
0a6875dc
MT
245
246class Post(misc.Object):
247 def init(self, id, data=None):
248 self.id = id
249 self.data = data
250
541c952b
MT
251 # Title
252
93725180
MT
253 @property
254 def title(self):
0a6875dc
MT
255 return self.data.title
256
257 @property
258 def slug(self):
259 return self.data.slug
260
a3a850a4 261 @lazy_property
0a6875dc
MT
262 def author(self):
263 if self.data.author_uid:
264 return self.backend.accounts.get_by_uid(self.data.author_uid)
265
cdf85ee7
MT
266 return self.data.author
267
0a6875dc
MT
268 @property
269 def created_at(self):
270 return self.data.created_at
271
2de5ad8a
MT
272 @property
273 def lang(self):
274 return self.data.lang
275
541c952b
MT
276 # Published?
277
0a6875dc
MT
278 @property
279 def published_at(self):
280 return self.data.published_at
281
541c952b
MT
282 def is_published(self):
283 """
284 Returns True if the post is already published
285 """
286 return self.published_at and self.published_at <= datetime.datetime.now()
287
9ea64cef 288 def publish(self, when=None):
541c952b
MT
289 if self.is_published():
290 return
291
9ea64cef
MT
292 self.db.execute("UPDATE blog SET published_at = COALESCE(%s, CURRENT_TIMESTAMP) \
293 WHERE id = %s", when, self.id)
541c952b
MT
294
295 # Update search indices
296 self.backend.blog.refresh()
297
298 # Updated?
299
7e64f6a3 300 @property
541c952b
MT
301 def updated_at(self):
302 return self.data.updated_at
303
541c952b
MT
304 # Text
305
93725180
MT
306 @property
307 def text(self):
541c952b
MT
308 return self.data.text
309
541c952b 310 # HTML
7e64f6a3 311
a3a850a4 312 @lazy_property
0a6875dc
MT
313 def html(self):
314 """
315 Returns this post as rendered HTML
316 """
2de5ad8a 317 return self.data.html or self.backend.blog._render_text(self.text, lang=self.lang)
8ebc98d4 318
541c952b
MT
319 # Tags
320
93725180
MT
321 @property
322 def tags(self):
8ebc98d4 323 return self.data.tags
1e76fec4 324
93725180 325 # Link
541c952b 326
1e76fec4
MT
327 @property
328 def link(self):
329 return self.data.link
984e4e7b 330
a3a850a4 331 @lazy_property
984e4e7b
MT
332 def release(self):
333 return self.backend.releases._get_release("SELECT * FROM releases \
334 WHERE published IS NOT NULL AND published <= NOW() AND blog_id = %s", self.id)
e8a81a70
MT
335
336 def is_editable(self, editor):
337 # Authors can edit their own posts
338 return self.author == editor
93725180
MT
339
340 def update(self, title, text, tags=[]):
341 """
342 Called to update the content of this post
343 """
344 # Update slug when post isn't published yet
baa294fb
MT
345 slug = self.backend.blog._make_slug(title) \
346 if not self.is_published() and not self.title == title else self.slug
93725180 347
694c4f08
MT
348 # Render and cache HTML
349 html = self.backend.blog._render_text(text, lang=self.lang)
93725180 350
694c4f08 351 self.db.execute("UPDATE blog SET title = %s, slug = %s, text = %s, html = %s, \
93725180 352 tags = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
694c4f08 353 title, slug, text, html, list(tags), self.id)
93725180
MT
354
355 # Update cache
356 self.data.update({
357 "title" : title,
358 "slug" : slug,
359 "text" : text,
d73edae7 360 "html" : html,
93725180
MT
361 "tags" : tags,
362 })
363
364 # Update search index if post is published
365 if self.is_published():
366 self.backend.blog.refresh()
914238a5
MT
367
368 def delete(self):
369 self.db.execute("DELETE FROM blog WHERE id = %s", self.id)
370
371 # Update search indices
372 self.backend.blog.refresh()