-
-
Save coleifer/d93d6c43e59698d149c0 to your computer and use it in GitHub Desktop.
Notes App version 3
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 import request | |
from flask_peewee.rest import Authentication | |
from flask_peewee.rest import RestAPI | |
from flask_peewee.rest import RestResource | |
from flask_peewee.utils import get_object_or_404 | |
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_urls(self): | |
urls = super(NoteResource, self).get_urls() | |
return ( | |
('/search/', self.search), | |
('/<pk>/details/', self.note_details), | |
) + urls | |
def search(self): | |
query = request.args.get('query') | |
notes = Note.search( | |
request.args.get('query') or '', | |
request.args.get('days')) | |
notes = self.process_query(notes) | |
return self.paginated_object_list(notes) | |
def note_details(self, pk): | |
note = get_object_or_404(self.get_query(), self.pk == pk) | |
return self.response({ | |
'content': note.unparse_content(), | |
'reminder': (note.reminder.strftime('%Y-%m-%dT%H:%M') | |
if note.reminder else None), | |
}) | |
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 playhouse.sqlite_ext import SqliteExtDatabase | |
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 = SqliteExtDatabase(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
import os | |
from app import app | |
from app import db | |
from models import FTSNote | |
from models import Note | |
from models import Task | |
from api import api | |
import views | |
api.setup() | |
if __name__ == '__main__': | |
db.create_tables([FTSNote, Note, Task]) | |
app.run(debug=bool(os.environ.get('DEBUG'))) |
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 playhouse.migrate import * | |
from app import db | |
from models import * | |
def run_migration(): | |
migrator = SqliteMigrator(db) | |
with db.atomic(): | |
migrate( | |
migrator.drop_column('note', 'reminder_task_created'), | |
) | |
FTSNote.create_table(True) | |
with db.atomic(): | |
for note in Note.select(): | |
FTSNote.store_note(note) | |
if __name__ == '__main__': | |
run_migration() |
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 | |
import re | |
from flask import Markup | |
from huey import crontab | |
from markdown import markdown | |
from micawber import parse_html | |
from peewee import * | |
from playhouse.sqlite_ext import FTSModel | |
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_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((True, line[2:].strip())) | |
elif line.startswith('@'): | |
tasks.append((False, line[1:].strip())) | |
else: | |
content.append(line) | |
return '\n'.join(content), tasks | |
def unparse_content(self): | |
content = [self.content] | |
for task in self.get_tasks(): | |
content.append('%s %s' % ('@!' if task.finished else '@', | |
task.title)) | |
return '\n'.join(line for line in content if line) | |
def save(self, *args, **kwargs): | |
# Split out the text content and any tasks. | |
self.content, tasks = self.parse_content_tasks() | |
# Update the timestamp. | |
self.timestamp = datetime.datetime.now() | |
# Save the note. | |
with db.atomic(): | |
ret = super(Note, self).save(*args, **kwargs) | |
# Store the tasks in the database. | |
if tasks: | |
Task.delete().where(Task.note == self).execute() | |
for idx, (finished, title) in enumerate(tasks): | |
Task.create( | |
note=self, | |
finished=finished, | |
title=title, | |
order=idx) | |
# Store the content for full-text search. | |
FTSNote.store_note(self) | |
return ret | |
@classmethod | |
def public(cls): | |
return (Note | |
.select() | |
.where(Note.status == Note.STATUS_VISIBLE) | |
.order_by(Note.timestamp.desc())) | |
@classmethod | |
def search(cls, search_term, days_to_search=None): | |
words = [word.strip() for word in search_term.split() if word] | |
if not words: | |
return Note.select().where(Note.id == 0) | |
else: | |
search = ' '.join(words) | |
query = (Note | |
.select(Note, FTSNote.rank().alias('score')) | |
.join(FTSNote, on=(Note.id == FTSNote.docid)) | |
.where( | |
(Note.status == Note.STATUS_VISIBLE) & | |
(FTSNote.match(search))) | |
.order_by(FTSNote.rank())) | |
if days_to_search: | |
today = datetime.date.today() | |
query = query.where( | |
Note.timestamp.between( | |
today - datetime.timedelta(days=int(days_to_search)), | |
datetime.datetime.now())) | |
return query | |
class FTSNote(FTSModel): | |
HTML_RE = re.compile('<.+?>') | |
content = TextField() | |
class Meta: | |
database = db | |
@classmethod | |
def store_note(cls, note): | |
content = FTSNote.get_search_content(note) | |
try: | |
FTSNote.get(FTSNote.note == note) | |
except FTSNote.DoesNotExist: | |
FTSNote.create(docid=note.id, content=content) | |
else: | |
(FTSNote | |
.update(content=content) | |
.where(FTSNote.docid == note.id) | |
.execute()) | |
@staticmethod | |
def get_search_content(note): | |
content = [FTSNote.HTML_RE.sub('', note.html())] | |
for task in note.get_tasks(): | |
content.append(FTSNote.HTML_RE.sub('', task.html())) | |
return '\n'.join(line for line in content if line) | |
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.periodic_task(crontab(minute='*/5')) | |
def send_note_reminders(): | |
query = (Note | |
.select() | |
.where( | |
(Note.status != Note.STATUS_DELETED) & | |
Note.reminder.is_null(False) & | |
(Note.reminder < datetime.datetime.now()) & | |
(Note.reminder_sent == False)) | |
.order_by(Note.reminder)) | |
for note in query: | |
app.logger.info( | |
'Sending reminder for Note(%s), reminder timestamp = %s.' % | |
(note.id, note.reminder.strftime('%Y-%m-%d %H:%M'))) | |
try: | |
mailer.send( | |
to=app.config['REMINDER_EMAIL'], | |
subj='[notes] reminder', | |
body=note.content) | |
except: | |
app.logger.info('Sending reminder failed for Note(%s).' % note.id) | |
else: | |
app.logger.info('Reminder for Note(%s) sent successfully.' % | |
note.id) | |
Note.update(reminder_sent=True).where(Note.id == note.id).execute() |
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
flask | |
peewee | |
micawber | |
beautifulsoup | |
markdown | |
flask-peewee | |
huey | |
redis |
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.cancelEditBtn = this.form.find('a#cancel-edit'); | |
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(); | |
}); | |
this.cancelEditBtn.on('click', function(e) { | |
e.preventDefault(); | |
self.resetForm(); | |
}); | |
} | |
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 = {}, | |
self = this, | |
url = search ? '/api/note/search/' : '/api/note/'; | |
this.container.empty(); | |
if (page) requestData['page'] = page; | |
if (search) requestData['query'] = search; | |
this.makeRequest(url, '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('a.edit-note').on('click', function(e) { | |
e.preventDefault(); | |
self.editNote($(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()}, | |
isEdit = this.cancelEditBtn.is(':visible'); | |
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.resetForm(); | |
if (isEdit) { | |
$('div#note-panel-' + data.id).remove(); | |
} | |
self.addNoteToList(data.rendered); | |
}); | |
} | |
Editor.prototype.resetForm = function() { | |
this.form.attr('action', '/api/note/'); | |
this.cancelEditBtn.hide(); | |
this.content.val('').focus(); | |
this.resetReminder(); | |
} | |
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.editNote = function(noteLink) { | |
var self = this, | |
noteId = noteLink.data('note'), | |
panel = noteLink.parents('.panel'), | |
detailsUrl = noteLink.attr('href') + 'details/'; | |
self.makeRequest(detailsUrl, 'GET', {}, function(response) { | |
self.content.val(response.content); | |
if (response.reminder) { | |
self.reminder.show(); | |
self.reminder.val(response.reminder); | |
} else { | |
self.reminder.hide(); | |
} | |
self.form.attr('action', noteLink.attr('href')); | |
self.cancelEditBtn.show(); | |
}); | |
} | |
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> | |
<a class="btn btn-danger btn-xs" href="#" id="cancel-edit" style="display:none;"> | |
<span class="glyphicon glyphicon-remove"></span> Cancel edit | |
</a> | |
<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 %}" id="note-panel-{{ note.id }}"> | |
<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> | |
<a class="btn btn-primary btn-xs edit-note pull-right" data-note="{{ note.id }}" href="/api/note/{{ note.id }}/">e</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') |
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
commit fae7fca7174531a3dc6b8154d1fd5f8168725fa7 | |
Author: Charles Leifer <coleifer@gmail.com> | |
Date: Mon Oct 6 21:34:41 2014 -0500 | |
Changes | |
diff --git a/api.py b/api.py | |
index 0fed202..c6e9419 100644 | |
--- a/api.py | |
+++ b/api.py | |
@@ -1,7 +1,9 @@ | |
from flask import render_template | |
+from flask import request | |
from flask_peewee.rest import Authentication | |
from flask_peewee.rest import RestAPI | |
from flask_peewee.rest import RestResource | |
+from flask_peewee.utils import get_object_or_404 | |
from app import app | |
from models import Note | |
@@ -16,6 +18,29 @@ class NoteResource(RestResource): | |
fields = ('id', 'content', 'timestamp', 'status') | |
paginate_by = 30 | |
+ def get_urls(self): | |
+ urls = super(NoteResource, self).get_urls() | |
+ return ( | |
+ ('/search/', self.search), | |
+ ('/<pk>/details/', self.note_details), | |
+ ) + urls | |
+ | |
+ def search(self): | |
+ query = request.args.get('query') | |
+ notes = Note.search( | |
+ request.args.get('query') or '', | |
+ request.args.get('days')) | |
+ notes = self.process_query(notes) | |
+ return self.paginated_object_list(notes) | |
+ | |
+ def note_details(self, pk): | |
+ note = get_object_or_404(self.get_query(), self.pk == pk) | |
+ return self.response({ | |
+ 'content': note.unparse_content(), | |
+ 'reminder': (note.reminder.strftime('%Y-%m-%dT%H:%M') | |
+ if note.reminder else None), | |
+ }) | |
+ | |
def get_query(self): | |
return Note.public() | |
diff --git a/app.py b/app.py | |
index 3eaf3f5..bec602b 100644 | |
--- a/app.py | |
+++ b/app.py | |
@@ -3,7 +3,7 @@ import os | |
from flask import Flask | |
from huey import RedisHuey | |
from micawber import bootstrap_basic | |
-from peewee import SqliteDatabase | |
+from playhouse.sqlite_ext import SqliteExtDatabase | |
APP_ROOT = os.path.dirname(os.path.realpath(__file__)) | |
DATABASE = os.path.join(APP_ROOT, 'notes.db') | |
@@ -12,6 +12,6 @@ DEBUG = False | |
app = Flask(__name__) | |
app.config.from_object(__name__) | |
-db = SqliteDatabase(app.config['DATABASE'], threadlocals=True) | |
+db = SqliteExtDatabase(app.config['DATABASE'], threadlocals=True) | |
huey = RedisHuey() | |
oembed = bootstrap_basic() | |
diff --git a/main.py b/main.py | |
index 546f95e..369522c 100644 | |
--- a/main.py | |
+++ b/main.py | |
@@ -1,4 +1,8 @@ | |
+import os | |
+ | |
from app import app | |
+from app import db | |
+from models import FTSNote | |
from models import Note | |
from models import Task | |
from api import api | |
@@ -7,6 +11,5 @@ import views | |
api.setup() | |
if __name__ == '__main__': | |
- Note.create_table(True) | |
- Task.create_table(True) | |
- app.run() | |
+ db.create_tables([FTSNote, Note, Task], safe=True) | |
+ app.run(debug=bool(os.environ.get('DEBUG'))) | |
diff --git a/migrate_notes.py b/migrate_notes.py | |
new file mode 100644 | |
index 0000000..d470cb2 | |
--- /dev/null | |
+++ b/migrate_notes.py | |
@@ -0,0 +1,21 @@ | |
+from playhouse.migrate import * | |
+ | |
+from app import db | |
+from models import * | |
+ | |
+ | |
+def run_migration(): | |
+ migrator = SqliteMigrator(db) | |
+ with db.transaction(): | |
+ migrate( | |
+ migrator.drop_column('note', 'reminder_task_created'), | |
+ ) | |
+ FTSNote.create_table(True) | |
+ | |
+ with db.transaction(): | |
+ for note in Note.select(): | |
+ FTSNote.store_note(note) | |
+ | |
+ | |
+if __name__ == '__main__': | |
+ run_migration() | |
diff --git a/models.py b/models.py | |
index febf075..ffcbc2c 100644 | |
--- a/models.py | |
+++ b/models.py | |
@@ -1,9 +1,12 @@ | |
import datetime | |
+import re | |
from flask import Markup | |
+from huey import crontab | |
from markdown import markdown | |
from micawber import parse_html | |
from peewee import * | |
+from playhouse.sqlite_ext import FTSModel | |
from app import db, huey, oembed | |
@@ -24,7 +27,6 @@ class Note(Model): | |
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: | |
@@ -45,31 +47,44 @@ class Note(Model): | |
content = [] | |
tasks = [] | |
for line in self.content.splitlines(): | |
- if line.startswith('@'): | |
- tasks.append(line[1:].strip()) | |
+ if line.startswith('@!'): | |
+ tasks.append((True, line[2:].strip())) | |
+ elif line.startswith('@'): | |
+ tasks.append((False, line[1:].strip())) | |
else: | |
content.append(line) | |
return '\n'.join(content), tasks | |
+ def unparse_content(self): | |
+ content = [self.content] | |
+ for task in self.get_tasks(): | |
+ content.append('%s %s' % ('@!' if task.finished else '@', | |
+ task.title)) | |
+ return '\n'.join(line for line in content if line) | |
+ | |
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 | |
+ # Update the timestamp. | |
+ self.timestamp = datetime.datetime.now() | |
# 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) | |
+ # Store the tasks in the database. | |
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) | |
+ for idx, (finished, title) in enumerate(tasks): | |
+ Task.create( | |
+ note=self, | |
+ finished=finished, | |
+ title=title, | |
+ order=idx) | |
+ | |
+ # Store the content for full-text search. | |
+ FTSNote.store_note(self) | |
+ | |
return ret | |
@classmethod | |
@@ -79,6 +94,60 @@ class Note(Model): | |
.where(Note.status == Note.STATUS_VISIBLE) | |
.order_by(Note.timestamp.desc())) | |
+ @classmethod | |
+ def search(cls, search_term, days_to_search=None): | |
+ words = [word.strip() for word in search_term.split() if word] | |
+ if not words: | |
+ return Note.select().where(Note.id == 0) | |
+ else: | |
+ search = ' '.join(words) | |
+ | |
+ query = (Note | |
+ .select(Note, FTSNote, FTSNote.rank().alias('score')) | |
+ .join(FTSNote) | |
+ .where( | |
+ (Note.status == Note.STATUS_VISIBLE) & | |
+ (FTSNote.match(search))) | |
+ .order_by(SQL('score').desc(), Note.timestamp.desc())) | |
+ | |
+ if days_to_search: | |
+ today = datetime.date.today() | |
+ query = query.where( | |
+ Note.timestamp.between( | |
+ today - datetime.timedelta(days=int(days_to_search)), | |
+ datetime.datetime.now())) | |
+ | |
+ return query | |
+ | |
+ | |
+class FTSNote(FTSModel): | |
+ HTML_RE = re.compile('<.+?>') | |
+ | |
+ note = ForeignKeyField(Note, primary_key=True) | |
+ content = TextField() | |
+ | |
+ class Meta: | |
+ database = db | |
+ | |
+ @classmethod | |
+ def store_note(cls, note): | |
+ try: | |
+ fts_note = FTSNote.get(FTSNote.note == note) | |
+ except FTSNote.DoesNotExist: | |
+ fts_note = FTSNote(note=note) | |
+ force_insert = True | |
+ else: | |
+ force_insert = False | |
+ fts_note.content = FTSNote.get_search_content(note) | |
+ fts_note.save(force_insert=force_insert) | |
+ | |
+ @staticmethod | |
+ def get_search_content(note): | |
+ content = [FTSNote.HTML_RE.sub('', note.html())] | |
+ for task in note.get_tasks(): | |
+ content.append(FTSNote.HTML_RE.sub('', task.html())) | |
+ return '\n'.join(line for line in content if line) | |
+ | |
class Task(Model): | |
note = ForeignKeyField(Note, related_name='tasks') | |
@@ -93,21 +162,21 @@ class Task(Model): | |
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 | |
+@huey.periodic_task(crontab(minute='*/5')) | |
+def send_note_reminders(): | |
+ query = (Note | |
+ .select() | |
+ .where( | |
+ (Note.status != Note.STATUS_DELETED) & | |
+ ~(Note.reminder >> None) & # reminder IS NOT NULL. | |
+ (Note.reminder < datetime.datetime.now()) & | |
+ (Note.reminder_sent == False)) | |
+ .order_by(Note.reminder)) | |
+ | |
+ for note in query: | |
+ app.logger.info( | |
+ 'Sending reminder for Note(%s), reminder timestamp = %s.' % | |
+ (note.id, note.reminder.strftime('%Y-%m-%d %H:%M'))) | |
try: | |
mailer.send( | |
@@ -115,8 +184,8 @@ def send_note_reminder(note_id): | |
subj='[notes] reminder', | |
body=note.content) | |
except: | |
- app.logger.info('Sending reminder failed for note id=%s.', note_id) | |
- raise | |
+ app.logger.info('Sending reminder failed for Note(%s).' % note.id) | |
else: | |
- note.reminder_sent = True | |
- note.save() | |
+ app.logger.info('Reminder for Note(%s) sent successfully.' % | |
+ note.id) | |
+ Note.update(reminder_sent=True).where(Note.id == note.id).execute() | |
diff --git a/requirements.txt b/requirements.txt | |
index a747f14..a310559 100644 | |
--- a/requirements.txt | |
+++ b/requirements.txt | |
@@ -3,3 +3,6 @@ peewee | |
micawber | |
beautifulsoup | |
markdown | |
+flask-peewee | |
+huey | |
+redis | |
diff --git a/static/js/notes.js b/static/js/notes.js | |
index 7d74b71..5aea866 100644 | |
--- a/static/js/notes.js | |
+++ b/static/js/notes.js | |
@@ -10,6 +10,7 @@ Notes = window.Notes || {}; | |
this.form = $('form#note-form'); | |
this.content = this.form.find('textarea#content'); | |
this.reminder = this.form.find('input[name="reminder"]'); | |
+ this.cancelEditBtn = this.form.find('a#cancel-edit'); | |
this.container = $('ul.notes'); | |
// Bind handlers. | |
@@ -43,6 +44,10 @@ Notes = window.Notes || {}; | |
e.preventDefault(); | |
self.addNote(); | |
}); | |
+ this.cancelEditBtn.on('click', function(e) { | |
+ e.preventDefault(); | |
+ self.resetForm(); | |
+ }); | |
} | |
Editor.prototype.bindArchiveDelete = function() { | |
@@ -79,15 +84,19 @@ Notes = window.Notes || {}; | |
/* Loading and saving notes. */ | |
Editor.prototype.getList = function(page, search) { | |
- var requestData = {}; | |
- var self = this; | |
+ var requestData = {}, | |
+ self = this, | |
+ url = search ? '/api/note/search/' : '/api/note/'; | |
this.container.empty(); | |
if (page) requestData['page'] = page; | |
- if (search) requestData['content__ilike'] = '%' + search + '%'; | |
+ if (search) requestData['query'] = search; | |
- this.makeRequest('/api/note/', 'GET', requestData, function(data) { | |
+ this.makeRequest(url, 'GET', requestData, function(data) { | |
data.objects.reverse(); | |
$.each(data.objects, function(idx, note) { | |
self.addNoteToList(note.rendered); | |
@@ -106,6 +115,10 @@ Notes = window.Notes || {}; | |
e.preventDefault(); | |
self.changeNote($(this)); | |
}); | |
+ listElem.find('a.edit-note').on('click', function(e) { | |
+ e.preventDefault(); | |
+ self.editNote($(this)); | |
+ }); | |
listElem.find('input[type="checkbox"]').on('change', function(e) { | |
self.updateTask($(this)); | |
}); | |
@@ -143,7 +156,9 @@ Notes = window.Notes || {}; | |
return | |
} | |
- var note = {'content': this.content.val()}; | |
+ var note = {'content': this.content.val()}, | |
+ isEdit = this.cancelEditBtn.is(':visible'); | |
+ | |
if (this.reminder.is(':visible') && this.reminder.val()) { | |
// Fix any bizarre date formats. | |
var dateTime = this.reminder.val().replace('T', ' ').split('Z')[0]; | |
@@ -152,15 +167,25 @@ Notes = window.Notes || {}; | |
} | |
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.resetForm(); | |
+ if (isEdit) { | |
+ $('div#note-panel-' + data.id).remove(); | |
+ } | |
self.addNoteToList(data.rendered); | |
}); | |
} | |
+ Editor.prototype.resetForm = function() { | |
+ this.form.attr('action', '/api/note/'); | |
+ this.cancelEditBtn.hide(); | |
+ this.content.val('').focus(); | |
+ this.resetReminder(); | |
+ } | |
+ | |
Editor.prototype.resetReminder = function() { | |
var now = new Date(); | |
var pad = function(v) {return ('0' + v).slice(-2);} | |
@@ -172,6 +197,25 @@ Notes = window.Notes || {}; | |
); | |
} | |
+ Editor.prototype.editNote = function(noteLink) { | |
+ var self = this, | |
+ noteId = noteLink.data('note'), | |
+ panel = noteLink.parents('.panel'), | |
+ detailsUrl = noteLink.attr('href') + 'details/'; | |
+ | |
+ self.makeRequest(detailsUrl, 'GET', {}, function(response) { | |
+ self.content.val(response.content); | |
+ if (response.reminder) { | |
+ self.reminder.show(); | |
+ self.reminder.val(response.reminder); | |
+ } else { | |
+ self.reminder.hide(); | |
+ } | |
+ self.form.attr('action', noteLink.attr('href')); | |
+ self.cancelEditBtn.show(); | |
+ }); | |
+ } | |
+ | |
Editor.prototype.changeNote = function(noteLink) { | |
var noteData = {}; | |
var panel = noteLink.parents('.panel'); | |
diff --git a/templates/homepage.html b/templates/homepage.html | |
index eb3b001..8684166 100644 | |
--- a/templates/homepage.html | |
+++ b/templates/homepage.html | |
@@ -31,6 +31,9 @@ | |
<button class="btn btn-primary btn-xs" type="submit"> | |
<span class="glyphicon glyphicon-plus"></span> Add Note | |
</button> | |
+ <a class="btn btn-danger btn-xs" href="#" id="cancel-edit" style="display:none;"> | |
+ <span class="glyphicon glyphicon-remove"></span> Cancel edit | |
+ </a> | |
<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> | |
diff --git a/templates/note.html b/templates/note.html | |
index 8706f77..5e61324 100644 | |
--- a/templates/note.html | |
+++ b/templates/note.html | |
@@ -1,8 +1,9 @@ | |
<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 panel-{% if note.reminder %}warning{% else %}primary{% endif %}" id="note-panel-{{ note.id }}"> | |
<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> | |
+ <a class="btn btn-primary btn-xs edit-note pull-right" data-note="{{ note.id }}" href="/api/note/{{ note.id }}/">e</a> | |
{{ note.timestamp.strftime('%b %d, %Y - %I:%M%p').lower() }} | |
</div> | |
<div class="panel-body"> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment