blogit.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  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-2016 Oz Nahum Tiram <nahumoz@gmail.com>
  16. # ============================================================================
  17. from __future__ import print_function
  18. import os
  19. import re
  20. import datetime
  21. import argparse
  22. import sys
  23. import operator
  24. import shutil
  25. from StringIO import StringIO
  26. import codecs
  27. import subprocess as sp
  28. import SimpleHTTPServer
  29. import BaseHTTPServer
  30. import socket
  31. import SocketServer
  32. from jinja2 import Environment, FileSystemLoader
  33. import markdown2
  34. import tinydb
  35. from tinydb import Query
  36. sys.path.insert(0, os.getcwd())
  37. from conf import CONFIG, ARCHIVE_SIZE, GLOBAL_TEMPLATE_CONTEXT, KINDS
  38. jinja_env = Environment(lstrip_blocks=True, trim_blocks=True,
  39. loader=FileSystemLoader(CONFIG['templates']))
  40. class DataBase(object): # pragma: no cover
  41. """A thin wrapper around TinyDB instance"""
  42. def __init__(self, path):
  43. _db = tinydb.TinyDB(path)
  44. self.posts = _db.table('posts')
  45. self.tags = _db.table('tags')
  46. self.pages = _db.table('pages')
  47. self.templates = _db.table('templates')
  48. self._db = _db
  49. DB = DataBase(os.path.join(CONFIG['content_root'], 'blogit.db'))
  50. class Tag(object):
  51. table = DB.tags
  52. def __init__(self, name):
  53. self .name = name
  54. self.permalink = GLOBAL_TEMPLATE_CONTEXT["site_url"]
  55. Tags = Query()
  56. tag = self.table.get(Tags.name == self.name)
  57. if not tag:
  58. self.table.insert({'name': self.name, 'post_ids': []})
  59. @property
  60. def slug(self):
  61. _slug = self.name.lower()
  62. _slug = re.sub(r'[;:,. ]+', '-', _slug)
  63. return _slug
  64. @property
  65. def posts(self):
  66. """
  67. return a list of posts tagged with Tag
  68. """
  69. Tags = Query()
  70. tag = self.table.get(Tags.name == self.name)
  71. return tag['post_ids']
  72. @posts.setter
  73. def posts(self, post_ids):
  74. if not isinstance(post_ids, list):
  75. raise ValueError("post_ids must be of type list")
  76. Tags = Query()
  77. tag = self.table.get(Tags.name == self.name)
  78. # if not tag: # pragma: no coverage
  79. # raise ValueError("Tag %s not found" % self.name)
  80. # else:
  81. new = set(post_ids) - set(tag['post_ids'])
  82. tag['post_ids'].extend(list(new))
  83. self.table.update({'post_ids': tag['post_ids']}, eids=[tag.eid])
  84. @property
  85. def entries(self):
  86. """return the actual lists of entries tagged with"""
  87. Posts = Query()
  88. for id in self.posts:
  89. post = DB.posts.get(eid=id)
  90. if not post: # pragma: no coverage
  91. raise ValueError("no post found for eid %s" % id)
  92. yield Entry(post['filename'])
  93. def render(self):
  94. """Render html page and atom feed"""
  95. context = GLOBAL_TEMPLATE_CONTEXT.copy()
  96. context['tag'] = self
  97. context['entries'] = _sort_entries(self.entries)
  98. # render html page
  99. _render(context, 'tag_index.html',
  100. os.path.join(CONFIG['output_to'], 'tags', self.slug, render_to,
  101. 'index.html'))
  102. # render atom.xml
  103. context['entries'] = context['entries'][:10]
  104. _render(context, 'atom.xml', os.path.join(render_to, 'atom.xml'))
  105. return True
  106. class Entry(object):
  107. """This is the base class for creating an HTML page from a Markdown
  108. based page.
  109. The file has the following structure for a page:
  110. .. code:
  111. ---
  112. title: example page
  113. public: yes
  114. kind: page
  115. template: about.html
  116. ---
  117. # some heading
  118. content paragraph
  119. ## heading 2
  120. some more content
  121. The file has the following structure for a blog entry:
  122. .. code:
  123. ---
  124. title: Blog post 1
  125. author: Famous author
  126. published: 2015-01-11
  127. tags: [python, git, bash, linux]
  128. public: yes
  129. chronological: yes
  130. kind: writing
  131. summary: This is a summry of post 1. Donec id elit non mi porta
  132. ---
  133. This is the body of post 1. Donec id elit non mi porta gravida
  134. """
  135. @classmethod
  136. def entry_from_db(kls, filename):
  137. f = os.path.join(filename)
  138. return kls(f)
  139. def __init__(self, path):
  140. self._path = path
  141. self.path = path.split(CONFIG['content_root'])[-1]
  142. self.prepare()
  143. def __str__(self):
  144. return self.path
  145. def __repr__(self):
  146. return self.path
  147. @property
  148. def name(self):
  149. return os.path.splitext(os.path.basename(self.path))[0]
  150. @property
  151. def abspath(self):
  152. return self._path
  153. @property
  154. def destination(self):
  155. return os.path.join(CONFIG['output_to'], self.permalink)
  156. @property
  157. def title(self):
  158. return self.header['title']
  159. @property
  160. def summary_html(self):
  161. return "%s" % markdown2.markdown(self.header.get('summary', "").strip())
  162. @property
  163. def summary_atom(self):
  164. summarya = markdown2.markdown(self.header.get('summary', "").strip())
  165. summarya = re.sub("<p>|</p>", "", summarya)
  166. more = '<a href="%s"> continue reading...</a>' % (self.permalink)
  167. return summarya+more
  168. @property
  169. def publish_date(self):
  170. return self.header.get('published',
  171. datetime.date.today().strftime("%Y-%m-%d"))
  172. @property
  173. def permalink(self):
  174. if self.kind == 'page':
  175. dest = '%s.html' % self.title.replace('/', "-")
  176. else:
  177. dest = "%s/%s/index.html" % (KINDS[self.kind]['name_plural'], self.name)
  178. dest = dest.lstrip('/')
  179. return dest
  180. @property
  181. def tags(self):
  182. """this property is always called after prepare"""
  183. if 'tags' in self.header:
  184. return [Tag(t) for t in self.header['tags']]
  185. else:
  186. return []
  187. def prepare(self):
  188. self.body_html = markdown2.markdown(codecs.open(self.abspath, 'r').read(),
  189. extras=['fenced-code-blocks',
  190. 'hilite',
  191. 'tables', 'metadata'])
  192. self.header = self.body_html.metadata
  193. if 'tags' in self.header: # pages can lack tags
  194. self.header['tags'] = self.header['tags'].split(',')
  195. self.date = self.header.get('published', datetime.date.today())
  196. for k, v in self.header.items():
  197. try:
  198. setattr(self, k, v)
  199. except AttributeError:
  200. pass
  201. def render(self):
  202. if not self.header['public']:
  203. return False
  204. try:
  205. context = GLOBAL_TEMPLATE_CONTEXT.copy()
  206. context['entry'] = self
  207. _render(context, self.header.get('template', 'entry.html'),
  208. self.destination)
  209. return True
  210. except Exception as e: # pragma: no cover
  211. print(context)
  212. print(self.path)
  213. print(e)
  214. sys.exit(1)
  215. def _sort_entries(entries):
  216. """Sort all entries by date and reverse the list"""
  217. return list(reversed(sorted(entries, key=operator.attrgetter('date'))))
  218. def _render(context, template_path, output_path, encoding='utf-8'):
  219. template = jinja_env.get_template(template_path)
  220. rendered = template.render(context)
  221. html = template.render(context)
  222. try:
  223. os.makedirs(os.path.dirname(output_path))
  224. except OSError:
  225. pass
  226. destination = codecs.open(output_path, 'w', encoding)
  227. destination.write(html)
  228. destination.close()
  229. def render_archive(entries):
  230. """
  231. This function creates the archive page
  232. """
  233. context = GLOBAL_TEMPLATE_CONTEXT.copy()
  234. context['entries'] = entries[ARCHIVE_SIZE:10]
  235. _render(context, 'archive_index.html',
  236. os.path.join(CONFIG['output_to'],'archive/index.html')),
  237. def find_new_posts_and_pages(DB):
  238. """Walk content dir, put each post and page in the database"""
  239. Q = Query()
  240. for root, dirs, files in os.walk(CONFIG['content_root']):
  241. for filename in files:
  242. if filename.endswith(('md', 'markdown')):
  243. fullpath = os.path.join(root, filename)
  244. if not DB.posts.contains(Q.filename == fullpath) and \
  245. not DB.pages.contains(Q.filename == fullpath):
  246. e = Entry(fullpath)
  247. if e.header['kind'] == 'writing':
  248. post_id = DB.posts.insert({'filename': fullpath})
  249. yield post_id, e
  250. if e.header['kind'] == 'page':
  251. page_id = DB.pages.insert({'filename': fullpath})
  252. yield page_id, e
  253. def _get_last_entries():
  254. eids = [post.eid for post in DB.posts.all()]
  255. eids = sorted(eids)[-10:][::-1]
  256. entries = [Entry(DB.posts.get(eid=eid)['filename']) for eid in eids]
  257. return entries
  258. def update_index(entries):
  259. """find the last 10 entries in the database and create the main
  260. page.
  261. Each entry in has an eid, so we only get the last 10 eids.
  262. This method also updates the ATOM feed.
  263. """
  264. context = GLOBAL_TEMPLATE_CONTEXT.copy()
  265. context['entries'] = entries
  266. map(lambda x: _render(
  267. context, x[0], os.path.join(CONFIG['output_to'], x[1])),
  268. (('entry_index.html', 'index.html'), ('atom.xml', 'atom.xml')))
  269. def build():
  270. """Incremental build of the website"""
  271. print("\nRendering website now...\n")
  272. print("entries:")
  273. tags = dict()
  274. root = CONFIG['content_root']
  275. for post_id, post in find_new_posts_and_pages(DB):
  276. # entry = post
  277. # this method will also parse the post's tags and
  278. # update the DB collection containing the tags.
  279. if post.render():
  280. if post.header['kind'] in ['writing', 'link']:
  281. for tag in post.tags:
  282. tag.posts = [post_id]
  283. tags[tag.name] = tag
  284. print("%s" % post.path)
  285. for name, to in tags.iteritems():
  286. print("updating tag %s" % name)
  287. to.render()
  288. # update index
  289. print("updating index")
  290. update_index(_get_last_entries())
  291. # update archive
  292. print("updating archive")
  293. render_archive(_sort_entries([Entry(p['filename'])
  294. for p in DB.posts.all()]))
  295. class StoppableHTTPServer(BaseHTTPServer.HTTPServer): # pragma: no coverage
  296. def server_bind(self):
  297. BaseHTTPServer.HTTPServer.server_bind(self)
  298. self.socket.settimeout(1)
  299. self.run = True
  300. def get_request(self):
  301. while self.run:
  302. try:
  303. sock, addr = self.socket.accept()
  304. sock.settimeout(None)
  305. return (sock, addr)
  306. except socket.timeout:
  307. pass
  308. def stop(self):
  309. self.run = False
  310. def serve(self):
  311. while self.run:
  312. self.handle_request()
  313. def preview(): # pragma: no coverage
  314. """launch an HTTP to preview the website"""
  315. Handler = SimpleHTTPServer.SimpleHTTPRequestHandler
  316. SocketServer.TCPServer.allow_reuse_address = True
  317. port = CONFIG['http_port']
  318. httpd = SocketServer.TCPServer(("", port), Handler)
  319. os.chdir(CONFIG['output_to'])
  320. print("and ready to test at http://127.0.0.1:%d" % CONFIG['http_port'])
  321. print("Hit Ctrl+C to exit")
  322. try:
  323. httpd.serve_forever()
  324. except KeyboardInterrupt:
  325. httpd.shutdown()
  326. def publish(GITDIRECTORY=CONFIG['output_to']): # pragma: no coverage
  327. sp.call('git push', cwd=GITDIRECTORY, shell=True)
  328. def new_post(GITDIRECTORY=CONFIG['output_to'],
  329. kind=KINDS['writing']): # pragma: no coverage
  330. """
  331. This function should create a template for a new post with a title
  332. read from the user input.
  333. Most other fields should be defaults.
  334. TODO: update this function
  335. """
  336. title = raw_input("Give the title of the post: ")
  337. while ':' in title:
  338. title = raw_input("Give the title of the post (':' not allowed): ")
  339. author = CONFIG['author']
  340. date = datetime.datetime.strftime(datetime.datetime.now(), '%Y-%m-%d')
  341. tags = raw_input("Give the tags, separated by ', ':")
  342. published = 'yes'
  343. chronological = 'yes'
  344. summary = ("summary: Type your summary here.")
  345. # make file name
  346. fname = os.path.join(os.getcwd(), 'content', kind['name_plural'],
  347. datetime.datetime.strftime(datetime.datetime.now(),
  348. '%Y'),
  349. date+'-'+title.replace(' ', '-')+'.markdown')
  350. with open(fname, 'w') as npost:
  351. npost.write('---\n')
  352. npost.write('title: %s\n' % title)
  353. npost.write('author: %s\n' % author)
  354. npost.write('published: %s\n' % date)
  355. npost.write('tags: %s\n' % tags)
  356. npost.write('public: %s\n' % published)
  357. npost.write('chronological: %s\n' % chronological)
  358. npost.write('kind: %s\n' % kind['name'])
  359. npost.write('%s' % summary)
  360. npost.write('---\n')
  361. print('%s %s' % (CONFIG['editor'], repr(fname)))
  362. os.system('%s %s' % (CONFIG['editor'], fname))
  363. def clean(GITDIRECTORY=CONFIG['output_to']): # pragma: no coverage
  364. directoriestoclean = ["writings", "notes", "links", "tags", "archive"]
  365. os.chdir(GITDIRECTORY)
  366. for directory in directoriestoclean:
  367. shutil.rmtree(directory)
  368. def dist(SOURCEDIR=os.getcwd()+"/content/",
  369. DESTDIR=CONFIG['raw_content']): # pragma: no coverage
  370. """
  371. sync raw files from SOURCE to DEST
  372. """
  373. sp.call(["rsync", "-avP", SOURCEDIR, DESTDIR], shell=False,
  374. cwd=os.getcwd())
  375. def main(): # pragma: no coverage
  376. parser = argparse.ArgumentParser(
  377. description='blogit - a tool to blog on github.')
  378. parser.add_argument('-b', '--build', action="store_true",
  379. help='convert the markdown files to HTML')
  380. parser.add_argument('-p', '--preview', action="store_true",
  381. help='Launch HTTP server to preview the website')
  382. parser.add_argument('-c', '--clean', action="store_true",
  383. help='clean output files')
  384. parser.add_argument('-n', '--new', action="store_true",
  385. help='create new post')
  386. parser.add_argument('-d', '--dist', action="store_true",
  387. help='sync raw files from SOURCE to DEST')
  388. parser.add_argument('--publish', action="store_true",
  389. help='push built HTML to git upstream')
  390. args = parser.parse_args()
  391. if len(sys.argv) < 2:
  392. parser.print_help()
  393. sys.exit()
  394. if args.clean:
  395. clean()
  396. if args.build:
  397. build()
  398. if args.dist:
  399. dist()
  400. if args.preview:
  401. preview()
  402. if args.new:
  403. new_post()
  404. if args.publish:
  405. publish()
  406. if __name__ == '__main__': # pragma: no coverage
  407. main()