]> git.ipfire.org Git - ipfire.org.git/blame - src/backend/blog.py
wiki: Style wiki content
[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,
153 extras=["footnotes", "link-patterns", "wiki-tables"])
154
155 elif lang == "textile":
156 return textile.textile(text)
157
158 return text
159
0a6875dc
MT
160 def refresh(self):
161 """
162 Needs to be called after a post has been changed
163 and updates the search index.
164 """
165 self.db.execute("REFRESH MATERIALIZED VIEW blog_search_index")
166
7e64f6a3
MT
167 @property
168 def years(self):
169 res = self.db.query("SELECT DISTINCT EXTRACT(year FROM published_at)::integer AS year \
170 FROM blog WHERE published_at IS NOT NULL AND published_at <= NOW() \
171 ORDER BY year DESC")
172
173 for row in res:
174 yield row.year
175
c70a7c29
MT
176 def update_feeds(self):
177 """
178 Updates all enabled feeds
179 """
180 for feed in self.db.query("SELECT * FROM blog_feeds WHERE enabled IS TRUE"):
181 try:
182 f = feedparser.parse(feed.url)
183 except Exception as e:
184 raise e
185
186 with self.db.transaction():
187 # Update name
188 self.db.execute("UPDATE blog_feeds SET name = %s \
189 WHERE id = %s", f.feed.title, feed.id)
190
191 # Walk through all entries
192 for entry in f.entries:
193 # Skip everything without the "blog.ipfire.org" tag
194 try:
195 tags = list((t.term for t in entry.tags))
196
197 if not "blog.ipfire.org" in tags:
198 continue
199 except AttributeError:
200 continue
201
202 # Get link to the posting site
203 link = entry.links[0].href
204
205 # Check if the entry has already been imported
206 res = self.db.get("SELECT id, (updated_at < %s) AS needs_update \
207 FROM blog WHERE feed_id = %s AND foreign_id = %s",
208 entry.updated, feed.id, entry.id)
209 if res:
210 # If the post needs to be updated, we do so
211 if res.needs_update:
212 self.db.execute("UPDATE blog SET title = %s, author = %s, \
213 published_at = %s, updated_at = %s, html = %s, link = %s, \
214 tags = %s WHERE id = %s", entry.title, entry.author,
215 entry.published, entry.updated, entry.summary, link,
216 feed.tags + tags, res.id)
217
218 # Done here
219 continue
220
221 # Insert the new post
222 self.db.execute("INSERT INTO blog(title, slug, author, \
223 published_at, html, link, tags, updated_at, feed_id, foreign_id) \
224 VALUES(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
225 entry.title, self._make_slug(entry.title), entry.author,
226 entry.published, entry.summary, link, feed.tags + tags,
227 entry.updated, feed.id, entry.id)
228
20277bf5
MT
229 # Mark feed as updated
230 self.db.execute("UPDATE blog_feeds SET last_updated_at = CURRENT_TIMESTAMP \
231 WHERE id = %s" % feed.id)
232
c70a7c29
MT
233 # Refresh the search index
234 with self.db.transaction():
235 self.refresh()
236
0a6875dc
MT
237
238class Post(misc.Object):
239 def init(self, id, data=None):
240 self.id = id
241 self.data = data
242
541c952b
MT
243 # Title
244
93725180
MT
245 @property
246 def title(self):
0a6875dc
MT
247 return self.data.title
248
249 @property
250 def slug(self):
251 return self.data.slug
252
a3a850a4 253 @lazy_property
0a6875dc
MT
254 def author(self):
255 if self.data.author_uid:
256 return self.backend.accounts.get_by_uid(self.data.author_uid)
257
cdf85ee7
MT
258 return self.data.author
259
0a6875dc
MT
260 @property
261 def created_at(self):
262 return self.data.created_at
263
2de5ad8a
MT
264 @property
265 def lang(self):
266 return self.data.lang
267
541c952b
MT
268 # Published?
269
0a6875dc
MT
270 @property
271 def published_at(self):
272 return self.data.published_at
273
541c952b
MT
274 def is_published(self):
275 """
276 Returns True if the post is already published
277 """
278 return self.published_at and self.published_at <= datetime.datetime.now()
279
9ea64cef 280 def publish(self, when=None):
541c952b
MT
281 if self.is_published():
282 return
283
9ea64cef
MT
284 self.db.execute("UPDATE blog SET published_at = COALESCE(%s, CURRENT_TIMESTAMP) \
285 WHERE id = %s", when, self.id)
541c952b
MT
286
287 # Update search indices
288 self.backend.blog.refresh()
289
290 # Updated?
291
7e64f6a3 292 @property
541c952b
MT
293 def updated_at(self):
294 return self.data.updated_at
295
541c952b
MT
296 # Text
297
93725180
MT
298 @property
299 def text(self):
541c952b
MT
300 return self.data.text
301
541c952b 302 # HTML
7e64f6a3 303
a3a850a4 304 @lazy_property
0a6875dc
MT
305 def html(self):
306 """
307 Returns this post as rendered HTML
308 """
2de5ad8a 309 return self.data.html or self.backend.blog._render_text(self.text, lang=self.lang)
8ebc98d4 310
541c952b
MT
311 # Tags
312
93725180
MT
313 @property
314 def tags(self):
8ebc98d4 315 return self.data.tags
1e76fec4 316
93725180 317 # Link
541c952b 318
1e76fec4
MT
319 @property
320 def link(self):
321 return self.data.link
984e4e7b 322
a3a850a4 323 @lazy_property
984e4e7b
MT
324 def release(self):
325 return self.backend.releases._get_release("SELECT * FROM releases \
326 WHERE published IS NOT NULL AND published <= NOW() AND blog_id = %s", self.id)
e8a81a70
MT
327
328 def is_editable(self, editor):
329 # Authors can edit their own posts
330 return self.author == editor
93725180
MT
331
332 def update(self, title, text, tags=[]):
333 """
334 Called to update the content of this post
335 """
336 # Update slug when post isn't published yet
baa294fb
MT
337 slug = self.backend.blog._make_slug(title) \
338 if not self.is_published() and not self.title == title else self.slug
93725180 339
694c4f08
MT
340 # Render and cache HTML
341 html = self.backend.blog._render_text(text, lang=self.lang)
93725180 342
694c4f08 343 self.db.execute("UPDATE blog SET title = %s, slug = %s, text = %s, html = %s, \
93725180 344 tags = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
694c4f08 345 title, slug, text, html, list(tags), self.id)
93725180
MT
346
347 # Update cache
348 self.data.update({
349 "title" : title,
350 "slug" : slug,
351 "text" : text,
d73edae7 352 "html" : html,
93725180
MT
353 "tags" : tags,
354 })
355
356 # Update search index if post is published
357 if self.is_published():
358 self.backend.blog.refresh()
914238a5
MT
359
360 def delete(self):
361 self.db.execute("DELETE FROM blog WHERE id = %s", self.id)
362
363 # Update search indices
364 self.backend.blog.refresh()