From 73acfbd0b43a87e25c4aedacfa9420e9ba777193 Mon Sep 17 00:00:00 2001 From: Carlo Baratto Date: Mon, 24 Aug 2026 14:16:11 +0200 Subject: [PATCH] harbour-quill 0.2.12: note markdown con sync WebDAV --- .gitignore | 7 + harbour-quill.pro | 45 +++++++ icons/108x108/harbour-quill.png | Bin 0 -> 5435 bytes icons/128x128/harbour-quill.png | Bin 0 -> 6515 bytes icons/172x172/harbour-quill.png | Bin 0 -> 6211 bytes icons/86x86/harbour-quill.png | Bin 0 -> 4412 bytes qml/components/Banner.qml | 65 +++++++++ qml/components/DavClient.qml | 145 ++++++++++++++++++++ qml/components/Markdown.js | 220 +++++++++++++++++++++++++++++++ qml/components/PropfindParser.js | 78 +++++++++++ qml/cover/CoverPage.qml | 26 ++++ qml/harbour-quill.qml | 38 ++++++ qml/pages/EditorPage.qml | 129 ++++++++++++++++++ qml/pages/MainPage.qml | 186 ++++++++++++++++++++++++++ qml/pages/NewNoteDialog.qml | 37 ++++++ qml/pages/PreviewPage.qml | 138 +++++++++++++++++++ qml/pages/SettingsPage.qml | 114 ++++++++++++++++ rpm/harbour-quill.desktop | 18 +++ rpm/harbour-quill.spec | 156 ++++++++++++++++++++++ src/downloader.cpp | 93 +++++++++++++ src/downloader.h | 36 +++++ src/main.cpp | 69 ++++++++++ src/settings.cpp | 108 +++++++++++++++ src/settings.h | 50 +++++++ tests/test_parsers.js | 158 ++++++++++++++++++++++ translations/harbour-quill_it.ts | 218 ++++++++++++++++++++++++++++++ 26 files changed, 2134 insertions(+) create mode 100644 .gitignore create mode 100644 harbour-quill.pro create mode 100644 icons/108x108/harbour-quill.png create mode 100644 icons/128x128/harbour-quill.png create mode 100644 icons/172x172/harbour-quill.png create mode 100644 icons/86x86/harbour-quill.png create mode 100644 qml/components/Banner.qml create mode 100644 qml/components/DavClient.qml create mode 100644 qml/components/Markdown.js create mode 100644 qml/components/PropfindParser.js create mode 100644 qml/cover/CoverPage.qml create mode 100644 qml/harbour-quill.qml create mode 100644 qml/pages/EditorPage.qml create mode 100644 qml/pages/MainPage.qml create mode 100644 qml/pages/NewNoteDialog.qml create mode 100644 qml/pages/PreviewPage.qml create mode 100644 qml/pages/SettingsPage.qml create mode 100644 rpm/harbour-quill.desktop create mode 100644 rpm/harbour-quill.spec create mode 100644 src/downloader.cpp create mode 100644 src/downloader.h create mode 100644 src/main.cpp create mode 100644 src/settings.cpp create mode 100644 src/settings.h create mode 100644 tests/test_parsers.js create mode 100644 translations/harbour-quill_it.ts 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 0000000000000000000000000000000000000000..2a96c57e138e5b2ed10ae094b58ea5d8592ae111 GIT binary patch literal 5435 zcmV-B6~yX^P)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00006 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 literal 0 HcmV?d00001 diff --git a/icons/128x128/harbour-quill.png b/icons/128x128/harbour-quill.png new file mode 100644 index 0000000000000000000000000000000000000000..344cc75103d81d60116303684ac78065f6ffdb5a GIT binary patch literal 6515 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 Ku~ES?;(q|D@T8mo literal 0 HcmV?d00001 diff --git a/icons/86x86/harbour-quill.png b/icons/86x86/harbour-quill.png new file mode 100644 index 0000000000000000000000000000000000000000..1e5582f2a79519fac14ce104df05464f578f50ad GIT binary patch literal 4412 zcmV-C5yS3@P)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00006 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 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 + + +