Skip to content

Commit

Permalink
Added auto save (as draft) feature to the editor. Closes #66.
Browse files Browse the repository at this point in the history
When a user starts editing a page, up to every five seconds the content
will we send as draft to the server. The server stores the draft (for
anonymous users only in the session). Is the page saved in the process,
the draft is discarded. If the page is not saved, the user will be
prompted if the editing of the drat should be continued or the draft
should be discarded.
  • Loading branch information
redimp committed Aug 2, 2024
1 parent 6ef7b5c commit 0b23141
Show file tree
Hide file tree
Showing 6 changed files with 235 additions and 26 deletions.
24 changes: 24 additions & 0 deletions otterwiki/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env python

from otterwiki.server import db

__all__ = ['Preferences']

class Preferences(db.Model):
name = db.Column(db.String(256), primary_key=True)
value = db.Column(db.String(256))

def __str__(self):
return '{}: {}'.format(self.name, self.value)

class Drafts(db.Model):
id = db.Column(db.Integer, primary_key=True)
pagepath = db.Column(db.String(2048), index=True)
revision = db.Column(db.String(64))
author_email = db.Column(db.String(256))
content = db.Column(db.Text)
cursor_line = db.Column(db.Integer)
cursor_ch = db.Column(db.Integer)
datetime = db.Column(db.DateTime())


7 changes: 1 addition & 6 deletions otterwiki/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,7 @@
#
# app.config from db preferences
#
class Preferences(db.Model):
name = db.Column(db.String(256), primary_key=True)
value = db.Column(db.String(256))

def __str__(self):
return '{}: {}'.format(self.name, self.value)
from otterwiki.models import *

mail = None

