]> git.ipfire.org Git - ipfire.org.git/blob - src/backend/blog.py
CSS: Add CSS for file listings
[ipfire.org.git] / src / backend / blog.py
1 #!/usr/bin/python
2
3 import datetime
4 import feedparser
5 import markdown2
6 import re
7 import textile
8 import unicodedata
9
10 from . import misc
11 from .decorators import *
12
13 # Used to automatically link some things
14 link_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
22 (re.compile(r"(?:CVE)[\s\-](\d{4}\-\d+)"), r"http://cve.mitre.org/cgi-bin/cvename.cgi?name=\1"),
23 )
24
25 class 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
38 def get_by_id(self, id):
39 return self._get_post("SELECT * FROM blog \
40 WHERE id = %s", id)
41
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
47 return self._get_post("SELECT * FROM blog \
48 WHERE slug = %s", slug)
49
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) \
61 ORDER BY published_at DESC LIMIT %s", tag, limit)
62
63 def get_by_author(self, author, limit=None):
64 return self._get_posts("SELECT * FROM blog \
65 WHERE (author = %s OR author_uid = %s) \
66 AND published_at IS NOT NULL \
67 AND published_at <= NOW() \
68 ORDER BY published_at DESC LIMIT %s",
69 author.name, author.uid, limit)
70
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
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
90 def search(self, query, limit=None):
91 query = self._parse_search_query(query)
92
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
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
118 def create_post(self, title, text, author, tags=[], lang="markdown"):
119 """
120 Creates a new post and returns the resulting Post object
121 """
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))
128
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
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
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
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
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
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
233 # Refresh the search index
234 with self.db.transaction():
235 self.refresh()
236
237
238 class Post(misc.Object):
239 def init(self, id, data=None):
240 self.id = id
241 self.data = data
242
243 # Title
244
245 @property
246 def title(self):
247 return self.data.title
248
249 @property
250 def slug(self):
251 return self.data.slug
252
253 @lazy_property
254 def author(self):
255 if self.data.author_uid:
256 return self.backend.accounts.get_by_uid(self.data.author_uid)
257
258 return self.data.author
259
260 @property
261 def created_at(self):
262 return self.data.created_at
263
264 @property
265 def lang(self):
266 return self.data.lang
267
268 # Published?
269
270 @property
271 def published_at(self):
272 return self.data.published_at
273
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
280 def publish(self, when=None):
281 if self.is_published():
282 return
283
284 self.db.execute("UPDATE blog SET published_at = COALESCE(%s, CURRENT_TIMESTAMP) \
285 WHERE id = %s", when, self.id)
286
287 # Update search indices
288 self.backend.blog.refresh()
289
290 # Updated?
291
292 @property
293 def updated_at(self):
294 return self.data.updated_at
295
296 # Text
297
298 @property
299 def text(self):
300 return self.data.text
301
302 # HTML
303
304 @lazy_property
305 def html(self):
306 """
307 Returns this post as rendered HTML
308 """
309 return self.data.html or self.backend.blog._render_text(self.text, lang=self.lang)
310
311 # Tags
312
313 @property
314 def tags(self):
315 return self.data.tags
316
317 # Link
318
319 @property
320 def link(self):
321 return self.data.link
322
323 @lazy_property
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)
327
328 def is_editable(self, editor):
329 # Authors can edit their own posts
330 return self.author == editor
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
337 slug = self.backend.blog._make_slug(title) \
338 if not self.is_published() and not self.title == title else self.slug
339
340 # Render and cache HTML
341 html = self.backend.blog._render_text(text, lang=self.lang)
342
343 self.db.execute("UPDATE blog SET title = %s, slug = %s, text = %s, html = %s, \
344 tags = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
345 title, slug, text, html, list(tags), self.id)
346
347 # Update cache
348 self.data.update({
349 "title" : title,
350 "slug" : slug,
351 "text" : text,
352 "html" : html,
353 "tags" : tags,
354 })
355
356 # Update search index if post is published
357 if self.is_published():
358 self.backend.blog.refresh()
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()