harbour-quill 0.2.12: note markdown con sync WebDAV
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
build/
|
||||||
|
build-*/
|
||||||
|
*.rpm
|
||||||
|
*.user
|
||||||
|
Makefile
|
||||||
|
.qmake.stash
|
||||||
|
*.qm
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
TEMPLATE = app
|
||||||
|
TARGET = harbour-quill
|
||||||
|
|
||||||
|
CONFIG += sailfishapp qt
|
||||||
|
|
||||||
|
# QQuickView (QtQuick), QQmlContext (QtQml), QSettings (QtCore).
|
||||||
|
QT += quick qml
|
||||||
|
|
||||||
|
SOURCES += \
|
||||||
|
src/main.cpp \
|
||||||
|
src/settings.cpp \
|
||||||
|
src/downloader.cpp
|
||||||
|
|
||||||
|
HEADERS += \
|
||||||
|
src/settings.h \
|
||||||
|
src/downloader.h
|
||||||
|
|
||||||
|
DISTFILES += \
|
||||||
|
qml/harbour-quill.qml \
|
||||||
|
qml/cover/CoverPage.qml \
|
||||||
|
qml/pages/MainPage.qml \
|
||||||
|
qml/pages/EditorPage.qml \
|
||||||
|
qml/pages/PreviewPage.qml \
|
||||||
|
qml/pages/SettingsPage.qml \
|
||||||
|
qml/pages/NewNoteDialog.qml \
|
||||||
|
qml/components/DavClient.qml \
|
||||||
|
qml/components/Banner.qml \
|
||||||
|
qml/components/Markdown.js \
|
||||||
|
qml/components/PropfindParser.js \
|
||||||
|
translations/harbour-quill_it.ts
|
||||||
|
|
||||||
|
SAILFISHAPP_ICONS = 86x86 108x108 128x128 172x172
|
||||||
|
|
||||||
|
# Translations are NOT handled by the sailfishapp_i18n template on the 5.1 kit
|
||||||
|
# (the .qm is never generated/installed reliably). The spec builds the .qm
|
||||||
|
# with lrelease and installs it into /usr/share/translations directly;
|
||||||
|
# libsailfishapp loads harbour-quill_<locale>.qm automatically.
|
||||||
|
|
||||||
|
# The sailfishapp template does NOT install the .desktop by itself on the
|
||||||
|
# 5.1 kit — declare it explicitly or rpmbuild fails with
|
||||||
|
# "File not found: .../usr/share/applications/harbour-quill.desktop".
|
||||||
|
desktop.files = $$PWD/rpm/harbour-quill.desktop
|
||||||
|
desktop.path = /usr/share/applications
|
||||||
|
|
||||||
|
INSTALLS += desktop
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
@@ -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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Quill
|
||||||
|
Comment=Markdown notes on Nextcloud
|
||||||
|
Exec=harbour-quill
|
||||||
|
Icon=harbour-quill
|
||||||
|
Categories=Office;TextEditor;Qt;
|
||||||
|
X-Nemo-Application-Type=silica-qt5
|
||||||
|
X-Nemo-Single-Instance=true
|
||||||
|
|
||||||
|
# Profilo SailJail esplicito: senza X-Sailjail la sandbox di Sailfish 5.1 usa il
|
||||||
|
# profilo default, che nasconde ~/.config/harbour (config non persistente).
|
||||||
|
# Con OrganizationName/ApplicationName la sandbox rende persistente
|
||||||
|
# ~/.config/harbour/quill/ (AppConfigLocation). Internet = WebDAV Nextcloud.
|
||||||
|
[X-Sailjail]
|
||||||
|
Permissions=Internet
|
||||||
|
OrganizationName=harbour
|
||||||
|
ApplicationName=quill
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
Name: harbour-quill
|
||||||
|
Summary: Markdown notes client (Obsidian-like) for Sailfish OS
|
||||||
|
Version: 0.2.12
|
||||||
|
Release: 1
|
||||||
|
Group: Applications/Office
|
||||||
|
License: GPLv3
|
||||||
|
URL: https://github.com/carlobaratto/harbour-quill
|
||||||
|
Source: %{name}-%{version}.tar.gz
|
||||||
|
|
||||||
|
# Disable the auto-generated debuginfo subpackage: sfdk on the 5.1 kit
|
||||||
|
# elaborates spec files without Source0 in a way that produces a duplicate
|
||||||
|
# debuginfo package declaration ("debuginfo already exists" parse error).
|
||||||
|
%global debug_package %{nil}
|
||||||
|
|
||||||
|
Requires: sailfishsilica-qt5 >= 0.10.0
|
||||||
|
|
||||||
|
BuildRequires: sailfishsilica-qt5-devel
|
||||||
|
BuildRequires: qt5-qttools-linguist
|
||||||
|
|
||||||
|
%description
|
||||||
|
Quill: markdown notes stored in a WebDAV folder (e.g. a Nextcloud
|
||||||
|
directory). Browse, edit, and preview .md notes with automatic save.
|
||||||
|
|
||||||
|
%prep
|
||||||
|
%setup -q -n %{name}-%{version}
|
||||||
|
|
||||||
|
%build
|
||||||
|
%qmake5
|
||||||
|
make %{?_smp_mflags}
|
||||||
|
|
||||||
|
# Translation catalogue: generated explicitly with lrelease (the sailfishapp_i18n
|
||||||
|
# template does not produce the .qm reliably on the 5.1 kit). The .ts lives in
|
||||||
|
# the source dir (sibling of the build dir); the .qm goes straight into the
|
||||||
|
# build dir (no subfolder — it may not exist).
|
||||||
|
lrelease %{_builddir}/../%{name}-%{version}/translations/harbour-quill_it.ts -qm %{_builddir}/harbour-quill_it.qm
|
||||||
|
|
||||||
|
%install
|
||||||
|
rm -rf %{buildroot}
|
||||||
|
%qmake5_install
|
||||||
|
|
||||||
|
# Italian translation: installed in the app data dir (the RPM validator
|
||||||
|
# rejects /usr/share/translations); main.cpp loads it from there.
|
||||||
|
mkdir -p %{buildroot}%{_datadir}/%{name}/translations
|
||||||
|
install -m 0644 %{_builddir}/harbour-quill_it.qm %{buildroot}%{_datadir}/%{name}/translations/
|
||||||
|
|
||||||
|
# Desktop file installed from the spec, NOT via qmake INSTALLS: the 5.1 kit
|
||||||
|
# Makefile ignores the qmake desktop target, so rpmbuild fails with
|
||||||
|
# "File not found: .../usr/share/applications/harbour-quill.desktop".
|
||||||
|
# NB: sfdk uses the sources live (shadow build): the build dir sits next to
|
||||||
|
# the source dir, the tarball is NOT extracted into the build dir, and rpm/
|
||||||
|
# is NOT copied there either.
|
||||||
|
mkdir -p %{buildroot}/usr/share/applications
|
||||||
|
if [ -f %{_builddir}/../%{name}-%{version}/rpm/%{name}.desktop ]; then
|
||||||
|
install -m 0644 %{_builddir}/../%{name}-%{version}/rpm/%{name}.desktop %{buildroot}/usr/share/applications/
|
||||||
|
elif [ -f %{_builddir}/rpm/%{name}.desktop ]; then
|
||||||
|
install -m 0644 %{_builddir}/rpm/%{name}.desktop %{buildroot}/usr/share/applications/
|
||||||
|
elif [ -f %{_builddir}/%{name}-%{version}/rpm/%{name}.desktop ]; then
|
||||||
|
install -m 0644 %{_builddir}/%{name}-%{version}/rpm/%{name}.desktop %{buildroot}/usr/share/applications/
|
||||||
|
else
|
||||||
|
echo "ERROR: %{name}.desktop not found (checked sibling source, build dir, extracted source)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
%files
|
||||||
|
%defattr(-,root,root,-)
|
||||||
|
/usr/bin/%{name}
|
||||||
|
/usr/share/%{name}/qml/
|
||||||
|
/usr/share/applications/%{name}.desktop
|
||||||
|
/usr/share/icons/hicolor/*/apps/%{name}.png
|
||||||
|
%{_datadir}/%{name}/translations/%{name}*.qm
|
||||||
|
|
||||||
|
%changelog
|
||||||
|
* Wed Aug 19 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.2.12-1
|
||||||
|
- Sandbox SailJail (Sailfish 4.4+/5.x): profilo X-Sailjail esplicito nel desktop
|
||||||
|
file (Internet, org harbour/app quill) e config in AppConfigLocation
|
||||||
|
(~/.config/harbour/quill/quill.conf, l'unica zona persistente della sandbox).
|
||||||
|
Senza profilo la sandbox nascondeva ~/.config/harbour e le impostazioni dal
|
||||||
|
launcher sparivano. Migrazione automatica dal vecchio file per-utente.
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.2.11-1
|
||||||
|
- App icon: smaller M, arrow detached further to the right
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.2.10-1
|
||||||
|
- App icon: markdown glyph with downward arrow on the right of the M
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.2.9-1
|
||||||
|
- App icon: violet gradient with markdown glyph, arrow moved to the right of the M
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.2.8-1
|
||||||
|
- New app icon: teardrop shape pointing right (Tooter style) with markdown glyph
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.2.7-1
|
||||||
|
- New app icon: dark panel with a coloured frame (Tooter/SubMusic style)
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.2.6-1
|
||||||
|
- New app icon: markdown M-down glyph on a bright violet gradient
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.2.5-1
|
||||||
|
- New app icon (Jolla-style gradient + document glyph)
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.2.4-1
|
||||||
|
- Fix crash entering a folder: search field accessed from the delegate (header ids not visible there)
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.2.3-1
|
||||||
|
- Toast banner anchored at the top (was clipped at the screen bottom)
|
||||||
|
- Note list shows all files (not only .md); added list diagnostics to the log
|
||||||
|
- Settings: language selector (system / English / Italiano), applied on restart
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.2.2-1
|
||||||
|
- Fix packaging: lrelease writes the .qm straight into the build dir (subfolder did not exist)
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.2.1-1
|
||||||
|
- Fix packaging: translation .qm built with lrelease and installed from the spec (5.1 template does not)
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.2.0-1
|
||||||
|
- V2: note search (title filter), navigable [[wiki-links]], inline images with local cache
|
||||||
|
- Localization: English default, Italian translation
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.1.12-1
|
||||||
|
- Fix links in preview: relative links resolved against the note's WebDAV folder; local file: links blocked with a banner
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.1.11-1
|
||||||
|
- New: GFM table rendering (delimiter row, column alignment, inline markup in cells)
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.1.10-1
|
||||||
|
- Fix preview render: JS files rewritten with top-level functions (QML namespace import exposes them directly)
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.1.9-1
|
||||||
|
- Fix preview: strip UTF-8 BOM in markdown render; log render errors instead of silent fallback
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.1.8-1
|
||||||
|
- Fix preview: import Markdown.js by file name (directory import exposes only .qml)
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.1.7-1
|
||||||
|
- Fix blank screen on launch: load QML via SailfishApp::pathTo (relative QUrl failed from the launcher)
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.1.6-1
|
||||||
|
- Fix packaging: desktop installed from sibling source dir (shadow build layout; qmake desktop target ignored by 5.1 kit)
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.1.5-1
|
||||||
|
- Fix packaging: removed section names from spec comments/changelog (parser saw a second install section)
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.1.4-1
|
||||||
|
- Fix packaging: desktop install path with fallback (sfdk does not extract the tarball)
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.1.3-1
|
||||||
|
- Fix sfdk 5.1 build-requires: added Source0 and disabled auto debuginfo (duplicate debuginfo package)
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.1.2-1
|
||||||
|
- Fix packaging: desktop file installed from the spec install section (independent of the qmake Makefile)
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.1.1-1
|
||||||
|
- Fix packaging: desktop file installed via qmake INSTALLS (was missing from buildroot)
|
||||||
|
|
||||||
|
* Sun Aug 16 2026 Carlo Baratto <carlobaratto@it> - 0.1.0-1
|
||||||
|
- Initial release: WebDAV browsing, markdown editor, preview, new/delete notes
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#include "downloader.h"
|
||||||
|
|
||||||
|
#include <QNetworkRequest>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include <QCryptographicHash>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QDebug>
|
||||||
|
|
||||||
|
Downloader::Downloader(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Downloader::authHeader() const
|
||||||
|
{
|
||||||
|
QSettings s("harbour", "harbour-quill");
|
||||||
|
const QString user = s.value("username", "").toString();
|
||||||
|
if (user.isEmpty())
|
||||||
|
return QString();
|
||||||
|
const QByteArray creds = (user + ":" + s.value("password", "").toString()).toUtf8();
|
||||||
|
return QString::fromLatin1("Basic ") + QString::fromLatin1(creds.toBase64());
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Downloader::cachePath(const QString &url) const
|
||||||
|
{
|
||||||
|
const QByteArray hash = QCryptographicHash::hash(url.toUtf8(), QCryptographicHash::Md5).toHex();
|
||||||
|
QString ext = ".img";
|
||||||
|
const QString lower = url.toLower();
|
||||||
|
if (lower.contains(".png")) ext = ".png";
|
||||||
|
else if (lower.contains(".jpg") || lower.contains(".jpeg")) ext = ".jpg";
|
||||||
|
else if (lower.contains(".gif")) ext = ".gif";
|
||||||
|
else if (lower.contains(".webp")) ext = ".webp";
|
||||||
|
else if (lower.contains(".svg")) ext = ".svg";
|
||||||
|
return QStandardPaths::writableLocation(QStandardPaths::CacheLocation)
|
||||||
|
+ QStringLiteral("/quill-images/")
|
||||||
|
+ QString::fromLatin1(hash) + ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Downloader::fetch(const QString &url)
|
||||||
|
{
|
||||||
|
const QString path = cachePath(url);
|
||||||
|
if (QFile::exists(path)) {
|
||||||
|
emit finished(url, path, true, QString());
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
QDir().mkpath(QFileInfo(path).absolutePath());
|
||||||
|
|
||||||
|
const QUrl u(url);
|
||||||
|
QNetworkRequest request(u); // NB: intermediate variable (most vexing parse)
|
||||||
|
const QString auth = authHeader();
|
||||||
|
if (!auth.isEmpty())
|
||||||
|
request.setRawHeader("Authorization", auth.toUtf8());
|
||||||
|
|
||||||
|
QNetworkReply *reply = m_nam.get(request);
|
||||||
|
m_pending.insert(reply, qMakePair(url, path));
|
||||||
|
connect(reply, SIGNAL(finished()), this, SLOT(onFinished()));
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Downloader::onFinished()
|
||||||
|
{
|
||||||
|
QNetworkReply *reply = qobject_cast<QNetworkReply *>(sender());
|
||||||
|
if (!reply)
|
||||||
|
return;
|
||||||
|
if (!m_pending.contains(reply)) {
|
||||||
|
reply->deleteLater();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QPair<QString, QString> p = m_pending.take(reply);
|
||||||
|
const QString url = p.first;
|
||||||
|
const QString path = p.second;
|
||||||
|
|
||||||
|
bool ok = false;
|
||||||
|
QString error;
|
||||||
|
if (reply->error() == QNetworkReply::NoError) {
|
||||||
|
QFile f(path);
|
||||||
|
if (f.open(QIODevice::WriteOnly)) {
|
||||||
|
f.write(reply->readAll());
|
||||||
|
f.close();
|
||||||
|
ok = true;
|
||||||
|
} else {
|
||||||
|
error = QStringLiteral("cannot write cache");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error = reply->errorString();
|
||||||
|
}
|
||||||
|
reply->deleteLater();
|
||||||
|
emit finished(url, path, ok, error);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#ifndef DOWNLOADER_H
|
||||||
|
#define DOWNLOADER_H
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QPair>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
// Downloads images to a local cache (MD5(url) + extension). Exposed to QML
|
||||||
|
// as a context property "downloader": fetch(url) returns the expected cache
|
||||||
|
// path immediately and emits finished(url, path, ok, error) when done.
|
||||||
|
// Sends Basic auth from the app settings (Nextcloud images are protected).
|
||||||
|
class Downloader : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit Downloader(QObject *parent = 0);
|
||||||
|
|
||||||
|
Q_INVOKABLE QString fetch(const QString &url);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void finished(const QString &url, const QString &path, bool ok, const QString &error);
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void onFinished();
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString authHeader() const;
|
||||||
|
QString cachePath(const QString &url) const;
|
||||||
|
|
||||||
|
QNetworkAccessManager m_nam;
|
||||||
|
QHash<QNetworkReply *, QPair<QString, QString> > m_pending;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // DOWNLOADER_H
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#include <QGuiApplication>
|
||||||
|
#include <QQuickView>
|
||||||
|
#include <QQmlContext>
|
||||||
|
#include <QUrl>
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QLocale>
|
||||||
|
#include <QTranslator>
|
||||||
|
#include <sailfishapp.h>
|
||||||
|
|
||||||
|
#include "settings.h"
|
||||||
|
#include "downloader.h"
|
||||||
|
|
||||||
|
int main(int argc, char *argv[])
|
||||||
|
{
|
||||||
|
qDebug() << "Quill v0.2.12 build" << __DATE__ << __TIME__;
|
||||||
|
|
||||||
|
QGuiApplication *app = SailfishApp::application(argc, argv);
|
||||||
|
|
||||||
|
// Identita' applicazione allineata al profilo X-Sailjail del desktop file:
|
||||||
|
// QStandardPaths::AppConfigLocation diventa ~/.config/harbour/quill,
|
||||||
|
// l'unica zona config persistente della sandbox di Sailfish 5.1.
|
||||||
|
app->setOrganizationName(QStringLiteral("harbour"));
|
||||||
|
app->setApplicationName(QStringLiteral("quill"));
|
||||||
|
|
||||||
|
Settings *settings = new Settings();
|
||||||
|
settings->setParent(app);
|
||||||
|
|
||||||
|
// User-chosen language (Settings -> Language). "system" = follow the
|
||||||
|
// Sailfish UI language. "en" = force English. "it" = force Italian.
|
||||||
|
// The .qm lives in the app data dir (the RPM validator rejects
|
||||||
|
// /usr/share/translations), so libsailfishapp's auto-load cannot be
|
||||||
|
// used: resolve the language here and install our own translator.
|
||||||
|
QString lang = settings->language();
|
||||||
|
if (lang == "system") {
|
||||||
|
const QString sys = QLocale::system().name();
|
||||||
|
lang = sys.startsWith(QLatin1String("it")) ? QStringLiteral("it")
|
||||||
|
: QStringLiteral("en");
|
||||||
|
}
|
||||||
|
if (lang == "en" || lang == "it") {
|
||||||
|
const QList<QTranslator *> trs = app->findChildren<QTranslator *>();
|
||||||
|
foreach (QTranslator *t, trs) {
|
||||||
|
app->removeTranslator(t);
|
||||||
|
t->deleteLater();
|
||||||
|
}
|
||||||
|
if (lang == "it") {
|
||||||
|
QTranslator *translator = new QTranslator(app);
|
||||||
|
if (translator->load(QStringLiteral("harbour-quill_it"),
|
||||||
|
QStringLiteral("/usr/share/harbour-quill/translations"))) {
|
||||||
|
app->installTranslator(translator);
|
||||||
|
} else {
|
||||||
|
qWarning() << "Quill: cannot load harbour-quill_it.qm";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Downloader *downloader = new Downloader(app);
|
||||||
|
|
||||||
|
QQuickView *view = SailfishApp::createView();
|
||||||
|
view->rootContext()->setContextProperty("appSettings", settings);
|
||||||
|
view->rootContext()->setContextProperty("downloader", downloader);
|
||||||
|
|
||||||
|
// pathTo() resolves relative to the installed app dir (/usr/share/harbour-quill);
|
||||||
|
// a plain relative QUrl would resolve against the CWD and fail when the app
|
||||||
|
// is launched from the launcher -> blank first screen.
|
||||||
|
view->setSource(SailfishApp::pathTo(QStringLiteral("qml/harbour-quill.qml")));
|
||||||
|
view->showFullScreen();
|
||||||
|
|
||||||
|
return app->exec();
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#include "settings.h"
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
|
||||||
|
QString Settings::configPath()
|
||||||
|
{
|
||||||
|
// Path canonico per la sandbox SailJail di Sailfish 4.4+/5.x: con org/app
|
||||||
|
// impostati (harbour/quill, vedi main.cpp e X-Sailjail nel desktop file),
|
||||||
|
// AppConfigLocation = ~/.config/harbour/quill, l'unica zona config che la
|
||||||
|
// sandbox rende PERSISTENTE tra gli avvii.
|
||||||
|
const QString dir = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation);
|
||||||
|
QDir().mkpath(dir);
|
||||||
|
return dir + QStringLiteral("/quill.conf");
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings::Settings(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_settings(configPath(), QSettings::IniFormat)
|
||||||
|
{
|
||||||
|
migrateIfNeeded();
|
||||||
|
qDebug() << "Quill settings file:" << m_settings.fileName();
|
||||||
|
qDebug() << "Quill settings read: serverUrl=[" << serverUrl() << "] user=["
|
||||||
|
<< username() << "] language=" << language();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Settings::migrateIfNeeded()
|
||||||
|
{
|
||||||
|
if (!serverUrl().isEmpty())
|
||||||
|
return; // il file canonico ha gia' una configurazione
|
||||||
|
// Sorgenti candidate: il vecchio file per-utente e i path delle build
|
||||||
|
// precedenti. La prima con valori vince.
|
||||||
|
const QStringList candidates = QStringList()
|
||||||
|
<< QDir::homePath() + QStringLiteral("/.config/harbour/harbour-quill.conf")
|
||||||
|
<< QStringLiteral("/home/rootitanium/.config/harbour/harbour-quill.conf")
|
||||||
|
<< QStringLiteral("/home/nemo/.config/harbour/harbour-quill.conf")
|
||||||
|
<< QStringLiteral("/home/defaultuser/.config/harbour/harbour-quill.conf");
|
||||||
|
for (const QString &cand : candidates) {
|
||||||
|
QSettings src(cand, QSettings::IniFormat);
|
||||||
|
const QString url = src.value(QStringLiteral("serverUrl")).toString();
|
||||||
|
if (url.isEmpty())
|
||||||
|
continue;
|
||||||
|
m_settings.setValue(QStringLiteral("serverUrl"), url);
|
||||||
|
m_settings.setValue(QStringLiteral("username"), src.value(QStringLiteral("username")));
|
||||||
|
m_settings.setValue(QStringLiteral("password"), src.value(QStringLiteral("password")));
|
||||||
|
m_settings.setValue(QStringLiteral("language"),
|
||||||
|
src.value(QStringLiteral("language"), QStringLiteral("system")));
|
||||||
|
m_settings.sync();
|
||||||
|
qDebug() << "Quill: importati i valori da" << cand;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Settings::serverUrl() const
|
||||||
|
{
|
||||||
|
return m_settings.value("serverUrl", "").toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Settings::setServerUrl(const QString &value)
|
||||||
|
{
|
||||||
|
if (serverUrl() == value)
|
||||||
|
return;
|
||||||
|
m_settings.setValue("serverUrl", value);
|
||||||
|
m_settings.sync();
|
||||||
|
emit serverUrlChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Settings::username() const
|
||||||
|
{
|
||||||
|
return m_settings.value("username", "").toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Settings::setUsername(const QString &value)
|
||||||
|
{
|
||||||
|
if (username() == value)
|
||||||
|
return;
|
||||||
|
m_settings.setValue("username", value);
|
||||||
|
m_settings.sync();
|
||||||
|
emit usernameChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Settings::password() const
|
||||||
|
{
|
||||||
|
return m_settings.value("password", "").toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Settings::setPassword(const QString &value)
|
||||||
|
{
|
||||||
|
if (password() == value)
|
||||||
|
return;
|
||||||
|
m_settings.setValue("password", value);
|
||||||
|
m_settings.sync();
|
||||||
|
emit passwordChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Settings::language() const
|
||||||
|
{
|
||||||
|
return m_settings.value("language", "system").toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Settings::setLanguage(const QString &value)
|
||||||
|
{
|
||||||
|
if (language() == value)
|
||||||
|
return;
|
||||||
|
m_settings.setValue("language", value);
|
||||||
|
m_settings.sync();
|
||||||
|
emit languageChanged();
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#ifndef SETTINGS_H
|
||||||
|
#define SETTINGS_H
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QSettings>
|
||||||
|
|
||||||
|
class Settings : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
Q_PROPERTY(QString serverUrl READ serverUrl WRITE setServerUrl NOTIFY serverUrlChanged)
|
||||||
|
Q_PROPERTY(QString username READ username WRITE setUsername NOTIFY usernameChanged)
|
||||||
|
Q_PROPERTY(QString password READ password WRITE setPassword NOTIFY passwordChanged)
|
||||||
|
Q_PROPERTY(QString language READ language WRITE setLanguage NOTIFY languageChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit Settings(QObject *parent = 0);
|
||||||
|
|
||||||
|
QString serverUrl() const;
|
||||||
|
void setServerUrl(const QString &value);
|
||||||
|
|
||||||
|
QString username() const;
|
||||||
|
void setUsername(const QString &value);
|
||||||
|
|
||||||
|
QString password() const;
|
||||||
|
void setPassword(const QString &value);
|
||||||
|
|
||||||
|
QString language() const;
|
||||||
|
void setLanguage(const QString &value);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void serverUrlChanged();
|
||||||
|
void usernameChanged();
|
||||||
|
void passwordChanged();
|
||||||
|
void languageChanged();
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Path canonico per la sandbox SailJail (Sailfish 4.4+/5.x):
|
||||||
|
// AppConfigLocation = ~/.config/harbour/quill, l'unica zona config
|
||||||
|
// persistente della sandbox. Senza profilo X-Sailjail esplicito la
|
||||||
|
// sandbox nasconde ~/.config/harbour e le impostazioni dal launcher
|
||||||
|
// non persistono (config funzionante solo da SSH).
|
||||||
|
static QString configPath();
|
||||||
|
// Importa i valori dal vecchio file per-utente (~/.config/harbour/...)
|
||||||
|
// e dai path usati dalle build precedenti.
|
||||||
|
void migrateIfNeeded();
|
||||||
|
|
||||||
|
QSettings m_settings;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // SETTINGS_H
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// Tests for Markdown.js and PropfindParser.js (pure ES5, runnable in node).
|
||||||
|
"use strict";
|
||||||
|
var assert = require("assert");
|
||||||
|
var fs = require("fs");
|
||||||
|
var path = require("path");
|
||||||
|
|
||||||
|
var comp = path.join(__dirname, "..", "qml", "components");
|
||||||
|
var Markdown = require(path.join(comp, "Markdown.js"));
|
||||||
|
var PropfindParser = require(path.join(comp, "PropfindParser.js"));
|
||||||
|
|
||||||
|
// ── Markdown ──────────────────────────────────────────────────────────────
|
||||||
|
var md = "# Titolo\n\nTesto con **grassetto**, *corsivo* e `codice`.\n\n" +
|
||||||
|
"- uno\n- due\n\n1. primo\n2. secondo\n\n> citazione\n\n" +
|
||||||
|
"[link](https://example.com)\n\n```\nvar x = 1;\n```\n\n---\n\n~~cancellato~~ e <b>html</b>";
|
||||||
|
|
||||||
|
var html = Markdown.render(md);
|
||||||
|
console.log("── Markdown output ──\n" + html + "\n────────────────────");
|
||||||
|
|
||||||
|
assert(html.indexOf("<h1>Titolo</h1>") >= 0, "heading");
|
||||||
|
assert(html.indexOf("<b>grassetto</b>") >= 0, "bold");
|
||||||
|
assert(html.indexOf("<i>corsivo</i>") >= 0, "italic");
|
||||||
|
assert(html.indexOf("<tt>codice</tt>") >= 0, "inline code");
|
||||||
|
assert(html.indexOf("<ul><li>uno</li><li>due</li></ul>") >= 0, "ul");
|
||||||
|
assert(html.indexOf("<ol><li>primo</li><li>secondo</li></ol>") >= 0, "ol");
|
||||||
|
assert(html.indexOf("<i>citazione</i>") >= 0, "quote");
|
||||||
|
assert(html.indexOf('<a href="https://example.com">link</a>') >= 0, "link");
|
||||||
|
assert(html.indexOf("<pre") >= 0 && html.indexOf("var x = 1;") >= 0, "code fence");
|
||||||
|
assert(html.indexOf("<hr/>") >= 0, "hr");
|
||||||
|
assert(html.indexOf("<s>cancellato</s>") >= 0, "strike");
|
||||||
|
assert(html.indexOf("<b>html</b>") >= 0, "html escaped");
|
||||||
|
assert(html.indexOf("<b>") < html.indexOf("<b>"), "escape before markup");
|
||||||
|
|
||||||
|
// nested bold/italic: **a *b* c**
|
||||||
|
var h2 = Markdown.render("**a *b* c**");
|
||||||
|
assert(h2.indexOf("<b>a <i>b</i> c</b>") >= 0, "nested bold+italic: " + h2);
|
||||||
|
|
||||||
|
// italics must not swallow bold markers
|
||||||
|
var h3 = Markdown.render("*solo* e **doppio**");
|
||||||
|
assert(h3.indexOf("<i>solo</i>") >= 0 && h3.indexOf("<b>doppio</b>") >= 0, "mixed: " + h3);
|
||||||
|
|
||||||
|
// empty input
|
||||||
|
assert(Markdown.render("") === "", "empty");
|
||||||
|
assert(Markdown.render(null) === "", "null");
|
||||||
|
|
||||||
|
// UTF-8 BOM must not break the first heading
|
||||||
|
var h4 = Markdown.render("\uFEFF# Titolo con BOM\n\nTesto");
|
||||||
|
assert(h4.indexOf("<h1>Titolo con BOM</h1>") >= 0, "BOM stripped: " + h4);
|
||||||
|
assert(h4.indexOf("\uFEFF") < 0, "no BOM in output");
|
||||||
|
|
||||||
|
// heading without space is not a heading (standard markdown)
|
||||||
|
var h5 = Markdown.render("#NienteSpazio");
|
||||||
|
assert(h5.indexOf("<h1>") < 0, "no-space hash is not a heading");
|
||||||
|
|
||||||
|
// ── tables (GFM) ──
|
||||||
|
var tbl = Markdown.render("| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |");
|
||||||
|
console.log("── table output ──\n" + tbl);
|
||||||
|
assert(tbl.indexOf("<table") >= 0, "table open");
|
||||||
|
assert(tbl.indexOf("<th><b>A</b></th>") >= 0, "th header: " + tbl);
|
||||||
|
assert(tbl.indexOf("<td>1</td>") >= 0, "td data");
|
||||||
|
assert(tbl.indexOf("<td>4</td>") >= 0, "last cell");
|
||||||
|
assert(tbl.indexOf("</table>") === tbl.lastIndexOf("</table>"), "one table only");
|
||||||
|
|
||||||
|
// alignment from delimiter
|
||||||
|
var tbl2 = Markdown.render("| A | B | C |\n|:--|--:|:-:|\n| x | y | z |");
|
||||||
|
assert(tbl2.indexOf('align="left"') >= 0, "left align");
|
||||||
|
assert(tbl2.indexOf('align="right"') >= 0, "right align");
|
||||||
|
assert(tbl2.indexOf('align="center"') >= 0, "center align");
|
||||||
|
|
||||||
|
// inline formatting inside cells
|
||||||
|
var tbl3 = Markdown.render("| **X** | `code` |\n|---|---|\n| [l](http://e.it) | *i* |");
|
||||||
|
assert(tbl3.indexOf("<b>X</b>") >= 0, "bold in cell");
|
||||||
|
assert(tbl3.indexOf("<tt>code</tt>") >= 0, "code in cell");
|
||||||
|
assert(tbl3.indexOf("<a href=\"http://e.it\">l</a>") >= 0, "link in cell");
|
||||||
|
|
||||||
|
// pipes without delimiter row stay a paragraph; bare dashes are still hr
|
||||||
|
var notTbl = Markdown.render("a | b\n\n---");
|
||||||
|
assert(notTbl.indexOf("<table") < 0, "no fake table");
|
||||||
|
assert(notTbl.indexOf("<hr/>") >= 0, "bare hr kept");
|
||||||
|
|
||||||
|
// single column table
|
||||||
|
var tbl4 = Markdown.render("| Solo |\n|---|\n| x |");
|
||||||
|
assert(tbl4.indexOf("<th><b>Solo</b></th>") >= 0, "single col header");
|
||||||
|
|
||||||
|
// ── links with baseUrl ──
|
||||||
|
var BASE = "https://host/remote.php/dav/files/u/Note";
|
||||||
|
var lk1 = Markdown.render("[rel](altra.md)", BASE);
|
||||||
|
assert(lk1.indexOf('href="' + BASE + '/altra.md"') >= 0, "relative link resolved: " + lk1);
|
||||||
|
var lk2 = Markdown.render("[web](https://example.com)", BASE);
|
||||||
|
assert(lk2.indexOf('href="https://example.com"') >= 0, "absolute link untouched");
|
||||||
|
var lk3 = Markdown.render("[a](#ancora) e [b](/abs)", BASE);
|
||||||
|
assert(lk3.indexOf('href="#ancora"') >= 0 && lk3.indexOf('href="/abs"') >= 0, "anchor/absolute path untouched");
|
||||||
|
var lk4 = Markdown.render("[rel](altra.md)");
|
||||||
|
assert(lk4.indexOf('href="altra.md"') >= 0, "no base: relative link left as-is");
|
||||||
|
|
||||||
|
// ── wiki links [[Nota]] ──
|
||||||
|
var wk1 = Markdown.render("Vedi [[altra nota]] e [[nota.md]]");
|
||||||
|
assert(wk1.indexOf('<a href="quill://altra%20nota.md">altra nota</a>') >= 0, "wiki link no ext: " + wk1);
|
||||||
|
assert(wk1.indexOf('<a href="quill://nota.md">nota</a>') >= 0, "wiki link with ext");
|
||||||
|
var wk2 = Markdown.render("[[Sub/pippo]]");
|
||||||
|
assert(wk2.indexOf('quill://Sub%2Fpippo.md') >= 0, "wiki link with path");
|
||||||
|
|
||||||
|
// ── images ──
|
||||||
|
var img1 = Markdown.render("", BASE, { imageMap: {}, imageLabel: "immagine" });
|
||||||
|
assert(img1.indexOf("<i>[immagine: foto]</i>") >= 0, "image placeholder with label: " + img1);
|
||||||
|
var m = {};
|
||||||
|
m[BASE + "/img.png"] = "/home/nemo/.cache/quill-images/ab12.png";
|
||||||
|
var img2 = Markdown.render("", BASE, { imageMap: m, imageLabel: "immagine" });
|
||||||
|
assert(img2.indexOf('<img src="file:///home/nemo/.cache/quill-images/ab12.png" width="350" height="-1" alt="foto"/>') >= 0,
|
||||||
|
"image inline from cache: " + img2);
|
||||||
|
var img3 = Markdown.render("", "", { imageMap: {} });
|
||||||
|
assert(img3.indexOf("<i>[image: x]</i>") >= 0, "default label 'image'");
|
||||||
|
|
||||||
|
console.log("Markdown tests: OK");
|
||||||
|
|
||||||
|
// ── PropfindParser ────────────────────────────────────────────────────────
|
||||||
|
var xml = '<?xml version="1.0"?>' +
|
||||||
|
'<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">' +
|
||||||
|
'<d:response>' +
|
||||||
|
'<d:href>/remote.php/dav/files/carlo/Note/</d:href>' +
|
||||||
|
'<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop>' +
|
||||||
|
'<d:status>HTTP/1.1 200 OK</d:status></d:propstat>' +
|
||||||
|
'</d:response>' +
|
||||||
|
'<d:response>' +
|
||||||
|
'<d:href>/remote.php/dav/files/carlo/Note/Sub%20folder/</d:href>' +
|
||||||
|
'<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop>' +
|
||||||
|
'<d:status>HTTP/1.1 200 OK</d:status></d:propstat>' +
|
||||||
|
'</d:response>' +
|
||||||
|
'<d:response>' +
|
||||||
|
'<d:href>/remote.php/dav/files/carlo/Note/Idea%20spaziale.md</d:href>' +
|
||||||
|
'<d:propstat><d:prop>' +
|
||||||
|
'<d:getlastmodified>Fri, 14 Aug 2026 09:30:00 GMT</d:getlastmodified>' +
|
||||||
|
'<d:getcontentlength>1234</d:getcontentlength>' +
|
||||||
|
'</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>' +
|
||||||
|
'</d:response>' +
|
||||||
|
'<d:response>' +
|
||||||
|
'<d:href>/remote.php/dav/files/carlo/Note/readme.txt</d:href>' +
|
||||||
|
'<d:propstat><d:prop><d:getcontentlength>42</d:getcontentlength></d:prop>' +
|
||||||
|
'<d:status>HTTP/1.1 200 OK</d:status></d:propstat>' +
|
||||||
|
'</d:response>' +
|
||||||
|
'</d:multistatus>';
|
||||||
|
|
||||||
|
var baseUrl = "https://nextcloud.hackatoniclife.com/remote.php/dav/files/carlo/Note";
|
||||||
|
var entries = PropfindParser.parse(xml, baseUrl);
|
||||||
|
|
||||||
|
console.log("── Propfind entries ──");
|
||||||
|
entries.forEach(function (e) { console.log(JSON.stringify(e)); });
|
||||||
|
|
||||||
|
assert.strictEqual(entries.length, 3, "root excluded, 3 entries (1 dir + 2 files)");
|
||||||
|
assert.strictEqual(entries[0].name, "Sub folder", "dir first");
|
||||||
|
assert.strictEqual(entries[0].isDir, true, "isDir");
|
||||||
|
assert.strictEqual(entries[0].path, "Sub folder/", "dir path with slash");
|
||||||
|
assert.strictEqual(entries[1].name, "Idea spaziale.md", "decoded name");
|
||||||
|
assert.strictEqual(entries[1].path, "Idea spaziale.md", "decoded path");
|
||||||
|
assert.strictEqual(entries[1].mtime, "Fri, 14 Aug 2026 09:30:00 GMT", "mtime");
|
||||||
|
assert.strictEqual(entries[1].size, 1234, "size");
|
||||||
|
assert.strictEqual(entries[2].name, "readme.txt", "non-md kept (filtered by caller)");
|
||||||
|
|
||||||
|
console.log("PropfindParser tests: OK");
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!DOCTYPE TS>
|
||||||
|
<TS version="2.1" language="it" sourcelanguage="en">
|
||||||
|
<context>
|
||||||
|
<name>MainPage</name>
|
||||||
|
<message>
|
||||||
|
<source>Notes</source>
|
||||||
|
<translation>Note</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Search</source>
|
||||||
|
<translation>Cerca</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>No notes yet. Create one from the pulley menu.</source>
|
||||||
|
<translation>Nessuna nota. Creala dal menu in basso.</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Empty folder</source>
|
||||||
|
<translation>Cartella vuota</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Settings</source>
|
||||||
|
<translation>Impostazioni</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>New note</source>
|
||||||
|
<translation>Nuova nota</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Refresh</source>
|
||||||
|
<translation>Aggiorna</translation>
|
||||||
|
</message>
|
||||||
|
</context>
|
||||||
|
<context>
|
||||||
|
<name>NewNoteDialog</name>
|
||||||
|
<message>
|
||||||
|
<source>New note</source>
|
||||||
|
<translation>Nuova nota</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Name</source>
|
||||||
|
<translation>Nome</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>e.g. Idea</source>
|
||||||
|
<translation>es. Idea</translation>
|
||||||
|
</message>
|
||||||
|
</context>
|
||||||
|
<context>
|
||||||
|
<name>EditorPage</name>
|
||||||
|
<message>
|
||||||
|
<source>Save</source>
|
||||||
|
<translation>Salva</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Preview</source>
|
||||||
|
<translation>Anteprima</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Delete note</source>
|
||||||
|
<translation>Elimina nota</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Delete the note?</source>
|
||||||
|
<translation>Eliminare la nota?</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Save in progress...</source>
|
||||||
|
<translation>Salvataggio in corso...</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Saving...</source>
|
||||||
|
<translation>Salvataggio...</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Saved</source>
|
||||||
|
<translation>Salvata</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Cannot open: %1</source>
|
||||||
|
<translation>Impossibile aprire: %1</translation>
|
||||||
|
</message>
|
||||||
|
</context>
|
||||||
|
<context>
|
||||||
|
<name>PreviewPage</name>
|
||||||
|
<message>
|
||||||
|
<source>image</source>
|
||||||
|
<translation>immagine</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Local link not supported</source>
|
||||||
|
<translation>Link locale non supportato</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Cannot open the note: %1</source>
|
||||||
|
<translation>Impossibile aprire la nota: %1</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Edit</source>
|
||||||
|
<translation>Modifica</translation>
|
||||||
|
</message>
|
||||||
|
</context>
|
||||||
|
<context>
|
||||||
|
<name>SettingsPage</name>
|
||||||
|
<message>
|
||||||
|
<source>Settings</source>
|
||||||
|
<translation>Impostazioni</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Connection</source>
|
||||||
|
<translation>Connessione</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>WebDAV URL (folder)</source>
|
||||||
|
<translation>URL WebDAV (cartella)</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Username</source>
|
||||||
|
<translation>Utente</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Password</source>
|
||||||
|
<translation>Password</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Test connection</source>
|
||||||
|
<translation>Test connessione</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Enter the folder URL</source>
|
||||||
|
<translation>Inserisci l'URL della cartella</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Checking...</source>
|
||||||
|
<translation>Verifica...</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Connection OK</source>
|
||||||
|
<translation>Connessione riuscita</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>On Nextcloud: Settings → Security → App passwords. </source>
|
||||||
|
<translation>Su Nextcloud: Impostazioni → Sicurezza → Password app. </translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>The URL is https://host/remote.php/dav/files/USER/FOLDER. </source>
|
||||||
|
<translation>L'URL è https://host/remote.php/dav/files/UTENTE/CARTELLA. </translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>The password is stored locally in the app config file.</source>
|
||||||
|
<translation>La password è salvata in locale (file di configurazione dell'app).</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Notes</source>
|
||||||
|
<translation>Note</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>The app saves automatically when you leave a note. </source>
|
||||||
|
<translation>L'app salva automaticamente quando esci da una nota. </translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Supported: headings, bold, italic, code, lists, quotes, links, tables and rules.</source>
|
||||||
|
<translation>Formato supportato: titoli, grassetto, corsivo, codice, liste, citazioni, link, tabelle e linee.</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Language</source>
|
||||||
|
<translation>Lingua</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>System (default)</source>
|
||||||
|
<translation>Sistema (predefinita)</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Language will be applied after restarting the app</source>
|
||||||
|
<translation>La lingua verrà applicata al riavvio dell'app</translation>
|
||||||
|
</message>
|
||||||
|
</context>
|
||||||
|
<context>
|
||||||
|
<name>DavClient</name>
|
||||||
|
<message>
|
||||||
|
<source>Request already in progress</source>
|
||||||
|
<translation>Richiesta già in corso</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Network timeout</source>
|
||||||
|
<translation>Timeout di rete</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Invalid credentials (401)</source>
|
||||||
|
<translation>Credenziali non valide (401)</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Access denied (403)</source>
|
||||||
|
<translation>Accesso negato (403)</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Folder not found (404) — check the URL</source>
|
||||||
|
<translation>Cartella non trovata (404) — verifica l'URL</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>WebDAV not available (405) — the URL must point to a DAV folder</source>
|
||||||
|
<translation>WebDAV non disponibile (405) — l'URL deve puntare a una cartella DAV</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Server unreachable</source>
|
||||||
|
<translation>Server non raggiungibile</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Server error (%1)</source>
|
||||||
|
<translation>Errore del server (%1)</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>HTTP error %1</source>
|
||||||
|
<translation>Errore HTTP %1</translation>
|
||||||
|
</message>
|
||||||
|
</context>
|
||||||
|
</TS>
|
||||||
Reference in New Issue
Block a user