harbour-quill 0.2.12: note markdown con sync WebDAV
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import QtQuick 2.2
|
||||
import Sailfish.Silica 1.0
|
||||
|
||||
// Self-hiding toast-like banner (Silica has no Toast; Notices is not
|
||||
// instantiable on Sailfish 5.1). Anchored to the bottom of the parent page.
|
||||
Item {
|
||||
id: banner
|
||||
|
||||
property string text: ""
|
||||
property bool error: false
|
||||
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
// Anchored at the TOP: at the bottom the toast could be clipped by the
|
||||
// screen edge / pulley peek area.
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: Theme.paddingLarge
|
||||
width: parent.width - 2 * Theme.paddingLarge
|
||||
z: 20
|
||||
visible: false
|
||||
|
||||
function show(message, isError) {
|
||||
text = message
|
||||
error = isError !== false
|
||||
visible = true
|
||||
fadeIn.start()
|
||||
hideTimer.restart()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: bannerRect
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: parent.width
|
||||
height: bannerLabel.height + 2 * Theme.paddingSmall
|
||||
radius: Theme.paddingSmall
|
||||
color: banner.error ? "#8b2e2e" : "#1f3a5f"
|
||||
opacity: 0
|
||||
|
||||
Label {
|
||||
id: bannerLabel
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 2 * Theme.paddingMedium
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
color: "#ffffff"
|
||||
wrapMode: Text.Wrap
|
||||
text: banner.text
|
||||
}
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
id: fadeIn
|
||||
target: bannerRect
|
||||
property: "opacity"
|
||||
to: 0.95
|
||||
duration: 200
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: hideTimer
|
||||
interval: 3500
|
||||
onTriggered: {
|
||||
banner.visible = false
|
||||
bannerRect.opacity = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import QtQuick 2.2
|
||||
import "PropfindParser.js" as Prop
|
||||
|
||||
// Minimal WebDAV client over XMLHttpRequest. One request at a time
|
||||
// (busy flag); 30 s watchdog turns hangs into a failed() signal.
|
||||
Item {
|
||||
id: dav
|
||||
|
||||
property bool busy: false
|
||||
|
||||
signal listReady(var entries)
|
||||
signal fileReady(string content)
|
||||
signal writeDone()
|
||||
signal deleteDone()
|
||||
signal connOk()
|
||||
signal failed(string message)
|
||||
|
||||
property var currentXhr: null
|
||||
|
||||
Timer {
|
||||
id: watchdog
|
||||
interval: 30000
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (dav.currentXhr) {
|
||||
dav.currentXhr.abort()
|
||||
dav.currentXhr = null
|
||||
}
|
||||
dav.busy = false
|
||||
dav.failed(qsTr("Network timeout"))
|
||||
}
|
||||
}
|
||||
|
||||
function authHeader() {
|
||||
var user = appSettings.username
|
||||
if (!user) return ""
|
||||
return "Basic " + Qt.btoa(user + ":" + appSettings.password)
|
||||
}
|
||||
|
||||
function encodeRel(rel) {
|
||||
if (!rel) return rel
|
||||
var parts = rel.split("/")
|
||||
var out = []
|
||||
for (var i = 0; i < parts.length; i++) out.push(encodeURIComponent(parts[i]))
|
||||
return out.join("/")
|
||||
}
|
||||
|
||||
function fullUrl(rel) {
|
||||
var base = String(appSettings.serverUrl).replace(/\s+$/, "")
|
||||
if (!base) return ""
|
||||
if (rel && rel !== "") {
|
||||
if (base.charAt(base.length - 1) !== "/") base += "/"
|
||||
base += encodeRel(rel)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
function request(method, url, body, onDone) {
|
||||
if (busy) {
|
||||
failed(qsTr("Request already in progress"))
|
||||
return
|
||||
}
|
||||
busy = true
|
||||
var xhr = new XMLHttpRequest()
|
||||
currentXhr = xhr
|
||||
xhr.open(method, url, true)
|
||||
var auth = authHeader()
|
||||
if (auth) xhr.setRequestHeader("Authorization", auth)
|
||||
if (method === "PROPFIND") xhr.setRequestHeader("Depth", "1")
|
||||
if (method === "PUT") xhr.setRequestHeader("Content-Type", "text/markdown; charset=utf-8")
|
||||
xhr.onreadystatechange = function () {
|
||||
if (xhr.readyState !== 4) return
|
||||
watchdog.stop()
|
||||
if (currentXhr === xhr) {
|
||||
currentXhr = null
|
||||
busy = false
|
||||
onDone(xhr)
|
||||
}
|
||||
// else: aborted by the watchdog — already signalled
|
||||
}
|
||||
xhr.send(body || "")
|
||||
watchdog.restart()
|
||||
}
|
||||
|
||||
function listDir(relPath) {
|
||||
request("PROPFIND", fullUrl(relPath || ""), "", function (xhr) {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
var entries = Prop.parse(xhr.responseText, appSettings.serverUrl)
|
||||
listReady(entries)
|
||||
} else {
|
||||
failed(statusMessage(xhr.status))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function readFile(relPath) {
|
||||
request("GET", fullUrl(relPath), "", function (xhr) {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
fileReady(xhr.responseText)
|
||||
} else {
|
||||
failed(statusMessage(xhr.status))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function writeFile(relPath, content) {
|
||||
request("PUT", fullUrl(relPath), content, function (xhr) {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
writeDone()
|
||||
} else {
|
||||
failed(statusMessage(xhr.status))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function deleteFile(relPath) {
|
||||
request("DELETE", fullUrl(relPath), "", function (xhr) {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
deleteDone()
|
||||
} else {
|
||||
failed(statusMessage(xhr.status))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function testConnection() {
|
||||
request("PROPFIND", fullUrl(""), "", function (xhr) {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
connOk()
|
||||
} else {
|
||||
failed(statusMessage(xhr.status))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function statusMessage(status) {
|
||||
if (status === 401) return qsTr("Invalid credentials (401)")
|
||||
if (status === 403) return qsTr("Access denied (403)")
|
||||
if (status === 404) return qsTr("Folder not found (404) — check the URL")
|
||||
if (status === 405) return qsTr("WebDAV not available (405) — the URL must point to a DAV folder")
|
||||
if (status === 0) return qsTr("Server unreachable")
|
||||
if (status >= 500) return qsTr("Server error (%1)").arg(status)
|
||||
return qsTr("HTTP error %1").arg(status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// Markdown -> HTML subset (Qt rich text) renderer. Pure ES5, no deps.
|
||||
// IMPORTANT (QML): functions are TOP-LEVEL so a QML namespace import exposes
|
||||
// them directly: import "../components/Markdown.js" as Markdown
|
||||
// ... Markdown.render(src) ...
|
||||
// (an IIFE + var would hide them under Markdown.Markdown and QML would throw
|
||||
// "Property 'render' of object [object Object] is not a function").
|
||||
// The module.exports guard at the bottom is for node tests only.
|
||||
//
|
||||
// render(src, baseUrl, opts):
|
||||
// baseUrl - resolves relative links/images against (the note's WebDAV folder)
|
||||
// opts.imageMap - { url: localCachePath } -> inline <img> instead of placeholder
|
||||
// opts.imageLabel - localized word for the image placeholder ("image")
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function resolveUrl(u, baseUrl) {
|
||||
if (baseUrl && !/^[a-z]+:/i.test(u) && u.charAt(0) !== "#" && u.charAt(0) !== "/") {
|
||||
return String(baseUrl).replace(/\/+$/, "") + "/" + u;
|
||||
}
|
||||
return u;
|
||||
}
|
||||
|
||||
function inline(s, baseUrl, opts) {
|
||||
s = escapeHtml(s);
|
||||
s = s.replace(/`([^`]+)`/g, "<tt>$1</tt>");
|
||||
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, function (m, alt, u) {
|
||||
var key = resolveUrl(u, baseUrl);
|
||||
var p = opts && opts.imageMap ? opts.imageMap[key] : "";
|
||||
if (p && p !== "error") {
|
||||
return '<img src="file://' + p + '" width="350" height="-1" alt="' + alt + '"/>';
|
||||
}
|
||||
var label = opts && opts.imageLabel ? opts.imageLabel : "image";
|
||||
return "<i>[" + label + ": " + alt + "]</i>";
|
||||
});
|
||||
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, function (m, t, u) {
|
||||
return '<a href="' + resolveUrl(u, baseUrl) + '">' + t + "</a>";
|
||||
});
|
||||
// Wiki links [[Nota]] -> quill://<url-encoded name.md>; the app intercepts
|
||||
// the scheme and opens the note in the editor.
|
||||
s = s.replace(/\[\[([^\]]+)\]\]/g, function (m, name) {
|
||||
var full = name;
|
||||
var lower = name.toLowerCase();
|
||||
if (lower.indexOf(".md") !== lower.length - 3) full = name + ".md";
|
||||
var label = full;
|
||||
if (label.toLowerCase().indexOf(".md") === label.length - 3) {
|
||||
label = label.substring(0, label.length - 3);
|
||||
}
|
||||
return '<a href="quill://' + encodeURIComponent(full) + '">' + label + "</a>";
|
||||
});
|
||||
s = s.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
|
||||
s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<i>$2</i>");
|
||||
s = s.replace(/~~([^~]+)~~/g, "<s>$1</s>");
|
||||
return s;
|
||||
}
|
||||
|
||||
// Split a table row on unescaped pipes; leading/trailing pipe is optional.
|
||||
function splitRow(s) {
|
||||
var t = s.trim();
|
||||
if (t.charAt(0) === "|") t = t.substring(1);
|
||||
if (t.charAt(t.length - 1) === "|") t = t.substring(0, t.length - 1);
|
||||
var parts = t.split("|");
|
||||
var cells = [];
|
||||
for (var i = 0; i < parts.length; i++) cells.push(parts[i].trim());
|
||||
return cells;
|
||||
}
|
||||
|
||||
// GFM alignment from a delimiter cell: ":---" left, "---:" right, ":---:" center.
|
||||
function alignmentFromSep(sep, n) {
|
||||
var parts = splitRow(sep);
|
||||
var aligns = [];
|
||||
for (var i = 0; i < n; i++) {
|
||||
var p = i < parts.length ? parts[i] : "";
|
||||
if (p.charAt(0) === ":" && p.charAt(p.length - 1) === ":") aligns.push("center");
|
||||
else if (p.charAt(0) === ":") aligns.push("left");
|
||||
else if (p.charAt(p.length - 1) === ":") aligns.push("right");
|
||||
else aligns.push("");
|
||||
}
|
||||
return aligns;
|
||||
}
|
||||
|
||||
function alignAttr(a) {
|
||||
return a ? ' align="' + a + '"' : "";
|
||||
}
|
||||
|
||||
function render(src, baseUrl, opts) {
|
||||
if (!src) return "";
|
||||
src = src.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
// Strip UTF-8 BOM: a leading \uFEFF breaks the first heading/block
|
||||
// regex (line no longer starts with #) and the symbol stays visible.
|
||||
src = src.replace(/^\uFEFF/, "");
|
||||
var lines = src.split("\n");
|
||||
var out = [];
|
||||
var i = 0;
|
||||
var n = lines.length;
|
||||
|
||||
var headingRe = /^\s*(#{1,6})\s+(.*)$/;
|
||||
var hrRe = /^\s*(-{3,}|\*{3,}|_{3,})\s*$/;
|
||||
var quoteRe = /^>\s?(.*)$/;
|
||||
var listRe = /^\s*([-*+]|\d+[.)])\s+(.*)$/;
|
||||
var fenceRe = /^```/;
|
||||
var tableHeaderRe = /^\s*\|.*\|/;
|
||||
var tableSepRe = /^\s*\|?[\s:|-]+\|?\s*$/;
|
||||
|
||||
while (i < n) {
|
||||
var line = lines[i];
|
||||
var m;
|
||||
|
||||
if (fenceRe.test(line)) {
|
||||
var buf = [];
|
||||
i++;
|
||||
while (i < n && !fenceRe.test(lines[i])) {
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
i++; // closing fence
|
||||
out.push('<pre style="color:#9a9a9a">' + escapeHtml(buf.join("\n")) + "</pre>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// GFM table: header row + delimiter row (must contain a pipe and a dash).
|
||||
if (tableHeaderRe.test(line) && i + 1 < n) {
|
||||
var sepLine = lines[i + 1];
|
||||
if (tableSepRe.test(sepLine) && sepLine.indexOf("|") >= 0 && sepLine.indexOf("-") >= 0) {
|
||||
var headerCells = splitRow(line);
|
||||
var aligns = alignmentFromSep(sepLine, headerCells.length);
|
||||
var tbody = [];
|
||||
i += 2;
|
||||
while (i < n) {
|
||||
var dl = lines[i];
|
||||
if (dl.trim() === "" || dl.indexOf("|") < 0) break;
|
||||
tbody.push(splitRow(dl));
|
||||
i++;
|
||||
}
|
||||
var t = '<table border="1" cellspacing="0" cellpadding="4">';
|
||||
t += "<tr>";
|
||||
for (var h = 0; h < headerCells.length; h++) {
|
||||
t += "<th" + alignAttr(aligns[h]) + "><b>" + inline(headerCells[h], baseUrl, opts) + "</b></th>";
|
||||
}
|
||||
t += "</tr>";
|
||||
for (var r = 0; r < tbody.length; r++) {
|
||||
t += "<tr>";
|
||||
var row = tbody[r];
|
||||
for (var c = 0; c < headerCells.length; c++) {
|
||||
var cell = c < row.length ? row[c] : "";
|
||||
t += "<td" + alignAttr(aligns[c]) + ">" + inline(cell, baseUrl, opts) + "</td>";
|
||||
}
|
||||
t += "</tr>";
|
||||
}
|
||||
t += "</table>";
|
||||
out.push(t);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
m = headingRe.exec(line);
|
||||
if (m) {
|
||||
var level = m[1].length;
|
||||
out.push("<h" + level + ">" + inline(m[2], baseUrl, opts) + "</h" + level + ">");
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hrRe.test(line)) {
|
||||
out.push("<hr/>");
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
m = quoteRe.exec(line);
|
||||
if (m) {
|
||||
var q = [];
|
||||
while (i < n) {
|
||||
var qm = quoteRe.exec(lines[i]);
|
||||
if (!qm) break;
|
||||
q.push(inline(qm[1], baseUrl, opts));
|
||||
i++;
|
||||
}
|
||||
out.push('<p style="color:#8f8f8f"><i>' + q.join("<br/>") + "</i></p>");
|
||||
continue;
|
||||
}
|
||||
|
||||
m = listRe.exec(line);
|
||||
if (m) {
|
||||
var ordered = /^\s*\d/.test(line);
|
||||
var items = [];
|
||||
while (i < n) {
|
||||
var lm = listRe.exec(lines[i]);
|
||||
if (!lm) break;
|
||||
items.push("<li>" + inline(lm[2], baseUrl, opts) + "</li>");
|
||||
i++;
|
||||
}
|
||||
out.push(ordered ? "<ol>" + items.join("") + "</ol>" : "<ul>" + items.join("") + "</ul>");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.trim() === "") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var para = [];
|
||||
while (i < n) {
|
||||
var pl = lines[i];
|
||||
if (pl.trim() === "" || headingRe.test(pl) || hrRe.test(pl) ||
|
||||
quoteRe.test(pl) || listRe.test(pl) || fenceRe.test(pl)) {
|
||||
break;
|
||||
}
|
||||
para.push(inline(pl, baseUrl, opts));
|
||||
i++;
|
||||
}
|
||||
out.push("<p>" + para.join("<br/>") + "</p>");
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { render: render };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Parse a WebDAV multistatus (PROPFIND) response into flat entries.
|
||||
// Pure ES5, no deps. Functions are TOP-LEVEL so the QML namespace import
|
||||
// exposes them directly (see Markdown.js): import "PropfindParser.js" as Prop
|
||||
// ... Prop.parse(xml, baseUrl) ...
|
||||
// The module.exports guard at the bottom is for node tests only.
|
||||
|
||||
function decodePath(u) {
|
||||
try {
|
||||
return decodeURIComponent(u);
|
||||
} catch (e) {
|
||||
return u;
|
||||
}
|
||||
}
|
||||
|
||||
function urlPath(u) {
|
||||
var m = /^[a-z]+:\/\/[^/]*(\/.*)$/i.exec(u);
|
||||
return m ? m[1] : u;
|
||||
}
|
||||
|
||||
// entries: [{name, path, isDir, size, mtime}] sorted dirs-first, alpha.
|
||||
// path is relative to baseUrl, URL-decoded; dirs end with "/".
|
||||
function parse(xml, baseUrl) {
|
||||
var entries = [];
|
||||
if (!xml) return entries;
|
||||
|
||||
var basePath = decodePath(urlPath(String(baseUrl || "")));
|
||||
if (basePath.charAt(basePath.length - 1) === "/") {
|
||||
basePath = basePath.slice(0, -1);
|
||||
}
|
||||
|
||||
var re = /<(?:[A-Za-z0-9_]+:)?response(?:\s[^>]*)?>([\s\S]*?)<\/(?:[A-Za-z0-9_]+:)?response>/gi;
|
||||
var blk;
|
||||
while ((blk = re.exec(xml)) !== null) {
|
||||
var body = blk[1];
|
||||
|
||||
var hrefMatch = /(?:[A-Za-z0-9_]+:)?href>([\s\S]*?)<\//.exec(body);
|
||||
if (!hrefMatch) continue;
|
||||
var href = hrefMatch[1].trim();
|
||||
if (!href) continue;
|
||||
|
||||
var decoded = decodePath(href);
|
||||
var rel = decoded;
|
||||
if (basePath && decoded.indexOf(basePath) === 0) {
|
||||
rel = decoded.substring(basePath.length);
|
||||
}
|
||||
if (rel.charAt(0) === "/") rel = rel.substring(1);
|
||||
if (rel === "" || rel === "/") continue;
|
||||
|
||||
var isDir = rel.charAt(rel.length - 1) === "/";
|
||||
var segs = rel.split("/");
|
||||
var name = isDir ? segs[segs.length - 2] : segs[segs.length - 1];
|
||||
if (!name) continue;
|
||||
|
||||
var mtimeMatch = /(?:[A-Za-z0-9_]+:)?getlastmodified>([\s\S]*?)<\//.exec(body);
|
||||
var sizeMatch = /(?:[A-Za-z0-9_]+:)?getcontentlength>([\s\S]*?)<\//.exec(body);
|
||||
|
||||
entries.push({
|
||||
name: name,
|
||||
path: rel,
|
||||
isDir: isDir,
|
||||
size: sizeMatch ? parseInt(sizeMatch[1].trim(), 10) : 0,
|
||||
mtime: mtimeMatch ? mtimeMatch[1].trim() : ""
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort(function (a, b) {
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
||||
var an = a.name.toLowerCase();
|
||||
var bn = b.name.toLowerCase();
|
||||
return an < bn ? -1 : (an > bn ? 1 : 0);
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { parse: parse };
|
||||
}
|
||||
Reference in New Issue
Block a user