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 };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import QtQuick 2.0
|
||||
import Sailfish.Silica 1.0
|
||||
|
||||
CoverBackground {
|
||||
property string coverText: ""
|
||||
|
||||
Label {
|
||||
id: titleLabel
|
||||
anchors.centerIn: parent
|
||||
text: "Quill"
|
||||
color: Theme.primaryColor
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
}
|
||||
|
||||
Label {
|
||||
anchors.top: titleLabel.bottom
|
||||
anchors.topMargin: Theme.paddingSmall
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: parent.width - 2 * Theme.paddingLarge
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: coverText
|
||||
color: Theme.secondaryColor
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
truncationMode: TruncationMode.Fade
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import QtQuick 2.2
|
||||
import Sailfish.Silica 1.0
|
||||
import "cover"
|
||||
import "components"
|
||||
|
||||
ApplicationWindow {
|
||||
id: appWindow
|
||||
|
||||
// Cover data — pushed from the current page, bound into the cover component.
|
||||
property string coverText: ""
|
||||
|
||||
cover: Component {
|
||||
CoverPage {
|
||||
coverText: appWindow.coverText
|
||||
}
|
||||
}
|
||||
|
||||
// Empty placeholder page: the real MainPage is pushed with the dav client
|
||||
// via replace() below. A real MainPage here would instantiate once WITHOUT
|
||||
// dav and log spurious Connections warnings ("Cannot assign to non-existent
|
||||
// property onFailed/onListReady/...").
|
||||
initialPage: Component { Page { } }
|
||||
|
||||
DavClient {
|
||||
id: davClient
|
||||
}
|
||||
|
||||
pageStack.onCurrentPageChanged: {
|
||||
var page = pageStack.currentPage
|
||||
if (page && page.hasOwnProperty("coverText")) {
|
||||
appWindow.coverText = page.coverText
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
pageStack.replace(Qt.resolvedUrl("pages/MainPage.qml"), { dav: davClient })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import QtQuick 2.2
|
||||
import Sailfish.Silica 1.0
|
||||
import "../components"
|
||||
|
||||
Page {
|
||||
id: page
|
||||
|
||||
property var dav
|
||||
property string notePath
|
||||
property string noteName
|
||||
property string initialContent: ""
|
||||
property bool skipLoad: false
|
||||
property bool loaded: false
|
||||
property bool dirty: false
|
||||
property string coverText: noteName
|
||||
|
||||
onStatusChanged: {
|
||||
if (status === PageStatus.Active) {
|
||||
if (!loaded) {
|
||||
if (skipLoad) {
|
||||
// New note: content is already known, no GET needed.
|
||||
loaded = true
|
||||
editor.text = initialContent
|
||||
dirty = false
|
||||
} else {
|
||||
loadNote()
|
||||
}
|
||||
}
|
||||
} else if (status === PageStatus.Inactive) {
|
||||
// Auto-save on exit (Obsidian-like: nothing is ever lost).
|
||||
if (loaded && dirty && !dav.busy) saveNow(false)
|
||||
}
|
||||
}
|
||||
|
||||
function loadNote() {
|
||||
loaded = false
|
||||
dav.readFile(notePath)
|
||||
}
|
||||
|
||||
function saveNow(notify) {
|
||||
if (!loaded) return
|
||||
if (dav.busy) {
|
||||
banner.show(qsTr("Save in progress..."), false)
|
||||
return
|
||||
}
|
||||
dav.writeFile(notePath, editor.text)
|
||||
if (notify) banner.show(qsTr("Saving..."), false)
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: dav
|
||||
onFileReady: {
|
||||
loaded = true
|
||||
initialContent = content
|
||||
editor.text = content
|
||||
dirty = false
|
||||
}
|
||||
onWriteDone: {
|
||||
initialContent = editor.text
|
||||
dirty = false
|
||||
banner.show(qsTr("Saved"), false)
|
||||
}
|
||||
onFailed: {
|
||||
if (loaded) {
|
||||
banner.show(message, true)
|
||||
} else {
|
||||
loaded = true
|
||||
banner.show(qsTr("Cannot open: %1").arg(message), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SilicaFlickable {
|
||||
anchors.fill: parent
|
||||
contentHeight: column.height
|
||||
|
||||
Column {
|
||||
id: column
|
||||
width: parent.width
|
||||
|
||||
PageHeader { title: noteName }
|
||||
|
||||
TextArea {
|
||||
id: editor
|
||||
width: parent.width
|
||||
focus: true
|
||||
label: "Markdown"
|
||||
onTextChanged: {
|
||||
dirty = editor.text !== initialContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PullDownMenu {
|
||||
MenuItem {
|
||||
text: qsTr("Save")
|
||||
enabled: page.dirty && page.loaded
|
||||
onClicked: page.saveNow(true)
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Preview")
|
||||
onClicked: pageStack.push(Qt.resolvedUrl("PreviewPage.qml"),
|
||||
{ dav: page.dav, noteName: page.noteName, notePath: page.notePath, markdown: editor.text })
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Delete note")
|
||||
onClicked: {
|
||||
var remorse = remorseComponent.createObject(page)
|
||||
remorse.execute(qsTr("Delete the note?"), function () {
|
||||
dav.deleteFile(notePath)
|
||||
pageStack.pop()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: remorseComponent
|
||||
RemorsePopup { }
|
||||
}
|
||||
|
||||
BusyIndicator {
|
||||
anchors.centerIn: parent
|
||||
running: !page.loaded
|
||||
}
|
||||
}
|
||||
|
||||
Banner { id: banner }
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import QtQuick 2.2
|
||||
import Sailfish.Silica 1.0
|
||||
import "../components"
|
||||
|
||||
Page {
|
||||
id: page
|
||||
|
||||
property var dav
|
||||
property string currentPath: ""
|
||||
property string coverText: currentPath === "" ? qsTr("Notes") : currentPath
|
||||
property bool loading: false
|
||||
property var pendingEditor: null
|
||||
property var allEntries: []
|
||||
property string filterText: ""
|
||||
|
||||
onFilterTextChanged: page.applyFilter()
|
||||
|
||||
onStatusChanged: {
|
||||
if (status === PageStatus.Active) {
|
||||
if (page.pendingEditor) {
|
||||
// New note accepted: the dialog pops itself, then we open the
|
||||
// editor (push here — after the dialog pop — avoids stack races).
|
||||
var ed = page.pendingEditor
|
||||
page.pendingEditor = null
|
||||
pageStack.push(Qt.resolvedUrl("EditorPage.qml"),
|
||||
{ dav: page.dav,
|
||||
notePath: ed.path,
|
||||
noteName: ed.name,
|
||||
initialContent: "# " + ed.name.replace(/\.md$/, "") + "\n\n",
|
||||
skipLoad: true })
|
||||
} else {
|
||||
reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: reload()
|
||||
|
||||
function reload() {
|
||||
if (!dav || dav.busy) return
|
||||
loading = true
|
||||
dav.listDir(currentPath)
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
notesModel.clear()
|
||||
var f = page.filterText.trim().toLowerCase()
|
||||
for (var i = 0; i < allEntries.length; i++) {
|
||||
var e = allEntries[i]
|
||||
// All files are shown (not only .md) — any file can be opened as text.
|
||||
if (f && e.name.toLowerCase().indexOf(f) < 0) continue
|
||||
notesModel.append({ name: e.name, path: e.path, isDir: e.isDir, mtime: e.mtime, size: e.size })
|
||||
}
|
||||
}
|
||||
|
||||
ListModel {
|
||||
id: notesModel
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: dav
|
||||
onListReady: {
|
||||
loading = false
|
||||
allEntries = entries
|
||||
console.log("Quill listReady:", entries.length, "entries in", page.currentPath)
|
||||
page.applyFilter()
|
||||
}
|
||||
// Refresh the list whenever a write completes elsewhere (new note,
|
||||
// editor save, delete) — the list under the stack stays fresh.
|
||||
onWriteDone: page.reload()
|
||||
onDeleteDone: page.reload()
|
||||
onFailed: {
|
||||
loading = false
|
||||
banner.show(message, true)
|
||||
}
|
||||
}
|
||||
|
||||
SilicaListView {
|
||||
id: listView
|
||||
anchors.fill: parent
|
||||
model: notesModel
|
||||
|
||||
header: Column {
|
||||
width: parent.width
|
||||
PageHeader { title: "Quill" }
|
||||
SearchField {
|
||||
id: searchField
|
||||
width: parent.width
|
||||
placeholderText: qsTr("Search")
|
||||
// Bound to the page property so the delegates (which cannot
|
||||
// see header ids) can clear it via page.filterText.
|
||||
text: page.filterText
|
||||
onTextChanged: {
|
||||
if (text !== page.filterText) page.filterText = text
|
||||
}
|
||||
}
|
||||
ListItem {
|
||||
visible: page.currentPath !== ""
|
||||
contentHeight: Theme.itemSizeSmall
|
||||
onClicked: {
|
||||
page.filterText = ""
|
||||
page.currentPath = ""
|
||||
page.reload()
|
||||
}
|
||||
Label {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
x: Theme.paddingLarge
|
||||
width: parent.width - 2 * Theme.paddingLarge
|
||||
color: Theme.highlightColor
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
text: "▲ " + page.currentPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ViewPlaceholder {
|
||||
enabled: notesModel.count === 0 && !page.loading
|
||||
text: page.currentPath === "" ? qsTr("No notes yet. Create one from the pulley menu.")
|
||||
: qsTr("Empty folder")
|
||||
}
|
||||
|
||||
delegate: ListItem {
|
||||
id: noteItem
|
||||
contentHeight: Theme.itemSizeMedium
|
||||
onClicked: {
|
||||
if (model.isDir) {
|
||||
page.filterText = ""
|
||||
page.currentPath = model.path
|
||||
page.reload()
|
||||
} else {
|
||||
pageStack.push(Qt.resolvedUrl("EditorPage.qml"),
|
||||
{ dav: page.dav, notePath: model.path, noteName: model.name })
|
||||
}
|
||||
}
|
||||
Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
x: Theme.paddingLarge
|
||||
width: parent.width - 2 * Theme.paddingLarge
|
||||
Label {
|
||||
text: model.name + (model.isDir ? "/" : "")
|
||||
color: model.isDir ? Theme.highlightColor : Theme.primaryColor
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
truncationMode: TruncationMode.Fade
|
||||
}
|
||||
Label {
|
||||
visible: !model.isDir
|
||||
text: model.mtime
|
||||
color: Theme.secondaryColor
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
truncationMode: TruncationMode.Fade
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VerticalScrollDecorator {}
|
||||
|
||||
PullDownMenu {
|
||||
MenuItem {
|
||||
text: qsTr("Settings")
|
||||
onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml"), { dav: page.dav })
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("New note")
|
||||
onClicked: {
|
||||
var dlg = pageStack.push(Qt.resolvedUrl("NewNoteDialog.qml"), { dav: page.dav })
|
||||
dlg.accepted.connect(function () {
|
||||
if (dlg.resultPath) {
|
||||
page.pendingEditor = { path: dlg.resultPath, name: dlg.resultName }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Refresh")
|
||||
onClicked: page.reload()
|
||||
}
|
||||
}
|
||||
|
||||
BusyIndicator {
|
||||
anchors.centerIn: parent
|
||||
running: page.loading
|
||||
}
|
||||
}
|
||||
|
||||
Banner { id: banner }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import QtQuick 2.2
|
||||
import Sailfish.Silica 1.0
|
||||
|
||||
Dialog {
|
||||
id: dialog
|
||||
|
||||
property var dav
|
||||
property string resultPath: ""
|
||||
property string resultName: ""
|
||||
|
||||
canAccept: nameField.text.length > 0
|
||||
|
||||
onAccepted: {
|
||||
var name = nameField.text.trim()
|
||||
if (!name) return
|
||||
var lower = name.toLowerCase()
|
||||
var hasExt = lower.indexOf(".md") === lower.length - 3
|
||||
resultName = hasExt ? name : name + ".md"
|
||||
resultPath = resultName
|
||||
dav.writeFile(resultPath, "# " + resultName.replace(/\.md$/, "") + "\n\n")
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
|
||||
DialogHeader { title: qsTr("New note") }
|
||||
|
||||
TextField {
|
||||
id: nameField
|
||||
width: parent.width
|
||||
label: qsTr("Name")
|
||||
placeholderText: qsTr("e.g. Idea")
|
||||
EnterKey.enabled: text.length > 0
|
||||
EnterKey.onClicked: dialog.accept()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import QtQuick 2.2
|
||||
import Sailfish.Silica 1.0
|
||||
import "../components/Markdown.js" as Markdown
|
||||
import "../components"
|
||||
|
||||
Page {
|
||||
id: page
|
||||
|
||||
property var dav
|
||||
property string noteName
|
||||
property string notePath: ""
|
||||
property string markdown: ""
|
||||
property string htmlText: ""
|
||||
property string baseUrl: ""
|
||||
property var imageMap: ({})
|
||||
property string pendingOpen: ""
|
||||
|
||||
Component.onCompleted: {
|
||||
// Base for relative links: the note's WebDAV folder. Relative links
|
||||
// are resolved here so Qt rich text never resolves them against the
|
||||
// page's .qml file (opening those fails with "text/qml not supported").
|
||||
baseUrl = String(appSettings.serverUrl).replace(/\s+$/, "")
|
||||
if (notePath && notePath.indexOf("/") >= 0) {
|
||||
baseUrl += "/" + notePath.substring(0, notePath.lastIndexOf("/"))
|
||||
}
|
||||
renderPreview()
|
||||
extractImages()
|
||||
}
|
||||
|
||||
function resolveImg(u) {
|
||||
if (/^[a-z]+:/i.test(u) || u.charAt(0) === "/" || u.charAt(0) === "#") return u
|
||||
return String(page.baseUrl).replace(/\/+$/, "") + "/" + u
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
try {
|
||||
htmlText = Markdown.render(markdown, baseUrl,
|
||||
{ imageMap: page.imageMap, imageLabel: qsTr("image") })
|
||||
} catch (e) {
|
||||
console.log("Quill markdown render error:", e)
|
||||
htmlText = "<tt>" + String(markdown).replace(/&/g, "&").replace(/</g, "<") + "</tt>"
|
||||
}
|
||||
}
|
||||
|
||||
function extractImages() {
|
||||
var re = /!\[[^\]]*\]\(([^)\s]+)\)/g
|
||||
var m
|
||||
while ((m = re.exec(markdown)) !== null) {
|
||||
var url = resolveImg(m[1])
|
||||
if (page.imageMap[url] === undefined) {
|
||||
page.imageMap[url] = "" // downloading
|
||||
downloader.fetch(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openWikiLink(name) {
|
||||
var dir = ""
|
||||
if (notePath && notePath.indexOf("/") >= 0) {
|
||||
dir = notePath.substring(0, notePath.lastIndexOf("/"))
|
||||
}
|
||||
page.pendingOpen = dir ? dir + "/" + name : name
|
||||
dav.readFile(page.pendingOpen)
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: dav
|
||||
onFileReady: {
|
||||
if (page.pendingOpen) {
|
||||
var p = page.pendingOpen
|
||||
page.pendingOpen = ""
|
||||
var parts = p.split("/")
|
||||
pageStack.push(Qt.resolvedUrl("EditorPage.qml"),
|
||||
{ dav: page.dav, notePath: p, noteName: parts[parts.length - 1],
|
||||
initialContent: content, skipLoad: true })
|
||||
}
|
||||
}
|
||||
onFailed: {
|
||||
if (page.pendingOpen) {
|
||||
page.pendingOpen = ""
|
||||
banner.show(qsTr("Cannot open the note: %1").arg(message), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: downloader
|
||||
onFinished: {
|
||||
if (page.imageMap[url] === undefined) return
|
||||
page.imageMap[url] = ok ? path : "error"
|
||||
page.renderPreview()
|
||||
}
|
||||
}
|
||||
|
||||
SilicaFlickable {
|
||||
anchors.fill: parent
|
||||
contentHeight: previewLabel.height + Theme.paddingLarge * 2
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
|
||||
PageHeader { title: noteName }
|
||||
|
||||
Label {
|
||||
id: previewLabel
|
||||
x: Theme.paddingLarge
|
||||
width: parent.width - 2 * Theme.paddingLarge
|
||||
text: page.htmlText
|
||||
textFormat: Text.RichText
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
linkColor: Theme.highlightColor
|
||||
onLinkActivated: {
|
||||
var l = String(link)
|
||||
if (l.indexOf("quill://") === 0) {
|
||||
page.openWikiLink(decodeURIComponent(l.substring("quill://".length)))
|
||||
} else if (l.indexOf("file:") === 0) {
|
||||
// A link that still resolved to a local file (no base):
|
||||
// opening it would fail with "text/qml not supported".
|
||||
banner.show(qsTr("Local link not supported"), true)
|
||||
} else {
|
||||
Qt.openUrlExternally(l)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VerticalScrollDecorator {}
|
||||
|
||||
PullDownMenu {
|
||||
MenuItem {
|
||||
text: qsTr("Edit")
|
||||
onClicked: pageStack.pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Banner { id: banner }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import QtQuick 2.2
|
||||
import Sailfish.Silica 1.0
|
||||
import "../components"
|
||||
|
||||
Page {
|
||||
id: page
|
||||
|
||||
property var dav
|
||||
|
||||
SilicaFlickable {
|
||||
anchors.fill: parent
|
||||
contentHeight: column.height
|
||||
|
||||
Column {
|
||||
id: column
|
||||
width: parent.width
|
||||
|
||||
PageHeader { title: qsTr("Settings") }
|
||||
|
||||
SectionHeader { text: qsTr("Connection") }
|
||||
|
||||
TextField {
|
||||
id: serverField
|
||||
width: parent.width
|
||||
label: qsTr("WebDAV URL (folder)")
|
||||
placeholderText: "https://host/remote.php/dav/files/user/Note"
|
||||
text: appSettings.serverUrl
|
||||
onTextChanged: appSettings.serverUrl = text
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: userField
|
||||
width: parent.width
|
||||
label: qsTr("Username")
|
||||
text: appSettings.username
|
||||
onTextChanged: appSettings.username = text
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: passField
|
||||
width: parent.width
|
||||
label: qsTr("Password")
|
||||
echoMode: TextInput.Password
|
||||
text: appSettings.password
|
||||
onTextChanged: appSettings.password = text
|
||||
}
|
||||
|
||||
Button {
|
||||
width: parent.width
|
||||
text: qsTr("Test connection")
|
||||
onClicked: {
|
||||
if (!appSettings.serverUrl) {
|
||||
banner.show(qsTr("Enter the folder URL"), true)
|
||||
return
|
||||
}
|
||||
banner.show(qsTr("Checking..."), false)
|
||||
dav.testConnection()
|
||||
}
|
||||
}
|
||||
|
||||
SectionHeader { text: qsTr("Language") }
|
||||
|
||||
ComboBox {
|
||||
id: langBox
|
||||
width: parent.width
|
||||
label: qsTr("Language")
|
||||
currentIndex: appSettings.language === "it" ? 2 : (appSettings.language === "en" ? 1 : 0)
|
||||
menu: ContextMenu {
|
||||
MenuItem { text: qsTr("System (default)") }
|
||||
MenuItem { text: "English" }
|
||||
MenuItem { text: "Italiano" }
|
||||
}
|
||||
onCurrentIndexChanged: {
|
||||
var v = currentIndex === 2 ? "it" : (currentIndex === 1 ? "en" : "system")
|
||||
if (v !== appSettings.language) {
|
||||
appSettings.language = v
|
||||
banner.show(qsTr("Language will be applied after restarting the app"), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
x: Theme.paddingLarge
|
||||
width: parent.width - 2 * Theme.paddingLarge
|
||||
wrapMode: Text.Wrap
|
||||
color: Theme.secondaryColor
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
text: qsTr("On Nextcloud: Settings → Security → App passwords. ") +
|
||||
qsTr("The URL is https://host/remote.php/dav/files/USER/FOLDER. ") +
|
||||
qsTr("The password is stored locally in the app config file.")
|
||||
}
|
||||
|
||||
SectionHeader { text: qsTr("Notes") }
|
||||
|
||||
Label {
|
||||
x: Theme.paddingLarge
|
||||
width: parent.width - 2 * Theme.paddingLarge
|
||||
wrapMode: Text.Wrap
|
||||
color: Theme.secondaryColor
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
text: qsTr("The app saves automatically when you leave a note. ") +
|
||||
qsTr("Supported: headings, bold, italic, code, lists, quotes, links, tables and rules.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: dav
|
||||
onConnOk: banner.show(qsTr("Connection OK"), false)
|
||||
onFailed: banner.show(message, true)
|
||||
}
|
||||
|
||||
Banner { id: banner }
|
||||
}
|
||||
Reference in New Issue
Block a user