commit 73acfbd0b43a87e25c4aedacfa9420e9ba777193 Author: Carlo Baratto Date: Mon Aug 24 14:16:11 2026 +0200 harbour-quill 0.2.12: note markdown con sync WebDAV diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..806dbd2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +build/ +build-*/ +*.rpm +*.user +Makefile +.qmake.stash +*.qm diff --git a/harbour-quill.pro b/harbour-quill.pro new file mode 100644 index 0000000..c04085a --- /dev/null +++ b/harbour-quill.pro @@ -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_.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 diff --git a/icons/108x108/harbour-quill.png b/icons/108x108/harbour-quill.png new file mode 100644 index 0000000..2a96c57 Binary files /dev/null and b/icons/108x108/harbour-quill.png differ diff --git a/icons/128x128/harbour-quill.png b/icons/128x128/harbour-quill.png new file mode 100644 index 0000000..344cc75 Binary files /dev/null and b/icons/128x128/harbour-quill.png differ diff --git a/icons/172x172/harbour-quill.png b/icons/172x172/harbour-quill.png new file mode 100644 index 0000000..9dc9e58 Binary files /dev/null and b/icons/172x172/harbour-quill.png differ diff --git a/icons/86x86/harbour-quill.png b/icons/86x86/harbour-quill.png new file mode 100644 index 0000000..1e5582f Binary files /dev/null and b/icons/86x86/harbour-quill.png differ diff --git a/qml/components/Banner.qml b/qml/components/Banner.qml new file mode 100644 index 0000000..29268ef --- /dev/null +++ b/qml/components/Banner.qml @@ -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 + } + } +} diff --git a/qml/components/DavClient.qml b/qml/components/DavClient.qml new file mode 100644 index 0000000..a3e4f04 --- /dev/null +++ b/qml/components/DavClient.qml @@ -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) + } +} diff --git a/qml/components/Markdown.js b/qml/components/Markdown.js new file mode 100644 index 0000000..5684700 --- /dev/null +++ b/qml/components/Markdown.js @@ -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 instead of placeholder +// opts.imageLabel - localized word for the image placeholder ("image") + +function escapeHtml(s) { + return s.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, "$1"); + 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 '' + alt + ''; + } + var label = opts && opts.imageLabel ? opts.imageLabel : "image"; + return "[" + label + ": " + alt + "]"; + }); + s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, function (m, t, u) { + return '' + t + ""; + }); + // Wiki links [[Nota]] -> quill://; 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 '' + label + ""; + }); + s = s.replace(/\*\*(.+?)\*\*/g, "$1"); + s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1$2"); + s = s.replace(/~~([^~]+)~~/g, "$1"); + 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('
' + escapeHtml(buf.join("\n")) + "
"); + 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 = ''; + t += ""; + for (var h = 0; h < headerCells.length; h++) { + t += "" + inline(headerCells[h], baseUrl, opts) + ""; + } + t += ""; + for (var r = 0; r < tbody.length; r++) { + t += ""; + var row = tbody[r]; + for (var c = 0; c < headerCells.length; c++) { + var cell = c < row.length ? row[c] : ""; + t += "" + inline(cell, baseUrl, opts) + ""; + } + t += ""; + } + t += "
"; + out.push(t); + continue; + } + } + + m = headingRe.exec(line); + if (m) { + var level = m[1].length; + out.push("" + inline(m[2], baseUrl, opts) + ""); + i++; + continue; + } + + if (hrRe.test(line)) { + out.push("
"); + 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('

' + q.join("
") + "

