├── DEPLOY.md ├── INSTALL.md ├── README.md ├── __init__.py ├── bower.json ├── help.db ├── lib.py ├── requirements.txt ├── schema.sql ├── settings.py ├── static ├── color.css ├── custom.css ├── entries.css ├── favicon.ico ├── palette.png ├── style.css ├── tanuki.js └── tanuki.png ├── templates ├── capture.html ├── delete.html ├── edit.html ├── entry.html ├── error.html ├── gallery.html ├── header.html ├── help.html ├── index.html ├── layout.html ├── list.html ├── media.html ├── search.html ├── tag_set.html └── tags.html └── views.py /DEPLOY.md: -------------------------------------------------------------------------------- 1 | DEPLOY 2 | ====== 3 | 4 | Provision a VM. 5 | 6 | ```root 7 | # lsb_release -a 8 | No LSB modules are available. 9 | Distributor ID: Ubuntu 10 | Description: Ubuntu 14.04 LTS 11 | Release: 14.04 12 | Codename: trusty 13 | ``` 14 | 15 | 16 | Install packages 17 | ---------------- 18 | 19 | Let's use apache, for example. 20 | 21 | ```root 22 | $ su - 23 | # apt-get install apache2 libapache2-mod-wsgi 24 | # apt-get install python-dev python-lxml sqlite3 25 | # apt-get install libxml2-dev libxslt-dev libncurses5-dev zlib1g-dev 26 | # apt-get install nodejs nodejs-dev npm 27 | # apt-get install python-virtualenv virtualenvwrapper 28 | # npm install -g bower 29 | 30 | # mkdir /var/www/tanuki 31 | # cd /var/www/tanuki 32 | # git clone https://github.com/siznax/tanuki.git 33 | # mkvirtualenv /var/www/tanuki/tanuki/env 34 | # source tanuki/env/bin/activate 35 | (env)# pip install -r tanuki/requirements.txt 36 | (env)# cd tanuki 37 | (env)# bower install 38 | (env)# sqlite3 tanuki.db < schema.sql 39 | 40 | # chgrp -R www-data /var/www/tanuki 41 | # chown -R www-data /var/www/tanuki 42 | ``` 43 | 44 | 45 | Create WSGIScript 46 | ----------------- 47 | 48 | /var/www/tanuki/app.wsgi: 49 | 50 | ```python 51 | activate_this = '/var/www/tanuki/tanuki/env/bin/activate_this.py' 52 | execfile(activate_this, dict(__file__=activate_this)) 53 | 54 | import sys,os 55 | sys.path.insert( 0, '/var/www/tanuki' ) 56 | os.chdir( '/var/www/tanuki' ) 57 | 58 | from tanuki import app as application 59 | ``` 60 | 61 | 62 | Create site config 63 | ------------------ 64 | 65 | /etc/apache2/sites-available/tanuki.conf: 66 | 67 | ``` 68 | 69 | ServerName tanuki.siznax.net 70 | 71 | WSGIProcessGroup tanuki 72 | WSGIApplicationGroup %{GLOBAL} 73 | Order deny,allow 74 | Allow from all 75 | 76 | WSGIDaemonProcess tanuki user=www-data group=www-data threads=5 77 | WSGIScriptAlias / /var/www/tanuki/app.wsgi 78 | 79 | ``` 80 | 81 | 82 | Enable site and reload 83 | ---------------------- 84 | 85 | ```root 86 | # a2ensite tanuki 87 | # service apache2 reload 88 | ``` 89 | 90 | 91 | @siznax 92 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | Install 2 | ================================================================ 3 | 4 | Clone tanuki: 5 | 6 | ```shell 7 | $ git clone https://github.com/siznax/tanuki.git 8 | ``` 9 | 10 | You may need to install some of the following packages: 11 | 12 | ```root 13 | $ su - 14 | # apt-get install python-virtualenv virtualenvwrapper 15 | # apt-get install python-dev python-lxml sqlite3 16 | # apt-get install libxml2-dev libxslt-dev libncurses5-dev zlib1g-dev 17 | # apt-get install nodejs nodejs-dev npm 18 | ``` 19 | 20 | 21 | Install Python dependencies (see [`requirements.txt`](https://github.com/siznax/tanuki/blob/master/requirements.txt)): 22 | 23 | ```shell 24 | $ mkvirtualenv tanuki 25 | (tanuki)$ pip install -r tanuki/requirements.txt 26 | ``` 27 | 28 | 29 | Install [bower](http://bower.io/) (JS/CSS) dependencies (see [`bower.json`](https://github.com/siznax/tanuki/blob/master/bower.json)): 30 | 31 | ```shell 32 | (tanuki)$ npm install -g bower 33 | (tanuki)$ cd tanuki 34 | (tanuki)$ bower install 35 | ``` 36 | 37 | 38 | Create a database from the schema provided: 39 | 40 | ```shell 41 | (tanuki)$ sqlite3 tanuki.db < schema.sql 42 | ``` 43 | 44 | 45 | _Optionally, you can put your database in the "cloud" ☁ to share 46 | on all your computers, and to have a durable backup. Please keep in 47 | mind, there is nothing in tanuki protecting your database. You can 48 | point to it in `settings.py` as `DATABASE`:_ 49 | 50 | ```python 51 | class DefaultConfig: 52 | DEBUG = True 53 | DATABASE = "/Users//Dropbox/tanuki.db" 54 | ``` 55 | 56 | _or you can symlink `tanuki/tanuki.db` (the default dbfile) to your Dropbox version:_ 57 | 58 | ```shell 59 | (tanuki)$ ln -s /Users//Dropbox/tanuki.db . 60 | ``` 61 | 62 | 63 | Create a startup script outside of the tanuki module (e.g. `~/Code/tanuki.py`): 64 | 65 | ```python 66 | from tanuki import app 67 | from tanuki import settings 68 | app.config.from_object(settings.DefaultConfig) 69 | app.run(port=5001) 70 | ``` 71 | 72 | 73 | Startup 74 | ------- 75 | 76 | Start the tanuki app in the shell: 77 | 78 | ```shell 79 | $ workon tanuki 80 | (tanuki)$ python tanuki.py 81 | * Running on http://127.0.0.1:5001/ 82 | ``` 83 | 84 | Visit your _tanuki_ in a web browser at: 85 | 86 | Enjoy! 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![tanuki icon](https://raw.githubusercontent.com/siznax/tanuki/master/static/tanuki.png) 2 | 3 | _Tanuki_ is a tiny web app that allows you to take media rich notes 4 | (text, images, HTML, audio, video, embeds, etc.) and keep them 5 | organized in your browser. I made it for taking personal notes to be 6 | kept offline—a modern [commonplace 7 | book](https://en.wikipedia.org/wiki/Commonplace_book). 8 | 9 | It's meant to be a small bit of (nearly serverless) code that's easy 10 | to understand and maintain. At this point it's probably best for 11 | programmers (or curious folks) to run on their local machines. It 12 | doesn't require a network connection or try to put your data online, 13 | but there's nothing preventing you from doing that sort of stuff 14 | either. 15 | 16 | It's made of [Python](https://python.org), 17 | [Flask](http://flask.pocoo.org/), [SQLite](http://www.sqlite.org/), 18 | and some [Bower](http://bower.io/) components like 19 | [Octicons](https://octicons.github.com/). I've also integrated 20 | [frag2text](https://github.com/siznax/frag2text/), 21 | [code-prettify](https://github.com/google/code-prettify), and of 22 | course you can _embed_ anything you like from the web. I have 23 | thousands of entries in my offline _tanuki_ and use it constantly. 24 | 25 | Anyone can contribute to tanuki development and I'm happy to answer 26 | any questions about it or hear feedback. 27 | 28 | See 29 | [INSTALL.md](https://github.com/siznax/tanuki/blob/master/INSTALL.md) 30 | to get started. 31 | 32 | _Tanuki_ icon courtesy of 33 | [Josh](http://artrelatedblog.wordpress.com/). 34 | 35 | screen1 36 | screen2 37 | screen3 38 | screen4 39 | screen5 40 | screen6 41 | screen7 42 | 43 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | app = Flask(__name__) 3 | import tanuki.views 4 | 5 | # this package wants something external to run app 6 | # see http://flask.pocoo.org/docs/patterns/packages/ 7 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tanuki", 3 | "version": "0.0.0", 4 | "homepage": "https://github.com/siznax/tanuki", 5 | "authors": [ 6 | "siznax" 7 | ], 8 | "description": "tanuki JS/CSS packages", 9 | "license": "MIT", 10 | "private": true, 11 | "ignore": [ 12 | "**/.*", 13 | "node_modules", 14 | "bower_components", 15 | "test", 16 | "tests" 17 | ], 18 | "dependencies": { 19 | "gallery-js": "~0.0.2", 20 | "google-code-prettify": "~1.0.3", 21 | "jquery": "~2.1.3", 22 | "octicons": "~2.1.2" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /help.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/siznax/tanuki/5da9b6b3f72e9121627acf8880bd458c2bc0fe4a/help.db -------------------------------------------------------------------------------- /lib.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | 3 | import datetime 4 | import frag2text 5 | import logging 6 | import lxml.html 7 | import markdown 8 | import os 9 | import sqlite3 10 | import string 11 | import sys 12 | 13 | from flask import render_template, request, redirect, abort 14 | 15 | __author__ = "siznax" 16 | __date__ = "Feb 2015" 17 | 18 | 19 | class Tanuki: 20 | 21 | DEFAULT_DB = "tanuki.db" 22 | DEFAULT_HELP_DB = "help.db" 23 | MAX_ENTRY_LEN = 131072 24 | MAX_TAGS_PER_ENTRY = 5 25 | MAX_TAG_LEN = 128 26 | MAX_TITLE_LEN = 1024 27 | 28 | def __init__(self, config): 29 | """initialize tanuki instance.""" 30 | if config['DEBUG']: 31 | self.log = console_logger(__name__) 32 | else: 33 | self.log = console_logger(__name__, logging.INFO) 34 | self.config = config 35 | self.log.debug(self.config) 36 | 37 | def apply_tags(self, entries, editing=False): 38 | """update entries dict w/tag list or comma-seperated list if 39 | editing. 40 | """ 41 | for x in entries: 42 | tags = self.get_tags(x['id']) 43 | if editing: 44 | x['tags'] = ', '.join(filter(bool, (ascii(x) for x in tags))) 45 | else: 46 | x['tags'] = tags 47 | return entries 48 | 49 | def clear_tags(self, entry_id): 50 | """remove all tags referencing given id.""" 51 | self.db_query("delete from tags where id=?", [entry_id]) 52 | 53 | def db_connect(self): 54 | """connect to default DB.""" 55 | if self.config.get('DATABASE'): 56 | if not os.path.exists(self.config['DATABASE']): 57 | raise ValueError("DATABASE not found: " + 58 | self.config.get('DATABASE')) 59 | dbfile = self.config.get('DATABASE') 60 | else: 61 | dbfile = os.path.join(os.path.dirname(__file__), 62 | Tanuki.DEFAULT_DB) 63 | if request.path.startswith('/help'): 64 | dbfile = os.path.join(os.path.dirname(__file__), 65 | Tanuki.DEFAULT_HELP_DB) 66 | self.log.info("Connecting %s" % (dbfile)) 67 | self.con = sqlite3.connect(dbfile) 68 | self.con.execute('pragma foreign_keys = on') # !important 69 | self.db = self.con.cursor() 70 | self.dbfile = dbfile 71 | 72 | def db_disconnect(self): 73 | """teardown DB.""" 74 | nchanges = self.con.total_changes 75 | msg = "Disconnect %s (%s changes)" % (self.dbfile, nchanges) 76 | self.log.info(msg) 77 | self.con.close() 78 | 79 | def db_query(self, sql, val=''): # TODO: ORM 80 | """query database.""" 81 | self.log.debug("%s | %s" % (sql, ''.join(str(val)))) 82 | return self.db.execute(sql, val) 83 | 84 | def delete_entry(self, entry_id): 85 | """purge entry and associated tags.""" 86 | self.clear_tags(entry_id) 87 | self.db_query('DELETE from entries WHERE id=?', [entry_id]) 88 | self.con.commit() 89 | 90 | def get_entries(self): 91 | """return fully hydrated entries ordered by date created.""" 92 | sql = 'select * from entries order by date desc,id desc' 93 | entries = [entry2dict(x) for x in self.db_query(sql)] 94 | self.log.debug("entries %d bytes" % (sys.getsizeof(entries))) 95 | return entries 96 | 97 | def get_entries_by_updated(self): 98 | """return fully hydrated entries ordered by date updated.""" 99 | sql = 'select * from entries order by updated desc, date desc' 100 | entries = [entry2dict(x, 'updated') for x in self.db_query(sql)] 101 | self.log.debug("entries %d bytes" % (sys.getsizeof(entries))) 102 | return entries 103 | 104 | def get_entries_img_src(self, entries): 105 | """update entries with src attribute.""" 106 | for x in entries: 107 | x['img'] = None if not x['text'] else img_src(x['text']) 108 | self.log.debug("%d %s" % (x['id'], x['img'])) 109 | return entries 110 | 111 | def get_entries_matching(self, terms): 112 | """return entries matching terms in title or text.""" 113 | terms = '%' + terms.encode('ascii', 'ignore') + '%' 114 | sql = ("select * from entries where title like ? or text like ? " 115 | "order by updated desc") 116 | val = [terms, terms] 117 | return [entry2dict(x) for x in self.db_query(sql, val)] 118 | 119 | def get_entries_tagged(self, tag): 120 | """return entries matching tag name ordered by date.""" 121 | sql = ("select * from entries,tags where tags.name=? and " 122 | "tags.id=entries.id order by updated desc") 123 | return [entry2dict(x, 'updated') for x in self.db_query(sql, [tag])] 124 | 125 | def get_entry(self, entry_id, editing=False): 126 | """returns single entry as HTML or markdown text.""" 127 | sql = 'select * from entries where id=?' 128 | row = self.db_query(sql, [entry_id]).fetchone() 129 | if not row: 130 | abort(404) 131 | entry = [entry2dict(row)] 132 | entry = self.apply_tags(entry, editing) 133 | if not editing: 134 | entry = self.markdown_entries(entry) 135 | return entry[0] 136 | 137 | def get_help_entries(self): 138 | """return entries from help.db.""" 139 | sql = 'select * from entries' 140 | return [entry2dict(x) for x in self.db_query(sql)] 141 | 142 | def get_latest_entries(self): 143 | """return last ten entries updated.""" 144 | sql = 'select * from entries order by updated desc limit 10' 145 | return [entry2dict(x) for x in self.db_query(sql)] 146 | 147 | def get_notag_entries(self): 148 | """return entries having no tags.""" 149 | sql = 'select * from entries where id not in (select id from tags)' 150 | return [entry2dict(x) for x in self.db_query(sql)] 151 | 152 | def get_num_entries(self): 153 | """returns count of entries table.""" 154 | sql = 'select count(*) from entries' 155 | return self.db_query(sql).fetchone()[0] 156 | 157 | def get_status(self): 158 | """get and set status data, mostly counts.""" 159 | self.status = {'entries': self.get_num_entries(), 160 | 'tags': len(self.get_tag_set()), 161 | 'notag': len(self.get_notag_entries())} 162 | 163 | def get_status_msg(self): 164 | """return status string for most routes.""" 165 | return "%d entries %d tags " % (self.status['entries'], 166 | self.status['tags']) 167 | 168 | def get_tag_set(self): 169 | """return dict of tag names keyed on count.""" 170 | sql = 'select count(*),name from tags group by name order by name' 171 | return [{'count': r[0], 'name': r[1]} for r in self.db_query(sql)] 172 | 173 | def get_tags(self, eid): 174 | """return sorted list of tag names.""" 175 | t = [] 176 | for r in self.db_query('select name from tags where id=?', [eid]): 177 | t.append(r[0]) 178 | return sorted(t) 179 | 180 | def get_tags_status_msg(self): 181 | """return /tags status string.""" 182 | notag_link = 'notag' 183 | return "%d entries %d tags (%d %s) " % (self.status['entries'], 184 | self.status['tags'], 185 | self.status['notag'], 186 | notag_link) 187 | 188 | def insert_entry(self, req): 189 | """executes DB INSERT, returns entry id.""" 190 | sql = "insert into entries values(?,?,?,?,?,?)" 191 | val = [None, 192 | req.form['title'][:Tanuki.MAX_TITLE_LEN], 193 | req.form['entry'][:Tanuki.MAX_ENTRY_LEN], 194 | req.form['date'], utcnow(), 0] 195 | cur = self.db_query(sql, val) 196 | return cur.lastrowid 197 | 198 | def markdown_entries(self, entries): 199 | """return Markdown text from HTML entries""" 200 | for x in entries: 201 | nbytes = sys.getsizeof(x['text']) 202 | self.log.debug("markdown %d (%d bytes)" % (x['id'], nbytes)) 203 | x['text'] = markdown.markdown(x['text']) 204 | return entries 205 | 206 | def msg_options(self, tag, view='list'): # TODO: poor implementation 207 | opt1 = 'list' 208 | opt2 = 'gallery' % (tag) 209 | if view == 'gallery': 210 | opt1 = 'list' % (tag) 211 | opt2 = 'gallery' 212 | return " — " + ' | '.join([opt1, opt2]) 213 | 214 | def render_capture_form(self): 215 | return render_template('capture.html', 216 | title='capture', 217 | status=self.status) 218 | 219 | def render_delete_form(self, entry_id): 220 | entry = self.get_entry(entry_id) 221 | if entry['public'] > 1: 222 | return render_template('error.html', msg="Entry locked.") 223 | return render_template('delete.html', entry=entry) 224 | 225 | def render_edit_form(self, entry_id): 226 | entry = self.get_entry(entry_id, True) 227 | referrer = request.referrer 228 | if not referrer: 229 | referrer = "/entry/%s" % entry_id 230 | title = "edit: %s" % (entry['title']) 231 | return render_template('edit.html', 232 | entry=entry, 233 | referrer=referrer, 234 | title=title, 235 | status=self.status) 236 | 237 | def render_edit_capture_form(self, endpoint, stype, selector): 238 | text = ("\n\n") 242 | try: 243 | text += frag2text.frag2text( 244 | endpoint, stype, selector).decode('utf8') 245 | except Exception as err: 246 | text += "Caught exception: %s" % err 247 | entry = {'date': datetime.date.today().isoformat(), 248 | 'text': text, 249 | 'title': None, 250 | 'tags': None, 251 | 'public': 0} 252 | return render_template('edit.html', 253 | title='capture %s' % endpoint, 254 | entry=entry, 255 | status=self.status) 256 | 257 | def render_entry(self, entry_id): 258 | entry = self.get_entry(entry_id) 259 | return render_template('entry.html', 260 | entry=entry, 261 | title=entry['title'], 262 | status=self.status) 263 | 264 | def render_help(self): 265 | return render_template("help.html", 266 | entries=self.get_help_entries(), 267 | title="help", 268 | status=self.status) 269 | 270 | def render_index(self, page=0): 271 | readme = self.get_entries_tagged("readme") 272 | todo = self.get_entries_tagged("todo") 273 | pinned = self.get_entries_tagged("pinned") 274 | return render_template('index.html', 275 | title="%d" % self.status['entries'], 276 | latest=self.get_latest_entries(), 277 | readme=readme, 278 | todo=todo, 279 | pinned=pinned, 280 | tag_set=self.get_tag_set(), 281 | status=self.status) 282 | 283 | def render_list(self): 284 | """show entries by date created.""" 285 | entries = self.get_entries() 286 | entries = mark_media(entries) 287 | return render_template('list.html', 288 | title="list (%d) by created" % len(entries), 289 | entries=entries, 290 | sortby='created', 291 | status=self.status) 292 | 293 | def render_list_by_updated(self): 294 | """show entries by date updated.""" 295 | entries = self.get_entries_by_updated() 296 | entries = mark_media(entries) 297 | return render_template('list.html', 298 | title="list (%d) by updated" % len(entries), 299 | entries=entries, 300 | sortby='updated', 301 | status=self.status) 302 | 303 | def render_list_media(self, mediatype): 304 | """show entries selected by mediatype""" 305 | entries = self.get_entries() 306 | entries = [x for x in mark_media(entries) if mediatype in x['media']] 307 | if not entries: 308 | abort(404) 309 | return render_template('list.html', 310 | title="list (%d) %s" % (len(entries), mediatype), 311 | entries=entries, 312 | mediatype=mediatype, 313 | status=self.status) 314 | 315 | def render_media_count(self): 316 | """show media counts""" 317 | entries = self.get_entries() 318 | entries = mark_media(entries, links=False) 319 | return render_template('media.html', 320 | title="(%d) media entries" % len(entries), 321 | entries=entries, 322 | media=media_count(entries), 323 | status=self.status) 324 | 325 | def render_new_form(self): 326 | entry = {'date': datetime.date.today().isoformat(), 327 | 'text': None, 328 | 'title': None, 329 | 'tags': None, 330 | 'public': 0} 331 | return render_template('edit.html', 332 | entry=entry, 333 | title='new', 334 | status=self.status) 335 | 336 | def render_notags(self): 337 | notag = self.get_notag_entries() 338 | return render_template('list.html', 339 | title="notag (%d)" % len(notag), 340 | entries=notag, 341 | notag=True, 342 | status=self.status) 343 | 344 | def render_tagged(self, tag, view=None): 345 | tagged = self.get_entries_tagged(tag) 346 | tagged = mark_media(tagged) 347 | num = len(tagged) 348 | title = "#%s (%d)" % (tag, num) 349 | if view == 'gallery': 350 | return self.render_tagged_gallery(title, tagged) 351 | return render_template('list.html', 352 | title=title, 353 | entries=tagged, 354 | tag=tag, 355 | status=self.status) 356 | 357 | def render_tagged_gallery(self, tag): 358 | tagged = self.get_entries_tagged(tag) 359 | tagged = self.markdown_entries(tagged) 360 | tagged = self.get_entries_img_src(tagged) 361 | images = set(x['img'] for x in tagged if x['img']) 362 | return render_template('gallery.html', 363 | title="#%s gallery" % tag, 364 | entries=tagged, 365 | tag=tag, 366 | images=images, 367 | status=self.status) 368 | 369 | def render_tags(self): 370 | tag_set = binned_tags(self.get_tag_set()) 371 | return render_template('tags.html', 372 | title="tags (%d)" % len(tag_set), 373 | tag_set=tag_set, 374 | status=self.status) 375 | 376 | def render_search_form(self): 377 | return render_template('search.html', 378 | title='search', 379 | status=self.status, 380 | tag_set=self.get_tag_set()) 381 | 382 | def render_search_results(self, terms): 383 | found = self.get_entries_matching(terms) 384 | found = mark_media(found) 385 | return render_template('list.html', 386 | title="%s (%d)" % (terms, len(found)), 387 | terms=terms, 388 | entries=found, 389 | status=self.status) 390 | 391 | def store_tags(self, entry_id, tags): 392 | """store tags specified in edit form""" 393 | self.clear_tags(entry_id) 394 | for count, tag in enumerate(normalize_tags(tags)): 395 | if tag.strip(): 396 | if (count + 1) > Tanuki.MAX_TAGS_PER_ENTRY: 397 | return 398 | sql = 'insert into tags values(?,?,?)' 399 | date = datetime.date.today().isoformat() 400 | self.db_query(sql, [entry_id, tag[:Tanuki.MAX_TAG_LEN], date]) 401 | 402 | def update_entry(self, req): 403 | """executes DB UPDATE, returns entry id.""" 404 | sql = ("update entries set title=?,text=?,date=?," 405 | "updated=?,public=? where id=?") 406 | val = (req.form['title'][:Tanuki.MAX_TITLE_LEN], 407 | req.form['entry'][:Tanuki.MAX_ENTRY_LEN], 408 | req.form['date'], utcnow(), 0, req.form['entry_id']) 409 | self.db_query(sql, val) 410 | return req.form['entry_id'] 411 | 412 | def upsert(self, req): 413 | """update existing or insert new entry in DB.""" 414 | try: 415 | if Tanuki.valid_edit_form(req): 416 | entry_id = self.update_entry(req) 417 | else: 418 | entry_id = self.insert_entry(req) 419 | self.store_tags(entry_id, req.form['tags']) 420 | self.con.commit() 421 | return redirect("/entry/%s" % (entry_id)) 422 | except ValueError: 423 | return render_template('error.html', 424 | msg="ValueError raised, try again.") 425 | except sqlite3.IntegrityError: 426 | return render_template('error.html', 427 | msg="Title or text not unique, try again.") 428 | 429 | @staticmethod 430 | def valid_edit_form(req): 431 | """returns True if edit form validates.""" 432 | datetime.datetime.strptime(req.form['date'], '%Y-%m-%d') 433 | if 'locked' in req.form: 434 | raise ValueError 435 | if str_is_int(req.form['title']): 436 | raise ValueError 437 | if str_is_int(req.form['entry']): 438 | raise ValueError 439 | if 'entry_id' in req.form.keys(): 440 | return True 441 | return False 442 | 443 | 444 | def ascii(s): 445 | """return just the ascii portion of a string""" 446 | return ''.join([c for c in s if ord(c) < 128]) 447 | 448 | 449 | def binned_tags(tag_set): 450 | if not tag_set: 451 | return [] 452 | max_count = max([x['count'] for x in tag_set]) 453 | t_min = 0 454 | f_max = 3 455 | for tag in tag_set: 456 | if tag['count'] > t_min: 457 | em = (f_max * (tag['count'] - t_min)) / (max_count - t_min) 458 | tag['em'] = round(em + 1, 2) 459 | else: 460 | tag['em'] = 1 461 | return tag_set 462 | 463 | 464 | def console_logger(user_agent, level=logging.DEBUG): 465 | """return logger emitting to console.""" 466 | lgr = logging.getLogger(user_agent) 467 | lgr.setLevel(logging.DEBUG) 468 | fmtr = logging.Formatter("%(name)s %(levelname)s %(funcName)s: " 469 | "%(message)s") 470 | clog = logging.StreamHandler(sys.stdout) 471 | clog.setLevel(level) 472 | clog.setFormatter(fmtr) 473 | lgr.addHandler(clog) 474 | return lgr 475 | 476 | 477 | def date_str(date): 478 | """return "Mon 1 Jan 1970" from 'YYYY-mm-dd'.""" 479 | try: 480 | date = datetime.datetime.strptime(date, '%Y-%m-%d') 481 | return date.strftime('%a %d %b %Y') 482 | except: 483 | return date 484 | 485 | 486 | def entry2dict(row, sort='date'): 487 | """map entries DB row to dict.""" 488 | _dict = {'id': row[0], 489 | 'title': row[1], 490 | 'text': row[2], 491 | 'date': row[3], 492 | 'date_str': date_str(row[3]), 493 | 'updated': row[4], 494 | 'public': row[5]} 495 | date = row[3] 496 | if sort == 'updated': 497 | date = row[4] 498 | _dict['year'] = parse_ymd(date)[0] 499 | _dict['month'] = parse_ymd(date)[1] 500 | _dict['day'] = parse_ymd(date)[2] 501 | return _dict 502 | 503 | 504 | def img_src(html): 505 | """return (first) src attribute from HTML.""" 506 | doc = lxml.html.document_fromstring(html) 507 | for src in doc.xpath("//img/@src"): 508 | return src 509 | 510 | 511 | def link_media(media): 512 | return ['%s' % (m, m) for m in media] 513 | 514 | 515 | def mark_media(entries, links=True): 516 | for x in entries: 517 | mediatypes = ['"); 13 | first_video = $( "#"+playlist+" a" ).attr("id"); 14 | changeChannel( first_video ); 15 | } 16 | }); 17 | 18 | $( ".playlist a" ).click( function( event ) { 19 | highlightClicked( event.target.id ); 20 | changeChannel( event.target.id ); 21 | return false; 22 | }); 23 | 24 | function changeChannel( video ) { 25 | console.log( "changeChannel(" + video + ")"); 26 | $( "#viewtube" ).html(''); 30 | } 31 | 32 | function highlightClicked( id ) { 33 | console.log( "highlightClicked " + id ); 34 | var last = $( "#viewtube" ).attr( "last" ); 35 | $( "#"+last ).css( "font-weight", "normal"); 36 | $( "#"+last ).closest( "li" ) 37 | .css( "list-style-type", "disc" ); 38 | $( "#viewtube" ).attr( "last", id ); 39 | $( "#"+id ).css( "font-weight", "bold" ); 40 | $( "#"+id ).closest( "li" ) 41 | .css( "list-style-type", "circle" ); 42 | } 43 | 44 | }); 45 | -------------------------------------------------------------------------------- /static/tanuki.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/siznax/tanuki/5da9b6b3f72e9121627acf8880bd458c2bc0fe4a/static/tanuki.png -------------------------------------------------------------------------------- /templates/capture.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 | {% include "header.html" %} 4 |
5 | 6 |
7 |
8 | 10 | 14 | 16 | 17 |
18 |
19 | 20 |
21 | 22 |

Capture any web page, or just part of it.

23 | 24 |

25 | 26 | 27 | 33 | 38 | 43 |
frag2text examples 29 | URL 30 | stype 31 | selector 32 |
A NYT Article: 34 | http://nyti.ms/17qJGw4 35 | CSS 36 | article 37 |
A Wikipedia Infobox: 39 | http://en.wikipedia.org/wiki/Amanita 40 | CSS 41 | .infobox 42 |
A specific paragraph: 44 | http://en.wikipedia.org/wiki/Amanita 45 | XPath 46 | //p[13] 47 |
48 |

49 | 50 |
51 | 52 | {% endblock %} 53 | -------------------------------------------------------------------------------- /templates/delete.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 | 4 |
5 | 6 |
7 | 8 |
9 | 10 | 11 | 12 | 13 | 16 | 20 | 24 | 28 | 29 | 30 | 31 | 32 |

Really, Destroy?

14 | 15 |
ID: 17 | {{entry.id}} 18 | 19 |
Title: 21 | {{entry.title}} 22 | 23 |
Updated: 25 | {{entry.updated}} 26 | 27 |
  
33 | 34 | 35 |
36 |
37 | 38 |
39 | 40 |
41 | 42 | {% endblock %} 43 | 44 | -------------------------------------------------------------------------------- /templates/edit.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 | {% include "header.html" %} 4 |
5 |
6 | 7 | 8 | 9 | 15 | 21 |
13 | 14 |
19 | 20 |
23 | 25 | 1 %} disabled {% endif %}> 27 | 28 |
29 | {% if entry.id %} 30 | 31 | 32 | {% endif %} 33 |
34 |
35 | {% endblock %} 36 | -------------------------------------------------------------------------------- /templates/entry.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 | {% include "header.html" %} 4 | 5 | {% if not request.path.startswith("/help") %} 6 |
7 | {{entry.date_str}} 8 | {% if entry.tags %} 9 | {% for tag in entry.tags %} 10 | #{{tag}} 11 | {% endfor %} 12 | {% endif %} 13 |
14 | {% endif %} 15 | 16 |
17 |

