markdown editor works

This commit is contained in:
ed 2020-05-02 01:16:10 +02:00
parent 706f30033e
commit d9c71c11fd
10 changed files with 256 additions and 35 deletions

View file

@ -35,7 +35,7 @@ turn your phone or raspi into a portable file server with resumable uploads/down
* [x] volumes * [x] volumes
* [x] accounts * [x] accounts
* [x] markdown viewer * [x] markdown viewer
* [ ] markdown editor? w * [x] markdown editor
summary: it works! you can use it! (but technically not even close to beta) summary: it works! you can use it! (but technically not even close to beta)

View file

@ -45,6 +45,11 @@ class HttpCli(object):
def _check_nonfatal(self, ex): def _check_nonfatal(self, ex):
return ex.code in [403, 404] return ex.code in [403, 404]
def _assert_safe_rem(self, rem):
# sanity check to prevent any disasters
if rem.startswith("/") or rem.startswith("../") or "/../" in rem:
raise Exception("that was close")
def run(self): def run(self):
"""returns true if connection can be reused""" """returns true if connection can be reused"""
self.keepalive = False self.keepalive = False
@ -263,9 +268,16 @@ class HttpCli(object):
if act == "mkdir": if act == "mkdir":
return self.handle_mkdir() return self.handle_mkdir()
if act == "new_md":
# kinda silly but has the least side effects
return self.handle_new_md()
if act == "bput": if act == "bput":
return self.handle_plain_upload() return self.handle_plain_upload()
if act == "tput":
return self.handle_text_upload()
raise Pebkac(422, 'invalid action "{}"'.format(act)) raise Pebkac(422, 'invalid action "{}"'.format(act))
def handle_post_json(self): def handle_post_json(self):
@ -407,11 +419,7 @@ class HttpCli(object):
nullwrite = self.args.nw nullwrite = self.args.nw
vfs, rem = self.conn.auth.vfs.get(self.vpath, self.uname, False, True) vfs, rem = self.conn.auth.vfs.get(self.vpath, self.uname, False, True)
self._assert_safe_rem(rem)
# rem is escaped at this point,
# this is just a sanity check to prevent any disasters
if rem.startswith("/") or rem.startswith("../") or "/../" in rem:
raise Exception("that was close")
if not nullwrite: if not nullwrite:
fdir = os.path.join(vfs.realpath, rem) fdir = os.path.join(vfs.realpath, rem)
@ -429,12 +437,44 @@ class HttpCli(object):
raise Pebkac(500, "mkdir failed, check the logs") raise Pebkac(500, "mkdir failed, check the logs")
vpath = "{}/{}".format(self.vpath, new_dir).lstrip("/") vpath = "{}/{}".format(self.vpath, new_dir).lstrip("/")
redir = '<script>document.getElementsByTagName("a")[0].click()</script>'
html = self.conn.tpl_msg.render( html = self.conn.tpl_msg.render(
h2='<a href="/{}">go to /{}</a>{}'.format( h2='<a href="/{}">go to /{}</a>'.format(
quotep(vpath), html_escape(vpath, quote=False), redir quotep(vpath), html_escape(vpath, quote=False)
), ),
pre="aight", pre="aight",
click=True,
)
self.reply(html.encode("utf-8", "replace"))
return True
def handle_new_md(self):
new_file = self.parser.require("name", 512)
self.parser.drop()
nullwrite = self.args.nw
vfs, rem = self.conn.auth.vfs.get(self.vpath, self.uname, False, True)
self._assert_safe_rem(rem)
if not new_file.endswith(".md"):
new_file += ".md"
if not nullwrite:
fdir = os.path.join(vfs.realpath, rem)
fn = os.path.join(fdir, sanitize_fn(new_file))
if os.path.exists(fsenc(fn)):
raise Pebkac(500, "that file exists already")
with open(fsenc(fn), "wb") as f:
f.write(b"`GRUNNUR`\n")
vpath = "{}/{}".format(self.vpath, new_file).lstrip("/")
html = self.conn.tpl_msg.render(
h2='<a href="/{}?edit">go to /{}?edit</a>'.format(
quotep(vpath), html_escape(vpath, quote=False)
),
pre="aight",
click=True,
) )
self.reply(html.encode("utf-8", "replace")) self.reply(html.encode("utf-8", "replace"))
return True return True
@ -442,11 +482,7 @@ class HttpCli(object):
def handle_plain_upload(self): def handle_plain_upload(self):
nullwrite = self.args.nw nullwrite = self.args.nw
vfs, rem = self.conn.auth.vfs.get(self.vpath, self.uname, False, True) vfs, rem = self.conn.auth.vfs.get(self.vpath, self.uname, False, True)
self._assert_safe_rem(rem)
# rem is escaped at this point,
# this is just a sanity check to prevent any disasters
if rem.startswith("/") or rem.startswith("../") or "/../" in rem:
raise Exception("that was close")
files = [] files = []
errmsg = "" errmsg = ""
@ -537,6 +573,86 @@ class HttpCli(object):
self.parser.drop() self.parser.drop()
return True return True
def handle_text_upload(self):
try:
cli_lastmod3 = int(self.parser.require("lastmod", 16))
except:
raise Pebkac(400, "could not read lastmod from request")
nullwrite = self.args.nw
vfs, rem = self.conn.auth.vfs.get(self.vpath, self.uname, False, True)
self._assert_safe_rem(rem)
# TODO:
# the per-volume read/write permissions must be replaced with permission flags
# which would decide how to handle uploads to filenames which are taken,
# current behavior of creating a new name is a good default for binary files
# but should also offer a flag to takeover the filename and rename the old one
#
# stopgap:
if not rem.endswith(".md"):
raise Pebkac(400, "only markdown pls")
if nullwrite:
response = json.dumps({"ok": True, "lastmod": 0})
self.log(response)
# TODO reply should parser.drop()
self.parser.drop()
self.reply(response.encode("utf-8"))
return True
fn = os.path.join(vfs.realpath, rem)
srv_lastmod = -1
try:
st = os.stat(fsenc(fn))
srv_lastmod = st.st_mtime
srv_lastmod3 = int(srv_lastmod * 1000)
except OSError as ex:
if ex.errno != 2:
raise
# if file exists, chekc that timestamp matches the client's
if srv_lastmod >= 0:
if cli_lastmod3 not in [-1, srv_lastmod3]:
response = json.dumps(
{
"ok": False,
"lastmod": srv_lastmod3,
"now": int(time.time() * 1000),
}
)
self.log(
"{} - {} = {}".format(
srv_lastmod3, cli_lastmod3, srv_lastmod3 - cli_lastmod3
)
)
self.log(response)
self.parser.drop()
self.reply(response.encode("utf-8"))
return True
# TODO another hack re: pending permissions rework
os.rename(fn, "{}.bak-{:.3f}.md".format(fn[:-3], srv_lastmod))
p_field, _, p_data = next(self.parser.gen)
if p_field != "body":
raise Pebkac(400, "expected body, got {}".format(p_field))
with open(fn, "wb") as f:
sz, sha512, _ = hashcopy(self.conn, p_data, f)
new_lastmod = os.stat(fsenc(fn)).st_mtime
new_lastmod3 = int(new_lastmod * 1000)
sha512 = sha512[:56]
response = json.dumps(
{"ok": True, "lastmod": new_lastmod3, "size": sz, "sha512": sha512}
)
self.log(response)
self.parser.drop()
self.reply(response.encode("utf-8"))
return True
def _chk_lastmod(self, file_ts): def _chk_lastmod(self, file_ts):
file_dt = datetime.utcfromtimestamp(file_ts) file_dt = datetime.utcfromtimestamp(file_ts)
file_lastmod = file_dt.strftime("%a, %d %b %Y %H:%M:%S GMT") file_lastmod = file_dt.strftime("%a, %d %b %Y %H:%M:%S GMT")
@ -731,6 +847,7 @@ class HttpCli(object):
targs = { targs = {
"title": html_escape(self.vpath, quote=False), "title": html_escape(self.vpath, quote=False),
"lastmod": int(ts_md * 1000),
"md": "", "md": "",
} }
sz_html = len(template.render(**targs).encode("utf-8")) sz_html = len(template.render(**targs).encode("utf-8"))

