-
-
Save coleifer/69ec9d09b2efe05527eb to your computer and use it in GitHub Desktop.
Note-taking app
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from app import app | |
from models import Note | |
from models import Task | |
from api import api | |
import views | |
api.setup() | |
if __name__ == '__main__': | |
Note.create_table(True) | |
Task.create_table(True) | |
app.run() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from flask import render_template | |
from app import app | |
@app.route('/') | |
def homepage(): | |
return render_template('homepage.html') |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment