blogit.py 17 KB

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