View file

@ -45,6 +45,14 @@ html, body {
text-decoration: underline; text-decoration: underline;
} }
html .editor-toolbar>button.disabled {
opacity: .35;
pointer-events: none;
}
html .editor-toolbar>button.save.force-save {
background: #f97;
}
/* /*
*[data-ln]:before { *[data-ln]:before {
content: attr(data-ln); content: attr(data-ln);

View file

@ -23,6 +23,7 @@
<script> <script>
var link_md_as_html = false; // TODO (does nothing) var link_md_as_html = false; // TODO (does nothing)
var last_modified = {{ lastmod }};
</script> </script>
<script src="/.cpr/deps/easymde.full.js"></script> <script src="/.cpr/deps/easymde.full.js"></script>

View file

@ -24,15 +24,14 @@ var dom_md = document.getElementById('mt');
name: "save", name: "save",
title: "save", title: "save",
className: "fa fa-save", className: "fa fa-save",
action: (mde) => { action: save
alert("TODO save (" + (mde.value().length / 1024) + ' kB)');
}
}, '|', }, '|',
'bold', 'italic', 'strikethrough', 'heading', '|', 'bold', 'italic', 'strikethrough', 'heading', '|',
'code', 'quote', 'unordered-list', 'ordered-list', 'clean-block', '|', 'code', 'quote', 'unordered-list', 'ordered-list', 'clean-block', '|',
'link', 'image', 'table', 'horizontal-rule', '|', 'link', 'image', 'table', 'horizontal-rule', '|',
'preview', 'side-by-side', 'fullscreen', '|', 'preview', 'side-by-side', 'fullscreen', '|',
'undo', 'redo']; 'undo', 'redo'];
var mde = new EasyMDE({ var mde = new EasyMDE({
autoDownloadFontAwesome: false, autoDownloadFontAwesome: false,
autofocus: true, autofocus: true,
@ -47,8 +46,94 @@ var dom_md = document.getElementById('mt');
tabSize: 4, tabSize: 4,
toolbar: tbar toolbar: tbar
}); });
md_changed(mde, true);
mde.codemirror.on("change", function () {
md_changed(mde);
});
var loader = document.getElementById('ml'); var loader = document.getElementById('ml');
loader.parentNode.removeChild(loader); loader.parentNode.removeChild(loader);
})(); })();
zsetTimeout(function () { window.location.reload(true); }, 1000); function md_changed(mde, on_srv) {
if (on_srv)
window.md_saved = mde.value();
var md_now = mde.value();
var save_btn = document.querySelector('.editor-toolbar button.save');
if (md_now == window.md_saved)
save_btn.classList.add('disabled');
else
save_btn.classList.remove('disabled');
}
function save(mde) {
var save_btn = document.querySelector('.editor-toolbar button.save');
if (save_btn.classList.contains('disabled')) {
alert('there is nothing to save');
return;
}
var force = save_btn.classList.contains('force-save');
if (force && !confirm('confirm that you wish to lose the changes made on the server since you opened this document')) {
alert('ok, aborted');
return;
}
var fd = new FormData();
fd.append("act", "tput");
fd.append("lastmod", (force ? -1 : last_modified));
fd.append("body", mde.value());
var url = (document.location + '').split('?')[0] + '?raw';
var xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.responseType = 'text';
xhr.onreadystatechange = save_cb;
xhr.btn = save_btn;
xhr.mde = mde;
xhr.send(fd);
}
function save_cb() {
if (this.readyState != XMLHttpRequest.DONE)
return;
if (this.status !== 200) {
alert('Error! The file was NOT saved.\n\n' + this.status + ": " + (this.responseText + '').replace(/^<pre>/, ""));
return;
}
var r;
try {
r = JSON.parse(this.responseText);
}
catch {
alert('Failed to parse reply from server:\n\n' + this.responseText);
return;
}
if (!r.ok) {
if (!this.btn.classList.contains('force-save')) {
this.btn.classList.add('force-save');
var msg = [
'This file has been modified since you started editing it!\n',
'if you really want to overwrite, press save again.\n',
'modified ' + ((r.now - r.lastmod) / 1000) + ' seconds ago,',
((r.lastmod - last_modified) / 1000) + ' sec after you opened it\n',
last_modified + ' lastmod when you opened it,',
r.lastmod + ' lastmod on the server now,',
r.now + ' server time now,\n',
];
alert(msg.join('\n'));
}
else {
alert('Error! Save failed. Maybe this JSON explains why:\n\n' + this.responseText);
}
return;
}
last_modified = r.lastmod;
this.btn.classList.remove('force-save');
alert('save OK -- wrote ' + r.size + ' bytes.\n\nsha512: ' + r.sha512);
md_changed(this.mde, true);
}

View file

@ -31,6 +31,10 @@
{%- if html %} {%- if html %}
{{ html }} {{ html }}
{%- endif %} {%- endif %}
{%- if click %}
<script>document.getElementsByTagName("a")[0].click()</script>
{%- endif %}
</div> </div>
{%- if redir %} {%- if redir %}

View file

@ -81,15 +81,15 @@ function opclick(ev) {
function goto(dest) { function goto(dest) {
var obj = document.querySelectorAll('.opview.act'); var obj = document.querySelectorAll('.opview.act');
for (var a = obj.length - 1; a >= 0; a--) for (var a = obj.length - 1; a >= 0; a--)
obj[a].setAttribute('class', 'opview'); obj[a].classList.remove('act');
var obj = document.querySelectorAll('#ops>a'); var obj = document.querySelectorAll('#ops>a');
for (var a = obj.length - 1; a >= 0; a--) for (var a = obj.length - 1; a >= 0; a--)
obj[a].setAttribute('class', ''); obj[a].classList.remove('act');
if (dest) { if (dest) {
document.querySelector('#ops>a[data-dest=' + dest + ']').setAttribute('class', 'act'); var dom_obj = document.getElementById('op_' + dest).classList.add('act');
document.getElementById('op_' + dest).setAttribute('class', 'opview act'); document.querySelector('#ops>a[data-dest=' + dest + ']').classList.add('act');
var fn = window['goto_' + dest]; var fn = window['goto_' + dest];
if (fn) if (fn)

View file

@ -55,8 +55,7 @@
font-size: 1.05em; font-size: 1.05em;
} }
#ops, #ops,
#op_bup, .opbox {
#op_mkdir {
border: 1px solid #3a3a3a; border: 1px solid #3a3a3a;
box-shadow: 0 0 1em #222 inset; box-shadow: 0 0 1em #222 inset;
} }
@ -68,8 +67,7 @@
border-radius: .3em; border-radius: .3em;
border-width: .15em 0; border-width: .15em 0;
} }
#op_bup, .opbox {
#op_mkdir {
background: #2d2d2d; background: #2d2d2d;
margin: 1.5em 0 0 0; margin: 1.5em 0 0 0;
padding: .5em; padding: .5em;
@ -77,11 +75,10 @@
border-width: .15em .3em .3em 0; border-width: .15em .3em .3em 0;
max-width: 40em; max-width: 40em;
} }
#op_mkdir input, .opbox input {
#op_bup input {
margin: .5em; margin: .5em;
} }
#op_mkdir input[type=text] { .opbox input[type=text] {
color: #fff; color: #fff;
background: #383838; background: #383838;
border: none; border: none;

View file

@ -2,9 +2,10 @@
href="#" data-dest="">---</a><i></i><a href="#" data-dest="">---</a><i></i><a
href="#" data-dest="up2k">up2k</a><i></i><a href="#" data-dest="up2k">up2k</a><i></i><a
href="#" data-dest="bup">bup</a><i></i><a href="#" data-dest="bup">bup</a><i></i><a
href="#" data-dest="mkdir">mkdir</a></div> href="#" data-dest="mkdir">mkdir</a><i></i><a
href="#" data-dest="new_md">new.md</a></div>
<div id="op_bup" class="opview act"> <div id="op_bup" class="opview opbox act">
<div id="u2err"></div> <div id="u2err"></div>
<form method="post" enctype="multipart/form-data" accept-charset="utf-8" action="/{{ vdir }}"> <form method="post" enctype="multipart/form-data" accept-charset="utf-8" action="/{{ vdir }}">
<input type="hidden" name="act" value="bput" /> <input type="hidden" name="act" value="bput" />
@ -13,7 +14,7 @@
</form> </form>
</div> </div>
<div id="op_mkdir" class="opview act"> <div id="op_mkdir" class="opview opbox act">
<form method="post" enctype="multipart/form-data" accept-charset="utf-8" action="/{{ vdir }}"> <form method="post" enctype="multipart/form-data" accept-charset="utf-8" action="/{{ vdir }}">
<input type="hidden" name="act" value="mkdir" /> <input type="hidden" name="act" value="mkdir" />
<input type="text" name="name" size="30"> <input type="text" name="name" size="30">
@ -21,6 +22,14 @@
</form> </form>
</div> </div>
<div id="op_new_md" class="opview opbox act">
<form method="post" enctype="multipart/form-data" accept-charset="utf-8" action="/{{ vdir }}">
<input type="hidden" name="act" value="new_md" />
<input type="text" name="name" size="30">
<input type="submit" value="create doc">
</form>
</div>
<div id="op_up2k" class="opview"> <div id="op_up2k" class="opview">
<form id="u2form" method="post" enctype="multipart/form-data" onsubmit="return false;"></form> <form id="u2form" method="post" enctype="multipart/form-data" onsubmit="return false;"></form>

View file

@ -1,6 +1,6 @@
diff -NarU2 easymde-mod1/src/js/easymde.js easymde-edit/src/js/easymde.js diff -NarU2 easymde-mod1/src/js/easymde.js easymde-edit/src/js/easymde.js
--- easymde-mod1/src/js/easymde.js 2020-05-01 14:34:19.878774400 +0200 --- easymde-mod1/src/js/easymde.js 2020-05-01 14:34:19.878774400 +0200
+++ easymde-edit/src/js/easymde.js 2020-05-01 19:26:56.843140600 +0200 +++ easymde-edit/src/js/easymde.js 2020-05-01 21:24:44.142611200 +0200
@@ -2189,4 +2189,5 @@ @@ -2189,4 +2189,5 @@
}; };
@ -63,8 +63,8 @@ diff -NarU2 easymde-mod1/src/js/easymde.js easymde-edit/src/js/easymde.js
+ var pre2 = get_pre_line(pre1[0] + 1, true) || + var pre2 = get_pre_line(pre1[0] + 1, true) ||
+ [cm.lineCount(), null, preview.scrollHeight]; + [cm.lineCount(), null, preview.scrollHeight];
+ +
+ console.log('code-center %d, frac %.2f, pre [%d,%d] [%d,%d]', + //console.log('code-center %d, frac %.2f, pre [%d,%d] [%d,%d]',
+ md_center_n, md_frac, pre1[0], pre1[2], pre2[0], pre2[2]); + // md_center_n, md_frac, pre1[0], pre1[2], pre2[0], pre2[2]);
+ +
+ // [0] is the markdown line which matches that preview y-pos + // [0] is the markdown line which matches that preview y-pos
+ // and since not all preview lines are tagged with a line-number + // and since not all preview lines are tagged with a line-number