]> git.llucax.com Git - software/blitiri.git/blob - blitiri.cgi
dd873877b8607bdbff696ff5395c49a086c085b9
[software/blitiri.git] / blitiri.cgi
1 #!/usr/bin/env python
2 #coding: utf8
3
4 # blitiri - A single-file blog engine.
5 # Alberto Bertogli (albertito@gmail.com)
6
7 #
8 # Configuration section
9 #
10 # You can edit these values, or create a file named "config.py" and put them
11 # there to make updating easier. The ones in config.py take precedence.
12 #
13
14 # Directory where entries are stored
15 data_path = "/tmp/blog/data"
16
17 # Path where templates are stored. Use an empty string for the built-in
18 # default templates. If they're not found, the built-in ones will be used.
19 templates_path = "/tmp/blog/templates"
20
21 # URL to the blog, including the name. Can be a full URL or just the path.
22 blog_url = "/blog/blitiri.cgi"
23
24 # Style sheet (CSS) URL. Can be relative or absolute. To use the built-in
25 # default, set it to blog_url + "/style".
26 css_url = blog_url + "/style"
27
28 # Blog title
29 title = "I don't like blogs"
30
31 # Default author
32 author = "Hartmut Kegan"
33
34 # Article encoding
35 encoding = "utf8"
36
37 #
38 # End of configuration
39 # DO *NOT* EDIT ANYTHING PAST HERE
40 #
41
42
43 import sys
44 import os
45 import time
46 import datetime
47 import calendar
48 import zlib
49 import urllib
50 import cgi
51 from docutils.core import publish_parts
52
53 # Load the config file, if there is one
54 try:
55         from config import *
56 except:
57         pass
58
59
60 # Default template
61
62 default_main_header = """
63 <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
64
65 <html>
66 <head>
67 <link rel="alternate" title="%(title)s" href="%(fullurl)s/atom"
68         type="application/atom+xml" />
69 <link href="%(css_url)s" rel="stylesheet"
70         type="text/css" />
71 <title>%(title)s</title>
72 </head>
73
74 <body>
75
76 <h1><a href="%(url)s">%(title)s</a></h1>
77
78 <div class="content">
79 """
80
81 default_main_footer = """
82 </div><p/>
83 <hr/><br/>
84 <div class="footer">
85   %(showyear)s: %(monthlinks)s<br/>
86   years: %(yearlinks)s<br/>
87   subscribe: <a href="%(url)s/atom">atom</a><br/>
88 </div>
89
90 </body>
91 </html>
92 """
93
94 default_article_header = """
95 <div class="article">
96 <h2><a href="%(url)s/post/%(uuid)s">%(arttitle)s</a></h2>
97 <span class="artinfo">
98   by %(author)s on <span class="date">
99
100 <a class="date" href="%(url)s/%(cyear)d/">%(cyear)04d</a>-\
101 <a class="date" href="%(url)s/%(cyear)d/%(cmonth)d/">%(cmonth)02d</a>-\
102 <a class="date" href="%(url)s/%(cyear)d/%(cmonth)d/%(cday)d/">%(cday)02d</a>\
103     %(chour)02d:%(cminute)02d</span>
104   (updated on <span class="date">
105 <a class="date" href="%(url)s/%(uyear)d/">%(uyear)04d</a>-\
106 <a class="date" href="%(url)s/%(uyear)d/%(umonth)d/">%(umonth)02d</a>-\
107 <a class="date" href="%(url)s/%(uyear)d/%(umonth)d/%(uday)d/">%(uday)02d</a>\
108     %(uhour)02d:%(uminute)02d)</span><br/>
109   <span class="tags">tagged %(tags)s</span>
110 </span><br/>
111 <p/>
112 <div class="artbody">
113 """
114
115 default_article_footer = """
116 <p/>
117 </div>
118 </div>
119 """
120
121 # Default CSS
122 default_css = """
123 body {
124         font-family: sans-serif;
125         font-size: small;
126 }
127
128 div.content {
129         width: 50%;
130 }
131
132 h1 {
133         font-size: large;
134         border-bottom: 2px solid #99F;
135         width: 60%;
136         margin-bottom: 1em;
137 }
138
139 h2 {
140         font-size: medium;
141         font-weigth: none;
142         margin-bottom: 1pt;
143         border-bottom: 1px solid #99C;
144 }
145
146 h1 a, h2 a {
147         text-decoration: none;
148         color: black;
149 }
150
151 span.artinfo {
152         font-size: xx-small;
153 }
154
155 span.artinfo a {
156         text-decoration: none;
157         color: #339;
158 }
159
160 span.artinfo a:hover {
161         text-decoration: none;
162         color: blue;
163 }
164
165 div.artbody {
166         margin-left: 1em;
167 }
168
169 div.article {
170         margin-bottom: 2em;
171 }
172
173 hr {
174         float: left;
175         height: 2px;
176         border: 0;
177         background-color: #99F;
178         width: 60%;
179 }
180
181 div.footer {
182         font-size: x-small;
183 }
184
185 div.footer a {
186         text-decoration: none;
187 }
188
189 /* Articles are enclosed in <div class="section"> */
190 div.section h1 {
191         font-size: small;
192         font-weigth: none;
193         width: 100%;
194         margin-bottom: 1pt;
195         border-bottom: 1px dotted #99C;
196 }
197
198 """
199
200 # find out our URL, needed for syndication
201 try:
202         n = os.environ['SERVER_NAME']
203         p = os.environ['SERVER_PORT']
204         s = os.environ['SCRIPT_NAME']
205         if p == '80': p = ''
206         else: p = ':' + p
207         full_url = 'http://%s%s%s' % (n, p, s)
208 except KeyError:
209         full_url = 'Not needed'
210
211
212 class Templates (object):
213         def __init__(self, tpath, db, showyear = None):
214                 self.tpath = tpath
215                 self.db = db
216                 now = datetime.datetime.now()
217                 if not showyear:
218                         showyear = now.year
219
220                 self.vars = {
221                         'css_url': css_url,
222                         'title': title,
223                         'url': blog_url,
224                         'fullurl': full_url,
225                         'year': now.year,
226                         'month': now.month,
227                         'day': now.day,
228                         'showyear': showyear,
229                         'monthlinks': ' '.join(db.get_month_links(showyear)),
230                         'yearlinks': ' '.join(db.get_year_links()),
231                 }
232
233         def get_main_header(self):
234                 p = self.tpath + '/header.html'
235                 if os.path.isfile(p):
236                         return open(p).read() % self.vars
237                 return default_main_header % self.vars
238
239         def get_main_footer(self):
240                 p = self.tpath + '/footer.html'
241                 if os.path.isfile(p):
242                         return open(p).read() % self.vars
243                 return default_main_footer % self.vars
244
245         def get_article_header(self, article):
246                 avars = self.vars.copy()
247                 avars.update( {
248                         'arttitle': article.title,
249                         'author': article.author,
250                         'date': article.created.isoformat(' '),
251                         'uuid': article.uuid,
252                         'created': article.created.isoformat(' '),
253                         'updated': article.updated.isoformat(' '),
254                         'tags': article.get_tags_links(),
255
256                         'cyear': article.created.year,
257                         'cmonth': article.created.month,
258                         'cday': article.created.day,
259                         'chour': article.created.hour,
260                         'cminute': article.created.minute,
261                         'csecond': article.created.second,
262
263                         'uyear': article.updated.year,
264                         'umonth': article.updated.month,
265                         'uday': article.updated.day,
266                         'uhour': article.updated.hour,
267                         'uminute': article.updated.minute,
268                         'usecond': article.updated.second,
269                 } )
270
271                 p = self.tpath + '/art_header.html'
272                 if os.path.isfile(p):
273                         return open(p).read() % avars
274                 return default_article_header % avars
275
276         def get_article_footer(self, article):
277                 avars = self.vars.copy()
278                 avars.update( {
279                         'arttitle': article.title,
280                         'author': article.author,
281                         'date': article.created.isoformat(' '),
282                         'uuid': article.uuid,
283                         'created': article.created.isoformat(' '),
284                         'updated': article.updated.isoformat(' '),
285                         'tags': article.get_tags_links(),
286
287                         'cyear': article.created.year,
288                         'cmonth': article.created.month,
289                         'cday': article.created.day,
290                         'chour': article.created.hour,
291                         'cminute': article.created.minute,
292                         'csecond': article.created.second,
293
294                         'uyear': article.updated.year,
295                         'umonth': article.updated.month,
296                         'uday': article.updated.day,
297                         'uhour': article.updated.hour,
298                         'uminute': article.updated.minute,
299                         'usecond': article.updated.second,
300                 } )
301
302                 p = self.tpath + '/art_footer.html'
303                 if os.path.isfile(p):
304                         return open(p).read() % avars
305                 return default_article_footer % avars
306
307
308 class Article (object):
309         def __init__(self, path):
310                 self.path = path
311                 self.created = None
312                 self.updated = None
313                 self.uuid = "%08x" % zlib.crc32(self.path)
314
315                 self.loaded = False
316
317                 # loaded on demand
318                 self._title = 'Removed post'
319                 self._author = author
320                 self._tags = []
321                 self._raw_content = ''
322
323
324         def get_title(self):
325                 if not self.loaded:
326                         self.load()
327                 return self._title
328         title = property(fget = get_title)
329
330         def get_author(self):
331                 if not self.loaded:
332                         self.load()
333                 return self._author
334         author = property(fget = get_author)
335
336         def get_tags(self):
337                 if not self.loaded:
338                         self.load()
339                 return self._tags
340         tags = property(fget = get_tags)
341
342         def get_raw_content(self):
343                 if not self.loaded:
344                         self.load()
345                 return self._raw_content
346         raw_content = property(fget = get_raw_content)
347
348
349         def __cmp__(self, other):
350                 if self.path == other.path:
351                         return 0
352                 if not self.created:
353                         return 1
354                 if not other.created:
355                         return -1
356                 if self.created < other.created:
357                         return -1
358                 return 1
359
360         def load(self):
361                 try:
362                         raw = open(data_path + '/' + self.path).readlines()
363                 except:
364                         return
365
366                 count = 0
367                 for l in raw:
368                         if ':' in l:
369                                 name, value = l.split(':', 1)
370                                 if name.lower() == 'title':
371                                         self._title = value
372                                 elif name.lower() == 'author':
373                                         self._author = value
374                                 elif name.lower() == 'tags':
375                                         ts = value.split(',')
376                                         ts = [t.strip() for t in ts]
377                                         self._tags = set(ts)
378                         elif l == '\n':
379                                 # end of header
380                                 break
381                         count += 1
382                 self._raw_content = ''.join(raw[count + 1:])
383                 self.loaded = True
384
385         def to_html(self):
386                 try:
387                         raw = open(data_path + '/' + self.path).readlines()
388                 except:
389                         return "Can't open post file<p>"
390                 raw = raw[raw.index('\n'):]
391
392                 settings = {
393                         'input_encoding': encoding,
394                         'output_encoding': 'utf8',
395                 }
396                 parts = publish_parts(self.raw_content,
397                                 settings_overrides = settings,
398                                 writer_name = "html")
399                 return parts['body'].encode('utf8')
400
401         def get_tags_links(self):
402                 l = []
403                 tags = list(self.tags)
404                 tags.sort()
405                 for t in tags:
406                         l.append('<a class="tag" href="%s/tag/%s">%s</a>' % \
407                                 (blog_url, urllib.quote(t), t) )
408                 return ', '.join(l)
409
410
411 class DB (object):
412         def __init__(self, dbpath):
413                 self.dbpath = dbpath
414                 self.articles = []
415                 self.uuids = {}
416                 self.actyears = set()
417                 self.actmonths = set()
418                 self.load()
419
420         def get_articles(self, year = 0, month = 0, day = 0, tags = None):
421                 l = []
422                 for a in self.articles:
423                         if year and a.created.year != year: continue
424                         if month and a.created.month != month: continue
425                         if day and a.created.day != day: continue
426                         if tags and not tags.issubset(a.tags): continue
427
428                         l.append(a)
429
430                 return l
431
432         def get_article(self, uuid):
433                 return self.uuids[uuid]
434
435         def load(self):
436                 try:
437                         f = open(self.dbpath)
438                 except:
439                         return
440
441                 for l in f:
442                         # Each line has the following comma separated format:
443                         # path (relative to data_path), \
444                         #       created (epoch), \
445                         #       updated (epoch)
446                         try:
447                                 l = l.split(',')
448                         except:
449                                 continue
450
451                         a = Article(l[0])
452                         a.created = datetime.datetime.fromtimestamp(
453                                                 float(l[1]) )
454                         a.updated = datetime.datetime.fromtimestamp(
455                                                 float(l[2]))
456                         self.uuids[a.uuid] = a
457                         self.actyears.add(a.created.year)
458                         self.actmonths.add((a.created.year, a.created.month))
459                         self.articles.append(a)
460
461         def save(self):
462                 f = open(self.dbpath + '.tmp', 'w')
463                 for a in self.articles:
464                         s = ''
465                         s += a.path + ', '
466                         s += str(time.mktime(a.created.timetuple())) + ', '
467                         s += str(time.mktime(a.updated.timetuple())) + '\n'
468                         f.write(s)
469                 f.close()
470                 os.rename(self.dbpath + '.tmp', self.dbpath)
471
472         def get_year_links(self):
473                 yl = list(self.actyears)
474                 yl.sort(reverse = True)
475                 return [ '<a href="%s/%d/">%d</a>' % (blog_url, y, y)
476                                 for y in yl ]
477
478         def get_month_links(self, year):
479                 am = [ i[1] for i in self.actmonths if i[0] == year ]
480                 ml = []
481                 for i in range(1, 13):
482                         name = calendar.month_name[i][:3]
483                         if i in am:
484                                 s = '<a href="%s/%d/%d/">%s</a>' % \
485                                         ( blog_url, year, i, name )
486                         else:
487                                 s = name
488                         ml.append(s)
489                 return ml
490
491 #
492 # Main
493 #
494
495
496 def render_html(articles, db, actyear = None):
497         template = Templates(templates_path, db, actyear)
498         print 'Content-type: text/html; charset=utf-8\n'
499         print template.get_main_header()
500         for a in articles:
501                 print template.get_article_header(a)
502                 print a.to_html()
503                 print template.get_article_footer(a)
504         print template.get_main_footer()
505
506 def render_atom(articles):
507         if len(articles) > 0:
508                 updated = articles[0].updated.isoformat()
509         else:
510                 updated = datetime.datetime.now().isoformat()
511
512         print 'Content-type: application/atom+xml; charset=utf-8\n'
513         print """<?xml version="1.0" encoding="utf-8"?>
514
515 <feed xmlns="http://www.w3.org/2005/Atom">
516  <title>%(title)s</title>
517  <link rel="alternate" type="text/html" href="%(url)s"/>
518  <link rel="self" type="application/atom+xml" href="%(url)s/atom"/>
519  <id>%(url)s</id> <!-- TODO: find a better <id>, see RFC 4151 -->
520  <updated>%(updated)sZ</updated>
521
522         """ % {
523                 'title': title,
524                 'url': full_url,
525                 'updated': updated,
526         }
527
528         for a in articles:
529                 print """
530   <entry>
531     <title>%(arttitle)s</title>
532     <author><name>%(author)s</name></author>
533     <link href="%(url)s/post/%(uuid)s" />
534     <id>%(url)s/post/%(uuid)s</id>
535     <summary>%(arttitle)s</summary>
536     <published>%(created)sZ</published>
537     <updated>%(updated)sZ</updated>
538     <content type="xhtml">
539       <div xmlns="http://www.w3.org/1999/xhtml"><p>
540 %(contents)s
541       </p></div>
542     </content>
543   </entry>
544                 """ % {
545                         'arttitle': a.title,
546                         'author': a.author,
547                         'uuid': a.uuid,
548                         'url': full_url,
549                         'created': a.created.isoformat(),
550                         'updated': a.updated.isoformat(),
551                         'contents': a.to_html(),
552                 }
553
554         print "</feed>"
555
556
557 def render_style():
558         print 'Content-type: text/plain\n'
559         print default_css
560
561 def handle_cgi():
562         import cgitb; cgitb.enable()
563
564         form = cgi.FieldStorage()
565         year = int(form.getfirst("year", 0))
566         month = int(form.getfirst("month", 0))
567         day = int(form.getfirst("day", 0))
568         tags = set(form.getlist("tag"))
569         uuid = None
570         atom = False
571         style = False
572         post = False
573
574         if os.environ.has_key('PATH_INFO'):
575                 path_info = os.environ['PATH_INFO']
576                 style = path_info == '/style'
577                 atom = path_info == '/atom'
578                 tag = path_info.startswith('/tag/')
579                 post = path_info.startswith('/post/')
580                 if not style and not atom and not post and not tag:
581                         date = path_info.split('/')[1:]
582                         try:
583                                 if len(date) > 1 and date[0]:
584                                         year = int(date[0])
585                                 if len(date) > 2 and date[1]:
586                                         month = int(date[1])
587                                 if len(date) > 3 and date[2]:
588                                         day = int(date[2])
589                         except ValueError:
590                                 pass
591                 elif post:
592                         uuid = path_info.replace('/post/', '')
593                         uuid = uuid.replace('/', '')
594                 elif tag:
595                         t = path_info.replace('/tag/', '')
596                         t = t.replace('/', '')
597                         t = urllib.unquote_plus(t)
598                         tags = set((t,))
599
600         db = DB(data_path + '/db')
601         if atom:
602                 articles = db.get_articles(tags = tags)
603                 articles.sort(reverse = True)
604                 render_atom(articles[:10])
605         elif style:
606                 render_style()
607         elif post:
608                 render_html( [db.get_article(uuid)], year )
609         else:
610                 articles = db.get_articles(year, month, day, tags)
611                 articles.sort(reverse = True)
612                 if not year and not month and not day and not tags:
613                         articles = articles[:10]
614                 render_html(articles, db, year)
615
616
617 def usage():
618         print 'Usage: %s {add|rm|update} article_path' % sys.argv[0]
619
620 def handle_cmd():
621         if len(sys.argv) != 3:
622                 usage()
623                 return 1
624
625         cmd = sys.argv[1]
626         art_path = os.path.realpath(sys.argv[2])
627
628         if os.path.commonprefix([data_path, art_path]) != data_path:
629                 print "Error: article (%s) must be inside data_path (%s)" % \
630                                 (art_path, data_path)
631                 return 1
632         art_path = art_path[len(data_path):]
633
634         if not os.path.isfile(data_path + '/db'):
635                 open(data_path + '/db', 'w').write('')
636         db = DB(data_path + '/db')
637
638         if cmd == 'add':
639                 article = Article(art_path)
640                 for a in db.articles:
641                         if a == article:
642                                 print 'Error: article already exists'
643                                 return 1
644                 db.articles.append(article)
645                 article.created = datetime.datetime.now()
646                 article.updated = datetime.datetime.now()
647                 db.save()
648         elif cmd == 'rm':
649                 article = Article(art_path)
650                 for a in db.articles:
651                         if a == article:
652                                 break
653                 else:
654                         print "Error: no such article"
655                         return 1
656                 db.articles.remove(a)
657                 db.save()
658         elif cmd == 'update':
659                 article = Article(art_path)
660                 for a in db.articles:
661                         if a == article:
662                                 break
663                 else:
664                         print "Error: no such article"
665                         return 1
666                 a.updated = datetime.datetime.now()
667                 db.save()
668         else:
669                 usage()
670                 return 1
671
672         return 0
673
674
675 if os.environ.has_key('GATEWAY_INTERFACE'):
676         handle_cgi()
677 else:
678         sys.exit(handle_cmd())
679
680