harbour-quill 0.2.12: note markdown con sync WebDAV

This commit is contained in:
2026-08-24 14:16:11 +02:00
commit 73acfbd0b4
26 changed files with 2134 additions and 0 deletions
+65
View File
@@ -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
}
}
}
+145
View File
@@ -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)
}
}
+220
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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 };
}
+78
View File
@@ -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 };
}
+26
View File
@@ -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
}
}
+38
View File
@@ -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 })
}
}
+129
View File
@@ -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 }
}
+186
View File
@@ -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 }
}
+37
View File
@@ -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()
}
}
}
+138
View File
@@ -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, "&amp;").replace(/</g, "&lt;") + "</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 }
}
+114
View File
@@ -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 }
}