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
+7
View File
@@ -0,0 +1,7 @@
build/
build-*/
*.rpm
*.user
Makefile
.qmake.stash
*.qm
+45
View File
@@ -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

+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 }
}
+18
View File
@@ -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
+156
View File
@@ -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
+93
View File
@@ -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);
}
+36
View File
@@ -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
+69
View File
@@ -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();
}
+108
View File
@@ -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();
}
+50
View File
@@ -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
+158
View File
@@ -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("&lt;b&gt;html&lt;/b&gt;") >= 0, "html escaped");
assert(html.indexOf("<b>") < html.indexOf("&lt;b&gt;"), "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("![foto](img.png)", 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("![foto](img.png)", 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("![x](https://host/img.jpg)", "", { 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");
+218
View File
@@ -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>