"); + 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("
  • " + inline(lm[2], baseUrl, opts) + "
  • "); + i++; + } + out.push(ordered ? "
      " + items.join("") + "
    " : "
      " + items.join("") + "
    "); + 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("

    " + para.join("
    ") + "

    "); + } + return out.join("\n"); +} + +if (typeof module !== "undefined" && module.exports) { + module.exports = { render: render }; +} diff --git a/qml/components/PropfindParser.js b/qml/components/PropfindParser.js new file mode 100644 index 0000000..5f13e37 --- /dev/null +++ b/qml/components/PropfindParser.js @@ -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 }; +} diff --git a/qml/cover/CoverPage.qml b/qml/cover/CoverPage.qml new file mode 100644 index 0000000..cc09758 --- /dev/null +++ b/qml/cover/CoverPage.qml @@ -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 + } +} diff --git a/qml/harbour-quill.qml b/qml/harbour-quill.qml new file mode 100644 index 0000000..57e28e2 --- /dev/null +++ b/qml/harbour-quill.qml @@ -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 }) + } +} diff --git a/qml/pages/EditorPage.qml b/qml/pages/EditorPage.qml new file mode 100644 index 0000000..727dfca --- /dev/null +++ b/qml/pages/EditorPage.qml @@ -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 } +} diff --git a/qml/pages/MainPage.qml b/qml/pages/MainPage.qml new file mode 100644 index 0000000..2ad8d67 --- /dev/null +++ b/qml/pages/MainPage.qml @@ -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 } +} diff --git a/qml/pages/NewNoteDialog.qml b/qml/pages/NewNoteDialog.qml new file mode 100644 index 0000000..811c4dc --- /dev/null +++ b/qml/pages/NewNoteDialog.qml @@ -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() + } + } +} diff --git a/qml/pages/PreviewPage.qml b/qml/pages/PreviewPage.qml new file mode 100644 index 0000000..be75a8b --- /dev/null +++ b/qml/pages/PreviewPage.qml @@ -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 = "" + String(markdown).replace(/&/g, "&").replace(/" + } + } + + 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 } +} diff --git a/qml/pages/SettingsPage.qml b/qml/pages/SettingsPage.qml new file mode 100644 index 0000000..abddfff --- /dev/null +++ b/qml/pages/SettingsPage.qml @@ -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 } +} diff --git a/rpm/harbour-quill.desktop b/rpm/harbour-quill.desktop new file mode 100644 index 0000000..da879b3 --- /dev/null +++ b/rpm/harbour-quill.desktop @@ -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 diff --git a/rpm/harbour-quill.spec b/rpm/harbour-quill.spec new file mode 100644 index 0000000..465c3cc --- /dev/null +++ b/rpm/harbour-quill.spec @@ -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 - 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 - 0.2.11-1 +- App icon: smaller M, arrow detached further to the right + +* Sun Aug 16 2026 Carlo Baratto - 0.2.10-1 +- App icon: markdown glyph with downward arrow on the right of the M + +* Sun Aug 16 2026 Carlo Baratto - 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 - 0.2.8-1 +- New app icon: teardrop shape pointing right (Tooter style) with markdown glyph + +* Sun Aug 16 2026 Carlo Baratto - 0.2.7-1 +- New app icon: dark panel with a coloured frame (Tooter/SubMusic style) + +* Sun Aug 16 2026 Carlo Baratto - 0.2.6-1 +- New app icon: markdown M-down glyph on a bright violet gradient + +* Sun Aug 16 2026 Carlo Baratto - 0.2.5-1 +- New app icon (Jolla-style gradient + document glyph) + +* Sun Aug 16 2026 Carlo Baratto - 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 - 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 - 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 - 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 - 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 - 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 - 0.1.11-1 +- New: GFM table rendering (delimiter row, column alignment, inline markup in cells) + +* Sun Aug 16 2026 Carlo Baratto - 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 - 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 - 0.1.8-1 +- Fix preview: import Markdown.js by file name (directory import exposes only .qml) + +* Sun Aug 16 2026 Carlo Baratto - 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 - 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 - 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 - 0.1.4-1 +- Fix packaging: desktop install path with fallback (sfdk does not extract the tarball) + +* Sun Aug 16 2026 Carlo Baratto - 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 - 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 - 0.1.1-1 +- Fix packaging: desktop file installed via qmake INSTALLS (was missing from buildroot) + +* Sun Aug 16 2026 Carlo Baratto - 0.1.0-1 +- Initial release: WebDAV browsing, markdown editor, preview, new/delete notes diff --git a/src/downloader.cpp b/src/downloader.cpp new file mode 100644 index 0000000..53889bf --- /dev/null +++ b/src/downloader.cpp @@ -0,0 +1,93 @@ +#include "downloader.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(sender()); + if (!reply) + return; + if (!m_pending.contains(reply)) { + reply->deleteLater(); + return; + } + const QPair 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); +} diff --git a/src/downloader.h b/src/downloader.h new file mode 100644 index 0000000..d7b08be --- /dev/null +++ b/src/downloader.h @@ -0,0 +1,36 @@ +#ifndef DOWNLOADER_H +#define DOWNLOADER_H + +#include +#include +#include +#include +#include + +// 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 > m_pending; +}; + +#endif // DOWNLOADER_H diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..e7b83c8 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,69 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#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 trs = app->findChildren(); + 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(); +} diff --git a/src/settings.cpp b/src/settings.cpp new file mode 100644 index 0000000..eb336a4 --- /dev/null +++ b/src/settings.cpp @@ -0,0 +1,108 @@ +#include "settings.h" +#include +#include +#include + +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(); +} diff --git a/src/settings.h b/src/settings.h new file mode 100644 index 0000000..29982d8 --- /dev/null +++ b/src/settings.h @@ -0,0 +1,50 @@ +#ifndef SETTINGS_H +#define SETTINGS_H + +#include +#include + +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 diff --git a/tests/test_parsers.js b/tests/test_parsers.js new file mode 100644 index 0000000..d4d48d4 --- /dev/null +++ b/tests/test_parsers.js @@ -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 html"; + +var html = Markdown.render(md); +console.log("── Markdown output ──\n" + html + "\n────────────────────"); + +assert(html.indexOf("

    Titolo

    ") >= 0, "heading"); +assert(html.indexOf("grassetto") >= 0, "bold"); +assert(html.indexOf("corsivo") >= 0, "italic"); +assert(html.indexOf("codice") >= 0, "inline code"); +assert(html.indexOf("
    • uno
    • due
    ") >= 0, "ul"); +assert(html.indexOf("
    1. primo
    2. secondo
    ") >= 0, "ol"); +assert(html.indexOf("citazione") >= 0, "quote"); +assert(html.indexOf('link') >= 0, "link"); +assert(html.indexOf("= 0 && html.indexOf("var x = 1;") >= 0, "code fence"); +assert(html.indexOf("
    ") >= 0, "hr"); +assert(html.indexOf("cancellato") >= 0, "strike"); +assert(html.indexOf("<b>html</b>") >= 0, "html escaped"); +assert(html.indexOf("") < html.indexOf("<b>"), "escape before markup"); + +// nested bold/italic: **a *b* c** +var h2 = Markdown.render("**a *b* c**"); +assert(h2.indexOf("a b c") >= 0, "nested bold+italic: " + h2); + +// italics must not swallow bold markers +var h3 = Markdown.render("*solo* e **doppio**"); +assert(h3.indexOf("solo") >= 0 && h3.indexOf("doppio") >= 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("

    Titolo con BOM

    ") >= 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("

    ") < 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("= 0, "table open"); +assert(tbl.indexOf("A") >= 0, "th header: " + tbl); +assert(tbl.indexOf("1") >= 0, "td data"); +assert(tbl.indexOf("4") >= 0, "last cell"); +assert(tbl.indexOf("") === tbl.lastIndexOf(""), "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("X") >= 0, "bold in cell"); +assert(tbl3.indexOf("code") >= 0, "code in cell"); +assert(tbl3.indexOf("l") >= 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("") >= 0, "bare hr kept"); + +// single column table +var tbl4 = Markdown.render("| Solo |\n|---|\n| x |"); +assert(tbl4.indexOf("Solo") >= 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('altra nota') >= 0, "wiki link no ext: " + wk1); +assert(wk1.indexOf('nota') >= 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("[immagine: foto]") >= 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('foto') >= 0, + "image inline from cache: " + img2); +var img3 = Markdown.render("![x](https://host/img.jpg)", "", { imageMap: {} }); +assert(img3.indexOf("[image: x]") >= 0, "default label 'image'"); + +console.log("Markdown tests: OK"); + +// ── PropfindParser ──────────────────────────────────────────────────────── +var xml = '' + + '' + + '' + + '/remote.php/dav/files/carlo/Note/' + + '' + + 'HTTP/1.1 200 OK' + + '' + + '' + + '/remote.php/dav/files/carlo/Note/Sub%20folder/' + + '' + + 'HTTP/1.1 200 OK' + + '' + + '' + + '/remote.php/dav/files/carlo/Note/Idea%20spaziale.md' + + '' + + 'Fri, 14 Aug 2026 09:30:00 GMT' + + '1234' + + 'HTTP/1.1 200 OK' + + '' + + '' + + '/remote.php/dav/files/carlo/Note/readme.txt' + + '42' + + 'HTTP/1.1 200 OK' + + '' + + ''; + +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"); diff --git a/translations/harbour-quill_it.ts b/translations/harbour-quill_it.ts new file mode 100644 index 0000000..6ffcfb4 --- /dev/null +++ b/translations/harbour-quill_it.ts @@ -0,0 +1,218 @@ + + + + + MainPage + + Notes + Note + + + Search + Cerca + + + No notes yet. Create one from the pulley menu. + Nessuna nota. Creala dal menu in basso. + + + Empty folder + Cartella vuota + + + Settings + Impostazioni + + + New note + Nuova nota + + + Refresh + Aggiorna + + + + NewNoteDialog + + New note + Nuova nota + + + Name + Nome + + + e.g. Idea + es. Idea + + + + EditorPage + + Save + Salva + + + Preview + Anteprima + + + Delete note + Elimina nota + + + Delete the note? + Eliminare la nota? + + + Save in progress... + Salvataggio in corso... + + + Saving... + Salvataggio... + + + Saved + Salvata + + + Cannot open: %1 + Impossibile aprire: %1 + + + + PreviewPage + + image + immagine + + + Local link not supported + Link locale non supportato + + + Cannot open the note: %1 + Impossibile aprire la nota: %1 + + + Edit + Modifica + + + + SettingsPage + + Settings + Impostazioni + + + Connection + Connessione + + + WebDAV URL (folder) + URL WebDAV (cartella) + + + Username + Utente + + + Password + Password + + + Test connection + Test connessione + + + Enter the folder URL + Inserisci l'URL della cartella + + + Checking... + Verifica... + + + Connection OK + Connessione riuscita + + + On Nextcloud: Settings → Security → App passwords. + Su Nextcloud: Impostazioni → Sicurezza → Password app. + + + The URL is https://host/remote.php/dav/files/USER/FOLDER. + L'URL è https://host/remote.php/dav/files/UTENTE/CARTELLA. + + + The password is stored locally in the app config file. + La password è salvata in locale (file di configurazione dell'app). + + + Notes + Note + + + The app saves automatically when you leave a note. + L'app salva automaticamente quando esci da una nota. + + + Supported: headings, bold, italic, code, lists, quotes, links, tables and rules. + Formato supportato: titoli, grassetto, corsivo, codice, liste, citazioni, link, tabelle e linee. + + + Language + Lingua + + + System (default) + Sistema (predefinita) + + + Language will be applied after restarting the app + La lingua verrà applicata al riavvio dell'app + + + + DavClient + + Request already in progress + Richiesta già in corso + + + Network timeout + Timeout di rete + + + Invalid credentials (401) + Credenziali non valide (401) + + + Access denied (403) + Accesso negato (403) + + + Folder not found (404) — check the URL + Cartella non trovata (404) — verifica l'URL + + + WebDAV not available (405) — the URL must point to a DAV folder + WebDAV non disponibile (405) — l'URL deve puntare a una cartella DAV + + + Server unreachable + Server non raggiungibile + + + Server error (%1) + Errore del server (%1) + + + HTTP error %1 + Errore HTTP %1 + + +