{{ entry.title }}

18 | {{ entry.text|safe }} 19 |
20 | 21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /templates/error.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 |
4 |

Uh-oh!

5 |

{{msg}}

6 |
7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /templates/gallery.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 | {% include "header.html" %} 4 | 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /templates/header.html: -------------------------------------------------------------------------------- 1 | 80 | -------------------------------------------------------------------------------- /templates/help.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 | {% include "header.html" %} 4 |
5 |

Help

6 |
    7 | {% for entry in entries %} 8 |
  1. {{entry.title}} 9 | {% endfor %} 10 |
11 |
12 | 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 | {% include "header.html" %} 4 | 5 |
6 | 7 |
8 | 9 |
10 |
11 |
    12 | latest 13 | {% if latest %} 14 | {% for entry in latest %} 15 |
  • {{entry.title}} 16 | {% endfor %} 17 | {% else %} 18 |
  • Latest entries will be listed here. 19 | {% endif %} 20 |
21 |
22 |
23 | 24 |
25 |
26 |
    27 | pinned ({{pinned|length()}}) 28 | {% if pinned %} 29 | {% for entry in pinned[:10] %} 30 |
  • {{entry.title}} 31 | {% endfor %} 32 | {% else %} 33 |
  • Entries tagged "pinned" will be listed here. 34 | {% endif %} 35 |
36 |
37 |
38 | 39 |
40 | 41 |
42 |
43 |
    44 | readme ({{readme|length()}}) 45 | {% if readme %} 46 | {% for entry in readme[:10] %} 47 |
  • {{entry.title}} 48 | {% endfor %} 49 | {% else %} 50 |
  • Entries tagged "readme" will be listed here. 51 | {% endif %} 52 |
53 |
54 |
55 | 56 |
57 |
58 |
    59 | todo ({{todo|length()}}) 60 | {% if todo %} 61 | {% for entry in todo[:10] %} 62 |
  • {{entry.title}} 63 | {% endfor %} 64 | {% else %} 65 |
  • Entries tagged "todo" will be listed here. 66 | {% endif %} 67 |
68 |
69 |
70 | 71 | {% if tag_set %}
{% endif %} 72 | 73 |
74 | 75 | {% include "tag_set.html" %} 76 | 77 |
78 | 79 |
80 | 83 |
84 | 85 |
86 | 87 | {% endblock %} 88 | -------------------------------------------------------------------------------- /templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ title }} 6 | 7 | 9 | 10 | 11 | 12 | {% if entry %} 13 | 14 | 16 | 17 | 19 | 20 | 21 | {% endif %} 22 | 23 | 24 | 25 | 26 | {% if entry %} 27 | 28 | {% else %} 29 | 30 | {% endif %} 31 | {% block body %} 32 | {% endblock %} 33 | 34 | -------------------------------------------------------------------------------- /templates/list.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 | {% include "header.html" %} 4 | 5 |
6 | 7 | {% if entries %} 8 | 9 |
10 | {% if tag %}{{entries|length()}} #{{tag}} 11 | | gallery{% endif %} 12 | {% if notag %}({{entries|length()}}) entries not tagged{% endif %} 13 | {% if terms %}found ({{entries|length()}}) matching "{{terms}}"{% endif %} 14 | {% if mediatype %}{{entries|length()}} {{mediatype}} media{% endif %} 15 | {% if request.path=='/list' or request.path=='/updates'%} 16 | {{entries|length()}} entries by date 17 | {% endif %} 18 | {% if request.path=='/list' %} 19 | created | updated 20 | {% endif %} 21 | {% if request.path=='/updates' %} 22 | updated | created 23 | {% endif %} 24 |
25 | 26 |
27 | 28 | {% if not '/list' in request.path %}
    {% endif %} 29 | 30 | {% for entry in entries %} 31 | 32 | {% if '/list' in request.path %} 33 | {% if not entry.month == last_month %} 34 | {% if last_month %}
{% endif %} 35 |
    {{entry.month}} {{entry.year}} 36 | {% endif %} 37 | {% endif %} 38 | 39 | {% if request.path.startswith('/list') %} 40 |
  1. 41 | {% else %} 42 |
  2. 43 | {% endif %} 44 | {{entry.title}} 45 | {% if entry.media %}- {{entry.media|safe}}{% endif %} 46 |
  3. 47 | 48 | {% set last_month = entry.month %} 49 | {% endfor %} 50 |
51 | 52 |
53 | 54 | {% else %} 55 | 56 |
57 | {% if terms %}No entries found matching "{{terms}}"{% endif %} 58 | {% if tag %}No entries tagged "{{tag}}"{% endif %} 59 |
60 | 61 | {% endif %} 62 | 63 |
64 | 65 | {% endblock %} 66 | -------------------------------------------------------------------------------- /templates/media.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 | {% include "header.html" %} 4 | 5 |
6 | 7 |
8 | Media found in ({{entries|length()}}) entries 9 |
10 | 11 |
12 | 13 | 14 | 18 |
mediatype 15 | count 16 | {% for key,val in media.iteritems() %} 17 |
{% if key %}{{key}} 19 | {% else %}None{% endif %} 20 | {{val}} 21 | {% endfor %} 22 |
23 |
24 |
25 | 26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /templates/search.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 | {% include "header.html" %} 4 | 18 | {% endblock %} 19 | 20 | -------------------------------------------------------------------------------- /templates/tag_set.html: -------------------------------------------------------------------------------- 1 |
2 | {% for tag in tag_set %} 3 | {{tag['name']}}({{tag['count']}}) 4 | {% endfor %} 5 |
6 | -------------------------------------------------------------------------------- /templates/tags.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 | {% include "header.html" %} 4 | 5 |
6 | 7 |
{{tag_set|length()}} tags
8 | 9 | {% if tag_set %} 10 | {% include "tag_set.html" %} 11 | {% else %} 12 |
No tags yet!
13 | {% endif %} 14 | 15 |
16 | 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /views.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flask import request, redirect, url_for 4 | from flask.ext.bower import Bower 5 | from lib import Tanuki 6 | from tanuki import app, settings 7 | 8 | __author__ = "siznax" 9 | __date__ = "Jan 2015" 10 | 11 | app.config.from_object(settings.DefaultConfig) 12 | if '/var/www/' in os.getcwd(): 13 | app.config.from_object(settings.ProductionConfig) 14 | 15 | Bower(app) 16 | applib = Tanuki(app.config) 17 | 18 | 19 | @app.before_request 20 | def before_request(): 21 | if '/static' not in request.path: 22 | applib.db_connect() 23 | applib.get_status() 24 | 25 | 26 | @app.teardown_request 27 | def teardown_request(exception): 28 | if '/static' not in request.path: 29 | applib.db_disconnect() 30 | 31 | 32 | @app.route('/') 33 | def index(): 34 | return applib.render_index() 35 | 36 | 37 | @app.route('/favicon.ico') 38 | def favicon(): 39 | return app.send_static_file("favicon.ico") 40 | 41 | 42 | @app.route('/list') 43 | def list(): 44 | return applib.render_list() 45 | 46 | 47 | @app.route('/updates') 48 | def updates(): 49 | return applib.render_list_by_updated() 50 | 51 | 52 | @app.route('/media') 53 | def media_count(): 54 | return applib.render_media_count() 55 | 56 | 57 | @app.route('/media/') 58 | def mediatype(mediatype): 59 | return applib.render_list_media(mediatype) 60 | 61 | 62 | @app.route('/new') 63 | def new(): 64 | return applib.render_new_form() 65 | 66 | 67 | @app.route('/capture') 68 | def capture(): 69 | return applib.render_capture_form() 70 | 71 | 72 | @app.route('/edit_capture', methods=['POST']) 73 | def edit_capture(): 74 | return applib.render_edit_capture_form( 75 | request.form['endpoint'], 76 | request.form['stype'], 77 | request.form['selector']) 78 | 79 | 80 | @app.route('/store', methods=['POST']) 81 | def store(): 82 | return applib.upsert(request) 83 | 84 | 85 | @app.route('/entry/') 86 | def entry(_id): 87 | return applib.render_entry(_id) 88 | 89 | 90 | @app.route('/edit/') 91 | def edit(_id): 92 | return applib.render_edit_form(_id) 93 | 94 | 95 | @app.route('/delete/') 96 | def delete_form(_id): 97 | return applib.render_delete_form(_id) 98 | 99 | 100 | @app.route('/delete', methods=['POST']) 101 | def delete_entry(): 102 | applib.delete_entry(request.form['entry_id']) 103 | return redirect(url_for('index')) 104 | 105 | 106 | @app.route('/tags') 107 | def show_tags(): 108 | return applib.render_tags() 109 | 110 | 111 | @app.route('/tagged/') 112 | def show_tagged(tag): 113 | return applib.render_tagged(tag, None) 114 | 115 | 116 | @app.route('/gallery/') 117 | def show_tagged_gallery(tag): 118 | return applib.render_tagged_gallery(tag) 119 | 120 | 121 | @app.route('/notag') 122 | def notag(): 123 | return applib.render_notags() 124 | 125 | 126 | @app.route('/search') 127 | def search(): 128 | return applib.render_search_form() 129 | 130 | 131 | @app.route('/found', methods=['GET']) 132 | def found(): 133 | return applib.render_search_results(request.args['terms']) 134 | 135 | 136 | @app.route('/help') 137 | def help(): 138 | return applib.render_help() 139 | 140 | 141 | @app.route('/help/') 142 | def help_entry(_id): 143 | return applib.render_entry(_id) 144 | 145 | 146 | @app.route('/help/edit/') 147 | def help_edit_id(_id): 148 | return applib.render_edit_form(_id) 149 | 150 | 151 | if __name__ == '__main__': 152 | app.run(debug=True, port=5001) 153 | --------------------------------------------------------------------------------