Note-taking app
from flask import render_template | |
from flask_peewee.rest import Authentication | |
from flask_peewee.rest import RestAPI | |
from flask_peewee.rest import RestResource | |
from app import app | |
from models import Note | |
from models import Task | |
# Allow GET and POST requests without requiring authentication. | |
auth = Authentication(protected_methods=['PUT', 'DELETE']) | |
api = RestAPI(app, default_auth=auth) | |
class NoteResource(RestResource): | |
fields = ('id', 'content', 'timestamp', 'status') | |
paginate_by = 30 | |
def get_query(self): | |
return Note.public() | |
def prepare_data(self, obj, data): | |
data['rendered'] = render_template('note.html', note=obj) | |
return data | |
class TaskResource(RestResource): | |
paginate_by = 50 | |
api.register(Note, NoteResource) | |
api.register(Task, TaskResource) |
import os | |
from flask import Flask | |
from huey import RedisHuey | |
from micawber import bootstrap_basic | |
from peewee import SqliteDatabase | |
APP_ROOT = os.path.dirname(os.path.realpath(__file__)) | |
DATABASE = os.path.join(APP_ROOT, 'notes.db') | |
DEBUG = False | |
app = Flask(__name__) | |
app.config.from_object(__name__) | |
db = SqliteDatabase(app.config['DATABASE'], pragmas=[('journal_mode', 'wal')]) | |
huey = RedisHuey() | |
oembed = bootstrap_basic() |
import datetime | |
from flask import Markup | |
from markdown import markdown | |
from micawber import parse_html | |
from peewee import * | |
from app import db, huey, oembed | |
def rich_content(content, maxwidth=300): | |
html = parse_html( | |
markdown(content), | |
oembed, | |
maxwidth=maxwidth, | |
urlize_all=True) | |
return Markup(html) | |
class Note(Model): | |
STATUS_VISIBLE = 1 | |
STATUS_ARCHIVED = 2 | |
STATUS_DELETED = 9 | |
content = TextField() | |
timestamp = DateTimeField(default=datetime.datetime.now) | |
status = IntegerField(default=STATUS_VISIBLE, index=True) | |
reminder = DateTimeField(null=True) | |
reminder_task_created = BooleanField(default=False) | |
reminder_sent = BooleanField(default=False) | |
class Meta: | |
database = db | |
def html(self): | |
return rich_content(self.content) | |
def is_finished(self): | |
if self.tasks.exists(): | |
return not self.tasks.where(Task.finished == False).exists() | |
def get_tasks(self): | |
return self.tasks.order_by(Task.order) | |
def parse_content_tasks(self): | |
# Split the list of tasks from the surrounding content, returning both. | |
content = [] | |
tasks = [] | |
for line in self.content.splitlines(): | |
if line.startswith('@'): | |
tasks.append(line[1:].strip()) | |
else: | |
content.append(line) | |
return '\n'.join(content), tasks | |
def save(self, *args, **kwargs): | |
# Split out the text content and any tasks. | |
self.content, tasks = self.parse_content_tasks() | |
# Determine if we need to set a reminder. | |
set_reminder = self.reminder and not self.reminder_task_created | |
self.reminder_task_created = True | |
# Save the note. | |
ret = super(Note, self).save(*args, **kwargs) | |
if set_reminder: | |
# Set a reminder to go off by enqueueing a task with huey. | |
send_note_reminder.schedule(args=(self.id,), eta=self.reminder) | |
if tasks: | |
# Store the tasks. | |
Task.delete().where(Task.note == self).execute() | |
for idx, title in enumerate(tasks): | |
Task.create(note=self, title=title, order=idx) | |
return ret | |
@classmethod | |
def public(cls): | |
return (Note | |
.select() | |
.where(Note.status == Note.STATUS_VISIBLE) | |
.order_by(Note.timestamp.desc())) | |
class Task(Model): | |
note = ForeignKeyField(Note, related_name='tasks') | |
title = CharField(max_length=255) | |
order = IntegerField(default=0) | |
finished = BooleanField(default=False) | |
class Meta: | |
database = db | |
def html(self): | |
return rich_content(self.title) | |
@huey.task(retries=3, retry_delay=60) | |
def send_note_reminder(note_id): | |
with database.transaction(): | |
try: | |
note = Note.get(Note.id == note_id) | |
except Note.DoesNotExist: | |
app.logger.info( | |
'Attempting to send reminder for note id=%s, but note ' | |
'was not found in the database.', note_id) | |
return | |
if note.status == Note.STATUS_DELETED: | |
app.logger.info('Attempting to send a reminder for a deleted ' | |
'note id=%s. Skipping.', note_id) | |
return | |
try: | |
mailer.send( | |
to=app.config['REMINDER_EMAIL'], | |
subj='[notes] reminder', | |
body=note.content) | |
except: | |
app.logger.info('Sending reminder failed for note id=%s.', note_id) | |
raise | |
else: | |
note.reminder_sent = True | |
note.save() |
Notes = window.Notes || {}; | |
(function(exports, $) { | |
function Editor() { | |
// Store state, such as next and previous page. | |
this.state = {}; | |
this.endpoint = '/api/note/'; | |
// DOM nodes. | |
this.form = $('form#note-form'); | |
this.content = this.form.find('textarea#content'); | |
this.reminder = this.form.find('input[name="reminder"]'); | |
this.container = $('ul.notes'); | |
// Bind handlers. | |
this.initialize(); | |
} | |
/* Initialization and event handler binding. */ | |
Editor.prototype.initialize = function() { | |
this.container.masonry(); | |
this.setupForm(); | |
this.bindArchiveDelete(); | |
this.bindPagination(); | |
this.bindSearchHandler(); | |
var page = 1; | |
if (window.location.hash) { | |
page = parseInt(window.location.hash.replace('#', '')); | |
} | |
this.getList(page || 1); | |
} | |
Editor.prototype.setupForm = function() { | |
var self = this; | |
this.content.on('keydown', function(e) { | |
if (e.ctrlKey && e.keyCode == 13) { | |
self.form.submit(); | |
} | |
}); | |
this.content.focus(); | |
this.addMarkdownHelpers(); | |
this.form.submit(function(e) { | |
e.preventDefault(); | |
self.addNote(); | |
}); | |
} | |
Editor.prototype.bindArchiveDelete = function() { | |
$('a.delete-note,a.archive-note').on('click', this.changeNote); | |
} | |
Editor.prototype.bindPagination = function() { | |
var self = this; | |
var makeHandler = function (key) { | |
return function(e) { | |
e.preventDefault(); | |
if ($(this).parent().hasClass('disabled')) return; | |
if (key == 'next') { | |
page = self.state['page'] + 1; | |
} else { | |
page = self.state['page'] - 1; | |
} | |
self.getList(page, self.state['search']); | |
} | |
} | |
$('ul.pager li.next a').on('click', makeHandler('next')); | |
$('ul.pager li.previous a').on('click', makeHandler('previous')); | |
} | |
Editor.prototype.bindSearchHandler = function() { | |
var self = this; | |
$('form#search-form').on('submit', function(e) { | |
e.preventDefault(); | |
var query = $('input[name="q"]').val(); | |
self.getList(1, query); | |
}); | |
} | |
/* Loading and saving notes. */ | |
Editor.prototype.getList = function(page, search) { | |
var requestData = {}; | |
var self = this; | |
this.container.empty(); | |
if (page) requestData['page'] = page; | |
if (search) requestData['content__ilike'] = '%' + search + '%'; | |
this.makeRequest('/api/note/', 'GET', requestData, function(data) { | |
data.objects.reverse(); | |
$.each(data.objects, function(idx, note) { | |
self.addNoteToList(note.rendered); | |
}); | |
imagesLoaded(self.container, function() { | |
self.container.masonry('layout'); | |
}); | |
self.updatePagination(data); | |
}); | |
} | |
Editor.prototype.addNoteToList = function(html) { | |
var self = this; | |
listElem = $(html); | |
listElem.find('a.delete-note,a.archive-note').on('click', function(e) { | |
e.preventDefault(); | |
self.changeNote($(this)); | |
}); | |
listElem.find('input[type="checkbox"]').on('change', function(e) { | |
self.updateTask($(this)); | |
}); | |
listElem.find('img').addClass('img-responsive'); | |
this.container.prepend(listElem); | |
this.container.masonry('prepended', listElem); | |
} | |
Editor.prototype.updatePagination = function(response) { | |
var meta = response.meta; | |
window.location.hash = meta.page; | |
this.state = {'page': meta.page}; | |
this.state['hasNext'] = meta.next != ''; | |
this.state['hasPrevious'] = meta.previous != ''; | |
this.state['search'] = $('input[name="q"]').val(); | |
var next = $('ul.pager li.next'); | |
if (this.state.hasNext) { | |
next.removeClass('disabled'); | |
} else { | |
next.addClass('disabled'); | |
} | |
var previous = $('ul.pager li.previous'); | |
if (this.state.hasPrevious) { | |
previous.removeClass('disabled'); | |
} else { | |
previous.addClass('disabled'); | |
} | |
} | |
Editor.prototype.addNote = function() { | |
if (!this.content.val()) { | |
this.content.css('color', '#dd1111'); | |
return | |
} | |
var note = {'content': this.content.val()}; | |
if (this.reminder.is(':visible') && this.reminder.val()) { | |
// Fix any bizarre date formats. | |
var dateTime = this.reminder.val().replace('T', ' ').split('Z')[0]; | |
if (dateTime.split(':').length == 2) { | |
dateTime = dateTime + ':00'; | |
} | |
note['reminder'] = dateTime; | |
} | |
var self = this; | |
this.content.css('color', '#464545'); | |
this.makeRequest(this.form.attr('action'), 'POST', note, function(data) { | |
self.content.val('').focus(); | |
self.resetReminder(); | |
self.addNoteToList(data.rendered); | |
}); | |
} | |
Editor.prototype.resetReminder = function() { | |
var now = new Date(); | |
var pad = function(v) {return ('0' + v).slice(-2);} | |
this.reminder.val( | |
(now.getYear() + 1900) + '-' + | |
pad(now.getMonth() + 1) + '-' + | |
pad(now.getDate()) + 'T' + | |
pad(now.getHours()) + ':' + '00' | |
); | |
} | |
Editor.prototype.changeNote = function(noteLink) { | |
var noteData = {}; | |
var panel = noteLink.parents('.panel'); | |
var self = this; | |
panel.removeClass('panel-primary').addClass('panel-danger'); | |
if (noteLink.hasClass('delete-note')) { | |
noteData['status'] = 9 | |
} else { | |
noteData['status'] = 2 | |
} | |
this.makeRequest(noteLink.attr('href'), 'POST', noteData, function(data) { | |
panel.remove(); | |
self.container.masonry(); | |
}); | |
} | |
Editor.prototype.updateTask = function(elem) { | |
/* Read checkbox state. */ | |
var isFinished = elem.is(':checked'); | |
var taskId = elem.val(); | |
var url = '/api/task/' + taskId + '/'; | |
this.makeRequest(url, 'POST', {'finished': isFinished}, function(data) { | |
var label = elem.parent(); | |
var color = label.css('color'); | |
label.css('color', '#119911'); | |
window.setTimeout(function() {label.css('color', color);}, 2000); | |
}); | |
} | |
/* Make API request. */ | |
Editor.prototype.makeRequest = function(url, method, data, callback) { | |
if (method == 'GET') { | |
$.get(url, data, callback); | |
} else { | |
$.ajax(url, { | |
'contentType': 'application/json; charset=UTF-8', | |
'data': JSON.stringify(data), | |
'dataType': 'json', | |
'success': callback, | |
'type': method | |
}); | |
} | |
} | |
/* Markdown and other editor features. */ | |
Editor.prototype.addMarkdownHelpers = function() { | |
var self = this; | |
this.addHelper('indent-left', function(line) {return ' ' + line;}); | |
this.addHelper('indent-right', function(line) {return line.substring(4);}); | |
this.addHelper('list', function(line) {return '* ' + line;}); | |
this.addHelper('bold', function(line) {return '**' + line + '**';}); | |
this.addHelper('italic', function(line) {return '*' + line + '*';}); | |
this.addHelper('font', null, function() {self.focus().select();}); | |
this.addHelper('time', null, function() { | |
self.reminder.toggle(); | |
if (self.reminder.is(':visible')) { | |
self.resetReminder(); | |
} else { | |
self.reminder.val(''); | |
} | |
}); | |
} | |
Editor.prototype.addHelper = function(iconClass, lineHandler, callBack) { | |
var link = $('<a>', {'class': 'btn btn-xs'}), | |
icon = $('<span>', {'class': 'glyphicon glyphicon-' + iconClass}), | |
self = this; | |
if (!callBack) { | |
callBack = function() { | |
self.modifySelection(lineHandler); | |
} | |
} | |
link.on('click', function(e) { | |
e.preventDefault(); | |
callBack(); | |
}); | |
link.append(icon); | |
this.content.before(link); | |
} | |
Editor.prototype.modifySelection = function(lineHandler) { | |
var selection = this.getSelectedText(); | |
if (!selection) return; | |
var lines = selection.split('\n'), | |
result = []; | |
for (var i = 0; i < lines.length; i++) { | |
result.push(lineHandler(lines[i])); | |
} | |
this.content.val( | |
this.content.val().split(selection).join(result.join('\n')) | |
); | |
} | |
Editor.prototype.getSelectedText = function() { | |
var textAreaDOM = this.content[0]; | |
return this.content.val().substring( | |
textAreaDOM.selectionStart, | |
textAreaDOM.selectionEnd); | |
} | |
exports.Editor = Editor; | |
})(Notes, jQuery); |
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="utf-8"> | |
<meta http-equiv="X-UA-Compatible" content="IE=edge"> | |
<meta name="viewport" content="width=device-width, initial-scale=1"> | |
<title>Notes</title> | |
<link href="{{ url_for('static', filename='css/bootstrap.min.css') }}" rel="stylesheet"> | |
<script src="{{ url_for('static', filename='js/jquery-1.11.0.min.js') }}"></script> | |
<script src="{{ url_for('static', filename='js/bootstrap.min.js') }}"></script> | |
<script src="{{ url_for('static', filename='js/imagesloaded.pkgd.min.js') }}"></script> | |
<script src="{{ url_for('static', filename='js/masonry.pkgd.min.js') }}"></script> | |
<script src="{{ url_for('static', filename='js/notes.js') }}"></script> | |
<script type="text/javascript"> | |
$(function() { | |
new Notes.Editor(); | |
}); | |
</script> | |
</head> | |
<body> | |
<div class="container content"> | |
<div class="page-header"> | |
<form class="form-inline pull-right" id="search-form"> | |
<div class="form-group"> | |
<input class="form-control" name="q" placeholder="search" type="text" /> | |
</div> | |
</form> | |
<h1>Notes</h1> | |
</div> | |
<form action="/api/note/" class="form" id="note-form" method="post"> | |
<button class="btn btn-primary btn-xs" type="submit"> | |
<span class="glyphicon glyphicon-plus"></span> Add Note | |
</button> | |
<textarea class="form-control" id="content" name="content"></textarea> | |
<input class="input-sm" name="reminder" step="600" style="display:none;" type="datetime-local" value="" /> | |
</form> | |
<ul class="list-unstyled notes row" style="margin-top: 30px;"></ul> | |
<div style="clear:both;"></div> | |
<ul class="pager"> | |
<li class="previous"><a href="#">« Previous</a></li> | |
<li class="next"><a href="#">Next »</a></li> | |
</ul> | |
</div> | |
</body> | |
</html> |
<li class="note col-xs-12 col-sm-6 col-lg-4"> | |
<div class="panel panel-{% if note.reminder %}warning{% else %}primary{% endif %}"> | |
<div class="panel-heading"> | |
<a class="btn btn-danger btn-xs delete-note pull-right" data-note="{{ note.id }}" href="/api/note/{{ note.id }}/">×</a> | |
<a class="btn btn-info btn-xs archive-note pull-right" data-note="{{ note.id }}" href="/api/note/{{ note.id }}/">a</a> | |
{{ note.timestamp.strftime('%b %d, %Y - %I:%M%p').lower() }} | |
</div> | |
<div class="panel-body"> | |
{{ note.html() }} | |
{% for task in note.get_tasks() %} | |
<div class="checkbox"> | |
<label> | |
<input id="task-{{ task.id }}" {% if task.finished %}checked="checked" {% endif %}name="task" type="checkbox" value="{{ task.id }}"> | |
{{ task.html() }} | |
</label> | |
</div> | |
{% endfor %} | |
</div> | |
{% if note.reminder %} | |
<div class="panel-footer"> | |
<span class="glyphicon glyphicon-time"></span> | |
{{ note.reminder.strftime('%m/%d/%Y %I:%M%p') }} | |
</div> | |
{% endif %} | |
</div> | |
</li> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment