blogit.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  1. # ============================================================================
  2. # Blogit.py is free software; you can redistribute it and/or modify
  3. # it under the terms of the GNU General Public License, version 3
  4. # as published by the Free Software Foundation;
  5. #
  6. # Blogit.py is distributed in the hope that it will be useful,
  7. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  8. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  9. # GNU General Public License for more details.
  10. #
  11. # You should have received a copy of the GNU General Public License
  12. # along with Blogit.py; if not, write to the Free Software
  13. # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
  14. # ============================================================================
  15. # Copyright (C) 2013 Oz Nahum Tiram <nahumoz@gmail.com>
  16. # ============================================================================
  17. # Note about Summary
  18. # has to be 1 line, no '\n' allowed!
  19. """
  20. Summary: |
  21. some summary ...
  22. Your post
  23. """
  24. """
  25. Everything the Header can't have ":" or "..." in it, you can't have title
  26. with ":" it makes markdown break!
  27. """
  28. """
  29. The content directory can contain only markdown or txt files, no images
  30. allowed!
  31. """
  32. import os
  33. import re
  34. import datetime
  35. import argparse
  36. import sys
  37. import operator
  38. import shutil
  39. from StringIO import StringIO
  40. import codecs
  41. import subprocess as sp
  42. import SimpleHTTPServer
  43. import BaseHTTPServer
  44. import socket
  45. import SocketServer
  46. import yaml
  47. from jinja2 import Environment, FileSystemLoader
  48. import markdown2
  49. import tinydb
  50. from tinydb import Query
  51. sys.path.insert(0, os.getcwd())
  52. from conf import CONFIG, ARCHIVE_SIZE, GLOBAL_TEMPLATE_CONTEXT, KINDS
  53. jinja_env = Environment(lstrip_blocks=True, trim_blocks=True,
  54. loader=FileSystemLoader(CONFIG['templates']))
  55. class DataBase(object):
  56. def __init__(self, path):
  57. _db = tinydb.TinyDB(path)
  58. self.posts = _db.table('posts')
  59. self.tags = _db.table('tags')
  60. self.pages = _db.table('pages')
  61. self.templates = _db.table('templates')
  62. self._db = _db
  63. DB = DataBase(os.path.join(CONFIG['content_root'], 'blogit.db'))
  64. class Tag(object):
  65. def __init__(self, name):
  66. self.name = name
  67. self.prepare()
  68. self.permalink = GLOBAL_TEMPLATE_CONTEXT["site_url"]
  69. self.table = DB.tags
  70. Tags = Query()
  71. tag = self.table.get(Tags.name == self.name)
  72. if not tag:
  73. self.table.insert({'name': self.name, 'post_ids': []})
  74. def prepare(self):
  75. _slug = self.name.lower()
  76. _slug = re.sub(r'[;;,. ]', '-', _slug)
  77. self.slug = _slug
  78. @property
  79. def posts(self):
  80. """
  81. return a list of posts tagged with Tag
  82. """
  83. Tags = Query()
  84. tag = self.table.get(Tags.name == self.name)
  85. return tag['post_ids']
  86. @posts.setter
  87. def posts(self, post_ids):
  88. if not isinstance(post_ids, list):
  89. raise ValueError("post_ids must be of type list")
  90. Tags = Query()
  91. tag = self.table.get(Tags.name == self.name)
  92. if not tag: # pragma: no coverage
  93. raise ValueError("Tag %s not found" % self.name)
  94. if tag:
  95. new = set(post_ids) - set(tag['post_ids'])
  96. tag['post_ids'].extend(list(new))
  97. self.table.update({'post_ids': tag['post_ids']}, eids=[tag.eid])
  98. @property
  99. def entries(self):
  100. _entries = []
  101. Posts = Query()
  102. for id in self.posts:
  103. post = DB.posts.get(eid=id)
  104. if not post: # pragma: no coverage
  105. raise ValueError("no post found for eid %s" % id)
  106. entry = Entry(post['filename'])
  107. _entries.append(entry)
  108. return _entries
  109. def render(self):
  110. """Render html page and atom feed"""
  111. self.destination = "%s/tags/%s" % (CONFIG['output_to'], self.slug)
  112. template = jinja_env.get_template('tag_index.html')
  113. try:
  114. os.makedirs(self.destination)
  115. except OSError: # pragma: no coverage
  116. pass
  117. context = GLOBAL_TEMPLATE_CONTEXT.copy()
  118. context['tag'] = self
  119. context['entries'] = _sort_entries(self.entries)
  120. sorted_entries = _sort_entries(self.entries)
  121. encoding = CONFIG['content_encoding']
  122. render_to = "%s/tags/%s" % (CONFIG['output_to'], self.slug)
  123. jobs = [{'tname': 'tag_index.html',
  124. 'output': codecs.open("%s/index.html" % render_to, 'w', encoding),
  125. 'entries': sorted_entries},
  126. {'tname': 'atom.xml',
  127. 'output': codecs.open("%s/atom.xml" % render_to, 'w', encoding),
  128. 'entries': sorted_entries[:10]}
  129. ]
  130. for j in jobs:
  131. template = jinja_env.get_template(j['tname'])
  132. context['entries'] = j['entries']
  133. html = template.render(context)
  134. j['output'].write(html)
  135. j['output'].close()
  136. return True
  137. class Entry(object):
  138. @classmethod
  139. def entry_from_db(kls, filename):
  140. f=os.path.join(os.path.join(CONFIG['content_root'], filename))
  141. return kls(f)
  142. def __init__(self, path):
  143. self._path = path
  144. self.path = path.split(CONFIG['content_root'])[-1]
  145. self.entry_template = jinja_env.get_template("entry.html")
  146. self.prepare()
  147. def __str__(self):
  148. return self.path
  149. def __repr__(self):
  150. return self.path
  151. @property
  152. def name(self):
  153. return os.path.splitext(os.path.basename(self.path))[0]
  154. @property
  155. def abspath(self):
  156. return self._path
  157. @property
  158. def destination(self):
  159. dest = "%s/%s/index.html" % (KINDS[
  160. self.kind]['name_plural'], self.name)
  161. print dest
  162. return os.path.join(CONFIG['output_to'], dest)
  163. @property
  164. def title(self):
  165. return self.header['title']
  166. @property
  167. def summary_html(self):
  168. return "%s" % markdown2.markdown(self.header.get('summary', "").strip())
  169. @property
  170. def credits_html(self):
  171. return "%s" % markdown2.markdown(self.header['credits'].strip())
  172. @property
  173. def summary_atom(self):
  174. summarya = markdown2.markdown(self.header.get('summary', "").strip())
  175. summarya = re.sub("<p>|</p>", "", summarya)
  176. more = '<a href="%s"> continue reading...</a>' % (self.permalink)
  177. return summarya+more
  178. @property
  179. def publish_date(self):
  180. d = self.header.get('published', datetime.date.today())
  181. return d.strftime("%B %d, %Y")
  182. @property
  183. def published_atom(self):
  184. return self.published.strftime("%Y-%m-%dT%H:%M:%SZ")
  185. @property
  186. def atom_id(self):
  187. return "tag:%s,%s:%s" % \
  188. (
  189. self.published.strftime("%Y-%m-%d"),
  190. self.permalink,
  191. GLOBAL_TEMPLATE_CONTEXT["site_url"]
  192. )
  193. @property
  194. def body_html(self):
  195. if renderer == 'md2':
  196. return markdown2.markdown(self.body, extras=['fenced-code-blocks',
  197. 'hilite',
  198. 'tables'])
  199. if renderer == 'md1':
  200. return markdown.markdown(self.body,
  201. extensions=['fenced_code',
  202. 'codehilite(linenums=False)',
  203. 'tables'])
  204. @property
  205. def permalink(self):
  206. return "/%s/%s" % (KINDS[self.kind]['name_plural'], self.name)
  207. @property
  208. def tags(self):
  209. try:
  210. return [Tag(t) for t in self.header['tags']]
  211. except KeyError:
  212. return []
  213. def _read_header(self, file):
  214. header = ['---']
  215. while True:
  216. line = file.readline()
  217. line = line.rstrip()
  218. if not line:
  219. break
  220. header.append(line)
  221. header = yaml.load(StringIO('\n'.join(header)))
  222. # todo: dispatch header to attribute
  223. # todo: parse date from string to a datetime object
  224. return header
  225. def prepare(self):
  226. file = codecs.open(self.abspath, 'r')
  227. self.header = self._read_header(file)
  228. self.date = self.header.get('published', datetime.date.today())
  229. for k, v in self.header.items():
  230. try:
  231. setattr(self, k, v)
  232. except:
  233. pass
  234. body = file.readlines()
  235. self.body = ''.join(body)
  236. file.close()
  237. def render(self):
  238. if not self.header['public']:
  239. return False
  240. try:
  241. os.makedirs(os.path.dirname(self.destination))
  242. except OSError:
  243. pass
  244. context = GLOBAL_TEMPLATE_CONTEXT.copy()
  245. context['entry'] = self
  246. try:
  247. html = self.entry_template.render(context)
  248. except Exception as e: # pragma: no cover
  249. print context
  250. print self.path
  251. print e
  252. sys.exit()
  253. destination = codecs.open(
  254. self.destination, 'w', CONFIG['content_encoding'])
  255. destination.write(html)
  256. destination.close()
  257. return True
  258. def _sort_entries(entries):
  259. """Sort all entries by date and reverse the list"""
  260. return list(reversed(sorted(entries, key=operator.attrgetter('date'))))
  261. def render_archive(entries):
  262. """
  263. This function creates the archive page
  264. To function it need to read:
  265. - entry title
  266. - entry publish date
  267. - entry permalink
  268. Until now, this was parsed from each entry YAML...
  269. It would be more convinient to read this from the DB.
  270. This requires changes for the database.
  271. """
  272. context = GLOBAL_TEMPLATE_CONTEXT.copy()
  273. context['entries'] = entries[ARCHIVE_SIZE:]
  274. template = jinja_env.get_template('archive_index.html')
  275. html = template.render(context)
  276. try:
  277. os.makedirs(os.path.join(CONFIG['output_to'], 'archive'))
  278. except OSError:
  279. pass
  280. destination = codecs.open("%s/archive/index.html" % CONFIG[
  281. 'output_to'], 'w', CONFIG['content_encoding'])
  282. destination.write(html)
  283. destination.close()
  284. def find_new_items(posts_table):
  285. """
  286. Walk content dir, put each post in the database
  287. """
  288. Posts = Query()
  289. for root, dirs, files in os.walk(CONFIG['content_root']):
  290. for filename in files:
  291. if filename.endswith(('md', 'markdown')):
  292. fullpath = os.path.join(root, filename)
  293. if not posts_table.contains(Posts.filename == fullpath):
  294. post_id = posts_table.insert({'filename': fullpath})
  295. yield post_id, fullpath
  296. def find_new_posts_and_pages(DB):
  297. """Walk content dir, put each post and page in the database"""
  298. Q = Query()
  299. for root, dirs, files in os.walk(CONFIG['content_root']):
  300. for filename in files:
  301. if filename.endswith(('md', 'markdown')):
  302. fullpath = os.path.join(root, filename)
  303. if not DB.posts.contains(Q.filename == fullpath) and \
  304. not DB.pages.contains(Q.filename == fullpath):
  305. e = Entry(fullpath)
  306. if e.header['kind'] == 'writing':
  307. post_id = DB.posts.insert({'filename': fullpath})
  308. yield post_id, e
  309. if e.header['kind'] == 'page':
  310. page_id = DB.pages.insert({'filename': fullpath})
  311. yield page_id, e
  312. def _get_last_entries():
  313. eids = [post.eid for post in DB.posts.all()]
  314. eids = sorted(eids)[-10:][::-1]
  315. entries = [Entry(DB.posts.get(eid=eid)['filename']) for eid in eids]
  316. return entries
  317. def update_index():
  318. """find the last 10 entries in the database and create the main
  319. page.
  320. Each entry in has an eid, so we only get the last 10 eids.
  321. This method also updates the ATOM feed.
  322. """
  323. entries = _get_last_entries()
  324. context = GLOBAL_TEMPLATE_CONTEXT.copy()
  325. context['entries'] = entries
  326. for name, out in {'entry_index.html': 'index.html',
  327. 'atom.xml': 'atom.xml'}.items():
  328. template = jinja_env.get_template(name)
  329. html = template.render(context)
  330. destination = codecs.open("%s/%s" % (CONFIG['output_to'], out),
  331. 'w', CONFIG['content_encoding'])
  332. destination.write(html)
  333. destination.close()
  334. def new_build():
  335. """
  336. a. For each new post:
  337. 1. render html
  338. 2. find post tags
  339. 3. update atom feeds for old tags
  340. 4. create new atom feeds for new tags
  341. b. update index page
  342. c. update archive page
  343. """
  344. # TODO: slug each page to it's unique location
  345. print
  346. print "Rendering website now..."
  347. print
  348. print " entries:"
  349. tags = dict()
  350. root = CONFIG['content_root']
  351. for post_id, post in find_new_posts_and_pages(DB):
  352. entry = post
  353. if post.render():
  354. if post.header['kind'] in ['writing', 'link']:
  355. for tag in entry.tags:
  356. tag.posts = [post_id]
  357. tags[tag.name] = tag
  358. print " %s" % entry.path
  359. for name, to in tags.iteritems():
  360. print "updating tag %s" % name
  361. to.render()
  362. # update index
  363. print "updating index"
  364. update_index()
  365. # update archive
  366. print "updating archive"
  367. render_archive(_sort_entries([Entry(p['filename'])
  368. for p in DB.posts.all()]))
  369. class StoppableHTTPServer(BaseHTTPServer.HTTPServer): # pragma: no coverage
  370. def server_bind(self):
  371. BaseHTTPServer.HTTPServer.server_bind(self)
  372. self.socket.settimeout(1)
  373. self.run = True
  374. def get_request(self):
  375. while self.run:
  376. try:
  377. sock, addr = self.socket.accept()
  378. sock.settimeout(None)
  379. return (sock, addr)
  380. except socket.timeout:
  381. pass
  382. def stop(self):
  383. self.run = False
  384. def serve(self):
  385. while self.run:
  386. self.handle_request()
  387. def preview(): # pragma: no coverage
  388. """launch an HTTP to preview the website"""
  389. Handler = SimpleHTTPServer.SimpleHTTPRequestHandler
  390. SocketServer.TCPServer.allow_reuse_address = True
  391. port = CONFIG['http_port']
  392. httpd = SocketServer.TCPServer(("", port), Handler)
  393. os.chdir(CONFIG['output_to'])
  394. print "and ready to test at http://127.0.0.1:%d" % CONFIG['http_port']
  395. print "Hit Ctrl+C to exit"
  396. try:
  397. httpd.serve_forever()
  398. except KeyboardInterrupt:
  399. httpd.shutdown()
  400. def publish(GITDIRECTORY=CONFIG['output_to']): # pragma: no coverage
  401. sp.call('git push', cwd=GITDIRECTORY, shell=True)
  402. def new_post(GITDIRECTORY=CONFIG['output_to'],
  403. kind=KINDS['writing']): # pragma: no coverage
  404. """
  405. This function should create a template for a new post with a title
  406. read from the user input.
  407. Most other fields should be defaults.
  408. """
  409. title = raw_input("Give the title of the post: ")
  410. while ':' in title:
  411. title = raw_input("Give the title of the post (':' not allowed): ")
  412. author = CONFIG['author']
  413. date = datetime.datetime.strftime(datetime.datetime.now(), '%Y-%m-%d')
  414. tags = '[' + raw_input("Give the tags, separated by ', ':") + ']'
  415. published = 'yes'
  416. chronological = 'yes'
  417. summary = ("summary: |\n Type your summary here.\n Do not change the "
  418. "indentation"
  419. "to the left\n ...\n\nStart writing your post here!")
  420. # make file name
  421. fname = os.path.join(os.getcwd(), 'content', kind['name_plural'],
  422. datetime.datetime.strftime(datetime.datetime.now(),
  423. '%Y'),
  424. date+'-'+title.replace(' ', '-')+'.markdown')
  425. with open(fname, 'w') as npost:
  426. npost.write('title: %s\n' % title)
  427. npost.write('author: %s\n' % author)
  428. npost.write('published: %s\n' % date)
  429. npost.write('tags: %s\n' % tags)
  430. npost.write('public: %s\n' % published)
  431. npost.write('chronological: %s\n' % chronological)
  432. npost.write('kind: %s\n' % kind['name'])
  433. npost.write('%s' % summary)
  434. print '%s %s' % (CONFIG['editor'], repr(fname))
  435. os.system('%s %s' % (CONFIG['editor'], fname))
  436. def clean(GITDIRECTORY=CONFIG['output_to']): # pragma: no coverage
  437. directoriestoclean = ["writings", "notes", "links", "tags", "archive"]
  438. os.chdir(GITDIRECTORY)
  439. for directory in directoriestoclean:
  440. shutil.rmtree(directory)
  441. def dist(SOURCEDIR=os.getcwd()+"/content/",
  442. DESTDIR=CONFIG['raw_content']): # pragma: no coverage
  443. """
  444. sync raw files from SOURCE to DEST
  445. """
  446. sp.call(["rsync", "-avP", SOURCEDIR, DESTDIR], shell=False,
  447. cwd=os.getcwd())
  448. def main(): # pragma: no coverage
  449. parser = argparse.ArgumentParser(
  450. description='blogit - a tool to blog on github.')
  451. parser.add_argument('-b', '--build', action="store_true",
  452. help='convert the markdown files to HTML')
  453. parser.add_argument('-p', '--preview', action="store_true",
  454. help='Launch HTTP server to preview the website')
  455. parser.add_argument('-c', '--clean', action="store_true",
  456. help='clean output files')
  457. parser.add_argument('-n', '--new', action="store_true",
  458. help='create new post')
  459. parser.add_argument('-d', '--dist', action="store_true",
  460. help='sync raw files from SOURCE to DEST')
  461. parser.add_argument('--publish', action="store_true",
  462. help='push built HTML to git upstream')
  463. args = parser.parse_args()
  464. if len(sys.argv) < 2:
  465. parser.print_help()
  466. sys.exit()
  467. if args.clean:
  468. clean()
  469. if args.build:
  470. new_build()
  471. if args.dist:
  472. dist()
  473. if args.preview:
  474. preview()
  475. if args.new:
  476. new_post()
  477. if args.publish:
  478. publish()
  479. if __name__ == '__main__': # pragma: no coverage
  480. main()