Expand Down
42 changes: 42 additions & 0 deletions otterwiki/templates/draft.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{#- vim: set et ts=8 sts=4 sw=4 ai: -#}
{% extends "page.html" %}

{% block content %}
<div class="card">
<h2 class="card-title">Continue editing draft?</h2>

<div class="mt-5 mb-15">
For <strong>{{pagepath}}</strong> exists a draft version saved <span title="{{draft_datetime|format_datetime}}">{{draft_datetime|format_datetime("deltanow")}} ago</span>.
</div>

<div class="d-flex">
<form action="{{ url_for("edit", path=pagepath) }}" method="POST" class="mr-15">
<input type="hidden" name="draft" value="edit" />
<input class="btn btn-primary" type="submit" name="submit" value="Continue editing draft" />
</form>
<form action="{{ url_for("edit", path=pagepath) }}" method="POST">
<input type="hidden" name="draft" value="discard" />
<input class="btn" type="submit" name="submit" value="Discard draft" />
</form>
</div>
</div>
<table class="table table-inner-bordered">
<thead>
<tr>
<td><em>Draft</em></td>
{% if content %}
<td>Stored Version</td>
{% endif %}
</tr>
</thead>
<tbody>
<tr class="align-top">
<td>{{draft_content|safe}}</td>
{% if content %}
<td>{{content|safe}}</td>
{% endif %}
</tr>
</tbody>
</table>
{% endblock %}

67 changes: 50 additions & 17 deletions otterwiki/templates/editor.html
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ <h5 class="sidebar-title"><a class="sidebar-title-link" href="{{ url_for('attach
<script src="{{ url_for("static", filename="js/cm-searchcursor.js") | debug_unixtime }}" type="text/javascript" charset="utf-8"></script>
<script src="{{ url_for("static", filename="js/cm-dialog.js") | debug_unixtime }}" type="text/javascript" charset="utf-8"></script>
<script type="text/javascript">
var cm_editor = CodeMirror.fromTextArea(document.getElementById("content_editor"), {
const cm_editor = CodeMirror.fromTextArea(document.getElementById("content_editor"), {
mode: 'markdown',
lineNumbers: true,
theme: "otterwiki",
Expand All @@ -290,7 +290,7 @@ <h5 class="sidebar-title"><a class="sidebar-title-link" href="{{ url_for('attach
},
});
cm_editor.setCursor({ line: {{cursor_line or 0}}, ch: {{cursor_ch or 0}} } );
var bottom_panel = document.getElementById('editor-bottom-panel');
const bottom_panel = document.getElementById('editor-bottom-panel');
bottom_panel.style.display = 'block';
cm_editor.addPanel(bottom_panel, {position: "bottom", stable: true});
Expand Down Expand Up @@ -333,22 +333,55 @@ <h5 class="sidebar-title"><a class="sidebar-title-link" href="{{ url_for('attach
bottom_panel,
]; // document.querySelector("#editor_block");
const preview_block = document.querySelector("#preview_block");
/*
prevent user leaving with unsaved changes
Known Issue: this doesn't work reliable in Safari
*/
window.addEventListener('beforeunload', function (e) {
// Safari logs the event, but the preventDefault() only
// works the first time it is called.
// console.log(e);
if (!cm_editor.doc.isClean()) {
e.preventDefault();
// Chrome requires returnValue to be set.
e.returnValue = '';
return e;
var cm_last_change = 0;
var cm_save_timer = null;
save_draft = function() {
cm_last_change = Date.now();
if (cm_save_timer != null) {
window.clearTimeout(cm_save_timer);
cm_save_timer = null;
}
return null;
});
const formData = new FormData();
formData.append("content", cm_editor.getValue());
formData.append("cursor_line", cm_editor.getCursor().line);
formData.append("cursor_ch", cm_editor.getCursor().ch);
formData.append("revision", "{{revision}}");
fetch("{{ url_for('draft', path=pagepath) }}", {
method: 'POST',
body: formData,
})
.then(function (response) {
return response.json();
})
.catch(function () {
console.log('Error saving draft ...');
});
}
const handleUnload = (event) => {
if (cm_editor != null && !cm_editor.doc.isClean()) {
event.preventDefault();
// No browser actually displays this message anymore.
// But Chrome requires it to be defined else the popup won't show.
event.returnValue = '';
save_draft();
}
};
window.addEventListener('load', function () {
/*
prevent user leaving with unsaved changes
Known Issue: this doesn't work reliable in Safari
*/
cm_editor.on('change', () => {
window.removeEventListener('beforeunload', handleUnload);
window.addEventListener('beforeunload', handleUnload);
if (cm_save_timer == null) {
cm_save_timer = window.setTimeout(save_draft, 6000);
}
});
}); // page load
/* save */
document.getElementById('saveform').onsubmit = function() {
const content_editor = cm_editor.getValue();
Expand Down
17 changes: 16 additions & 1 deletion otterwiki/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,8 +308,12 @@ def blame(path, revision=None):
@app.route("/<path:path>/edit", methods=["POST", "GET"])
@app.route("/<path:path>/edit/<string:revision>", methods=["GET"])
def edit(path, revision=None):

p = Page(path, revision=revision)
return p.editor()
return p.editor(
author=otterwiki.auth.get_author(),
handle_draft=request.form.get("draft", None),
)


@app.route("/<path:path>/save", methods=["POST"])
Expand All @@ -336,6 +340,17 @@ def preview(path):
cursor_line=request.form.get("cursor_line"),
)

@app.route("/<path:path>/draft", methods=["POST", "GET"])
def draft(path):
p = Page(path)
return p.save_draft(
content=request.form.get("content", ""),
cursor_line=request.form.get("cursor_line", 0),
cursor_ch=request.form.get("cursor_ch", 0),
revision=request.form.get("revision", ""),
author=otterwiki.auth.get_author(),
)


@app.route("/<path:pagepath>/source/<string:revision>")
@app.route("/<path:pagepath>/source", methods=["GET"])
Expand Down
104 changes: 102 additions & 2 deletions otterwiki/wiki.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import os
import re
from collections import namedtuple
from unidiff import PatchSet
from flask import (
redirect,
Expand All @@ -15,7 +16,8 @@
)
from markupsafe import escape as html_escape
from otterwiki.gitstorage import StorageNotFound, StorageError
from otterwiki.server import app, storage
from otterwiki.server import app, storage, db
from otterwiki.models import Drafts
from otterwiki.renderer import render, pygments_render
from otterwiki.sidebar import SidebarNavigation
from otterwiki.util import (
Expand Down Expand Up @@ -77,6 +79,8 @@ def upsert_pagecrumbs(pagepath):

# add the pagepath to the tail of the list of pagecrumbs
session["pagecrumbs"] = session["pagecrumbs"][-7:] + [pagepath]
# flask.session: modifications on mutable structures are not picked up automatically
session.modified = True

class PageIndex:
def __init__(self, path=None):
Expand Down Expand Up @@ -610,7 +614,7 @@ def preview(self, content=None, cursor_line=None):
"preview_toc" : toc_html,
}

def editor(self):
def editor(self, author, handle_draft=None):
if not has_permission("WRITE"):
abort(403)
if self.exists:
Expand All @@ -626,6 +630,27 @@ def editor(self):
cursor_line = 2
cursor_ch = 0

# check Drafts
draft = self.load_draft(author=author)
if draft is not None:
if handle_draft is None:
return render_template(
"draft.html",
pagename=self.pagename,
pagepath=self.pagepath,
revision=self.metadata["revision"] if self.metadata else None,
draft_revision=self.revision,
content=pygments_render(self.content, lang='markdown') if self.content else None,
draft_content=pygments_render(draft.content, lang='markdown'),
draft_datetime=draft.datetime.astimezone(UTC),
)
if handle_draft == "discard":
self.discard_draft(author=author)
if handle_draft == "edit":
content = draft.content
cursor_line = draft.cursor_line
cursor_ch = draft.cursor_ch

# get file listing
files = [f.data for f in self._attachments() if f.metadata is not None]

Expand All @@ -637,6 +662,7 @@ def editor(self):
files=files,
cursor_line=cursor_line,
cursor_ch=cursor_ch,
revision=self.metadata.get("revision", "") if self.metadata else "",
)

def save(self, content, commit, author):
Expand All @@ -653,6 +679,8 @@ def save(self, content, commit, author):
toast("Nothing changed.", "warning")
else:
toast("{} saved.".format(self.pagename))
# take care of drafts
self.discard_draft(author)
# redirect to view
return redirect(url_for("view", path=self.pagepath))

Expand Down Expand Up @@ -980,6 +1008,78 @@ def edit_attachment(
# show edit form
return a.edit()

def load_draft(self, author):
if current_user.is_anonymous:
try:
d = session["drafts"][self.pagepath]
return namedtuple('SessionDraft', d.keys())(*d.values())
except KeyError:
return None
else:
draft = Drafts.query.filter_by(pagepath=self.pagepath, author_email=author[1]).first()
if draft:
if draft.datetime.tzinfo is None:
# add the UTC timezone, that we set via datetime.now(UTC)
draft.datetime = draft.datetime.replace(tzinfo = UTC);
return draft

def discard_draft(self, author):
if current_user.is_anonymous:
if "drafts" not in session:
session["drafts"] = {}
else:
try:
# delete draft from session
del session["drafts"][self.pagepath]
session.modified = True
except KeyError:
pass
else:
Drafts.query.filter_by(pagepath=self.pagepath, author_email=author[1]).delete()
db.session.commit()

def save_draft(self, author, content, revision="", cursor_line=0, cursor_ch=0):
if not has_permission("WRITE"):
abort(403)
# Handle anonymous users, save draft in session
if current_user.is_anonymous:
if "drafts" not in session:
session["drafts"] = {}
# save draft in session
d = {
"content" : content,
"revision": revision,
"cursor_line": cursor_line,
"cursor_ch": cursor_ch,
"datetime": datetime.now(UTC)
}
session["drafts"][self.pagepath] = d
# flask.session: modifications on mutable structures are not picked up automatically
session.modified = True
return {
"status" : "draft saved in session",
}

# find existing Draft
draft = Drafts.query.filter_by(pagepath=self.pagepath, author_email=author[1]).first()
if draft is None:
draft = Drafts()
draft.pagepath=self.pagepath
draft.author_email=author[1]
# update content, timestamp, revision, line
draft.content = content
draft.datetime = datetime.now(UTC)
draft.revision = revision
draft.cursor_line = cursor_line
draft.cursor_ch = cursor_ch

db.session.add(draft)
db.session.commit()

return {
"status" : "draft saved",
}


class Attachment:
def __init__(self, pagepath, filename, revision=None):
Expand Down

0 comments on commit 0b23141

Please sign in to comment.