harbour-quill 0.2.12: note markdown con sync WebDAV

This commit is contained in:
2026-08-24 14:16:11 +02:00
commit 73acfbd0b4
26 changed files with 2134 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
import QtQuick 2.2
import Sailfish.Silica 1.0
import "../components"
Page {
id: page
property var dav
property string notePath
property string noteName
property string initialContent: ""
property bool skipLoad: false
property bool loaded: false
property bool dirty: false
property string coverText: noteName
onStatusChanged: {
if (status === PageStatus.Active) {
if (!loaded) {
if (skipLoad) {
// New note: content is already known, no GET needed.
loaded = true
editor.text = initialContent
dirty = false
} else {
loadNote()
}
}
} else if (status === PageStatus.Inactive) {
// Auto-save on exit (Obsidian-like: nothing is ever lost).
if (loaded && dirty && !dav.busy) saveNow(false)
}
}
function loadNote() {
loaded = false
dav.readFile(notePath)
}
function saveNow(notify) {
if (!loaded) return
if (dav.busy) {
banner.show(qsTr("Save in progress..."), false)
return
}
dav.writeFile(notePath, editor.text)
if (notify) banner.show(qsTr("Saving..."), false)
}
Connections {
target: dav
onFileReady: {
loaded = true
initialContent = content
editor.text = content
dirty = false
}
onWriteDone: {
initialContent = editor.text
dirty = false
banner.show(qsTr("Saved"), false)
}
onFailed: {
if (loaded) {
banner.show(message, true)
} else {
loaded = true
banner.show(qsTr("Cannot open: %1").arg(message), true)
}
}
}
SilicaFlickable {
anchors.fill: parent
contentHeight: column.height
Column {
id: column
width: parent.width
PageHeader { title: noteName }
TextArea {
id: editor
width: parent.width
focus: true
label: "Markdown"
onTextChanged: {
dirty = editor.text !== initialContent
}
}
}
PullDownMenu {
MenuItem {
text: qsTr("Save")
enabled: page.dirty && page.loaded
onClicked: page.saveNow(true)
}
MenuItem {
text: qsTr("Preview")
onClicked: pageStack.push(Qt.resolvedUrl("PreviewPage.qml"),
{ dav: page.dav, noteName: page.noteName, notePath: page.notePath, markdown: editor.text })
}
MenuItem {
text: qsTr("Delete note")
onClicked: {
var remorse = remorseComponent.createObject(page)
remorse.execute(qsTr("Delete the note?"), function () {
dav.deleteFile(notePath)
pageStack.pop()
})
}
}
}
Component {
id: remorseComponent
RemorsePopup { }
}
BusyIndicator {
anchors.centerIn: parent
running: !page.loaded
}
}
Banner { id: banner }
}
+186
View File
@@ -0,0 +1,186 @@
import QtQuick 2.2
import Sailfish.Silica 1.0
import "../components"
Page {
id: page
property var dav
property string currentPath: ""
property string coverText: currentPath === "" ? qsTr("Notes") : currentPath
property bool loading: false
property var pendingEditor: null
property var allEntries: []
property string filterText: ""
onFilterTextChanged: page.applyFilter()
onStatusChanged: {
if (status === PageStatus.Active) {
if (page.pendingEditor) {
// New note accepted: the dialog pops itself, then we open the
// editor (push here — after the dialog pop — avoids stack races).
var ed = page.pendingEditor
page.pendingEditor = null
pageStack.push(Qt.resolvedUrl("EditorPage.qml"),
{ dav: page.dav,
notePath: ed.path,
noteName: ed.name,
initialContent: "# " + ed.name.replace(/\.md$/, "") + "\n\n",
skipLoad: true })
} else {
reload()
}
}
}
Component.onCompleted: reload()
function reload() {
if (!dav || dav.busy) return
loading = true
dav.listDir(currentPath)
}
function applyFilter() {
notesModel.clear()
var f = page.filterText.trim().toLowerCase()
for (var i = 0; i < allEntries.length; i++) {
var e = allEntries[i]
// All files are shown (not only .md) — any file can be opened as text.
if (f && e.name.toLowerCase().indexOf(f) < 0) continue
notesModel.append({ name: e.name, path: e.path, isDir: e.isDir, mtime: e.mtime, size: e.size })
}
}
ListModel {
id: notesModel
}
Connections {
target: dav
onListReady: {
loading = false
allEntries = entries
console.log("Quill listReady:", entries.length, "entries in", page.currentPath)
page.applyFilter()
}
// Refresh the list whenever a write completes elsewhere (new note,
// editor save, delete) — the list under the stack stays fresh.
onWriteDone: page.reload()
onDeleteDone: page.reload()
onFailed: {
loading = false
banner.show(message, true)
}
}
SilicaListView {
id: listView
anchors.fill: parent
model: notesModel
header: Column {
width: parent.width
PageHeader { title: "Quill" }
SearchField {
id: searchField
width: parent.width
placeholderText: qsTr("Search")
// Bound to the page property so the delegates (which cannot
// see header ids) can clear it via page.filterText.
text: page.filterText
onTextChanged: {
if (text !== page.filterText) page.filterText = text
}
}
ListItem {
visible: page.currentPath !== ""
contentHeight: Theme.itemSizeSmall
onClicked: {
page.filterText = ""
page.currentPath = ""
page.reload()
}
Label {
anchors.verticalCenter: parent.verticalCenter
x: Theme.paddingLarge
width: parent.width - 2 * Theme.paddingLarge
color: Theme.highlightColor
font.pixelSize: Theme.fontSizeSmall
text: "▲ " + page.currentPath
}
}
}
ViewPlaceholder {
enabled: notesModel.count === 0 && !page.loading
text: page.currentPath === "" ? qsTr("No notes yet. Create one from the pulley menu.")
: qsTr("Empty folder")
}
delegate: ListItem {
id: noteItem
contentHeight: Theme.itemSizeMedium
onClicked: {
if (model.isDir) {
page.filterText = ""
page.currentPath = model.path
page.reload()
} else {
pageStack.push(Qt.resolvedUrl("EditorPage.qml"),
{ dav: page.dav, notePath: model.path, noteName: model.name })
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
x: Theme.paddingLarge
width: parent.width - 2 * Theme.paddingLarge
Label {
text: model.name + (model.isDir ? "/" : "")
color: model.isDir ? Theme.highlightColor : Theme.primaryColor
font.pixelSize: Theme.fontSizeMedium
truncationMode: TruncationMode.Fade
}
Label {
visible: !model.isDir
text: model.mtime
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeSmall
truncationMode: TruncationMode.Fade
}
}
}
VerticalScrollDecorator {}
PullDownMenu {
MenuItem {
text: qsTr("Settings")
onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml"), { dav: page.dav })
}
MenuItem {
text: qsTr("New note")
onClicked: {
var dlg = pageStack.push(Qt.resolvedUrl("NewNoteDialog.qml"), { dav: page.dav })
dlg.accepted.connect(function () {
if (dlg.resultPath) {
page.pendingEditor = { path: dlg.resultPath, name: dlg.resultName }
}
})
}
}
MenuItem {
text: qsTr("Refresh")
onClicked: page.reload()
}
}
BusyIndicator {
anchors.centerIn: parent
running: page.loading
}
}
Banner { id: banner }
}
+37
View File
@@ -0,0 +1,37 @@
import QtQuick 2.2
import Sailfish.Silica 1.0
Dialog {
id: dialog
property var dav
property string resultPath: ""
property string resultName: ""
canAccept: nameField.text.length > 0
onAccepted: {
var name = nameField.text.trim()
if (!name) return
var lower = name.toLowerCase()
var hasExt = lower.indexOf(".md") === lower.length - 3
resultName = hasExt ? name : name + ".md"
resultPath = resultName
dav.writeFile(resultPath, "# " + resultName.replace(/\.md$/, "") + "\n\n")
}
Column {
width: parent.width
DialogHeader { title: qsTr("New note") }
TextField {
id: nameField
width: parent.width
label: qsTr("Name")
placeholderText: qsTr("e.g. Idea")
EnterKey.enabled: text.length > 0
EnterKey.onClicked: dialog.accept()
}
}
}
+138
View File
@@ -0,0 +1,138 @@
import QtQuick 2.2
import Sailfish.Silica 1.0
import "../components/Markdown.js" as Markdown
import "../components"
Page {
id: page
property var dav
property string noteName
property string notePath: ""
property string markdown: ""
property string htmlText: ""
property string baseUrl: ""
property var imageMap: ({})
property string pendingOpen: ""
Component.onCompleted: {
// Base for relative links: the note's WebDAV folder. Relative links
// are resolved here so Qt rich text never resolves them against the
// page's .qml file (opening those fails with "text/qml not supported").
baseUrl = String(appSettings.serverUrl).replace(/\s+$/, "")
if (notePath && notePath.indexOf("/") >= 0) {
baseUrl += "/" + notePath.substring(0, notePath.lastIndexOf("/"))
}
renderPreview()
extractImages()
}
function resolveImg(u) {
if (/^[a-z]+:/i.test(u) || u.charAt(0) === "/" || u.charAt(0) === "#") return u
return String(page.baseUrl).replace(/\/+$/, "") + "/" + u
}
function renderPreview() {
try {
htmlText = Markdown.render(markdown, baseUrl,
{ imageMap: page.imageMap, imageLabel: qsTr("image") })
} catch (e) {
console.log("Quill markdown render error:", e)
htmlText = "<tt>" + String(markdown).replace(/&/g, "&amp;").replace(/</g, "&lt;") + "</tt>"
}
}
function extractImages() {
var re = /!\[[^\]]*\]\(([^)\s]+)\)/g
var m
while ((m = re.exec(markdown)) !== null) {
var url = resolveImg(m[1])
if (page.imageMap[url] === undefined) {
page.imageMap[url] = "" // downloading
downloader.fetch(url)
}
}
}
function openWikiLink(name) {
var dir = ""
if (notePath && notePath.indexOf("/") >= 0) {
dir = notePath.substring(0, notePath.lastIndexOf("/"))
}
page.pendingOpen = dir ? dir + "/" + name : name
dav.readFile(page.pendingOpen)
}
Connections {
target: dav
onFileReady: {
if (page.pendingOpen) {
var p = page.pendingOpen
page.pendingOpen = ""
var parts = p.split("/")
pageStack.push(Qt.resolvedUrl("EditorPage.qml"),
{ dav: page.dav, notePath: p, noteName: parts[parts.length - 1],
initialContent: content, skipLoad: true })
}
}
onFailed: {
if (page.pendingOpen) {
page.pendingOpen = ""
banner.show(qsTr("Cannot open the note: %1").arg(message), true)
}
}
}
Connections {
target: downloader
onFinished: {
if (page.imageMap[url] === undefined) return
page.imageMap[url] = ok ? path : "error"
page.renderPreview()
}
}
SilicaFlickable {
anchors.fill: parent
contentHeight: previewLabel.height + Theme.paddingLarge * 2
Column {
width: parent.width
PageHeader { title: noteName }
Label {
id: previewLabel
x: Theme.paddingLarge
width: parent.width - 2 * Theme.paddingLarge
text: page.htmlText
textFormat: Text.RichText
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
linkColor: Theme.highlightColor
onLinkActivated: {
var l = String(link)
if (l.indexOf("quill://") === 0) {
page.openWikiLink(decodeURIComponent(l.substring("quill://".length)))
} else if (l.indexOf("file:") === 0) {
// A link that still resolved to a local file (no base):
// opening it would fail with "text/qml not supported".
banner.show(qsTr("Local link not supported"), true)
} else {
Qt.openUrlExternally(l)
}
}
}
}
VerticalScrollDecorator {}
PullDownMenu {
MenuItem {
text: qsTr("Edit")
onClicked: pageStack.pop()
}
}
}
Banner { id: banner }
}
+114
View File
@@ -0,0 +1,114 @@
import QtQuick 2.2
import Sailfish.Silica 1.0
import "../components"
Page {
id: page
property var dav
SilicaFlickable {
anchors.fill: parent
contentHeight: column.height
Column {
id: column
width: parent.width
PageHeader { title: qsTr("Settings") }
SectionHeader { text: qsTr("Connection") }
TextField {
id: serverField
width: parent.width
label: qsTr("WebDAV URL (folder)")
placeholderText: "https://host/remote.php/dav/files/user/Note"
text: appSettings.serverUrl
onTextChanged: appSettings.serverUrl = text
}
TextField {
id: userField
width: parent.width
label: qsTr("Username")
text: appSettings.username
onTextChanged: appSettings.username = text
}
TextField {
id: passField
width: parent.width
label: qsTr("Password")
echoMode: TextInput.Password
text: appSettings.password
onTextChanged: appSettings.password = text
}
Button {
width: parent.width
text: qsTr("Test connection")
onClicked: {
if (!appSettings.serverUrl) {
banner.show(qsTr("Enter the folder URL"), true)
return
}
banner.show(qsTr("Checking..."), false)
dav.testConnection()
}
}
SectionHeader { text: qsTr("Language") }
ComboBox {
id: langBox
width: parent.width
label: qsTr("Language")
currentIndex: appSettings.language === "it" ? 2 : (appSettings.language === "en" ? 1 : 0)
menu: ContextMenu {
MenuItem { text: qsTr("System (default)") }
MenuItem { text: "English" }
MenuItem { text: "Italiano" }
}
onCurrentIndexChanged: {
var v = currentIndex === 2 ? "it" : (currentIndex === 1 ? "en" : "system")
if (v !== appSettings.language) {
appSettings.language = v
banner.show(qsTr("Language will be applied after restarting the app"), false)
}
}
}
Label {
x: Theme.paddingLarge
width: parent.width - 2 * Theme.paddingLarge
wrapMode: Text.Wrap
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeSmall
text: qsTr("On Nextcloud: Settings → Security → App passwords. ") +
qsTr("The URL is https://host/remote.php/dav/files/USER/FOLDER. ") +
qsTr("The password is stored locally in the app config file.")
}
SectionHeader { text: qsTr("Notes") }
Label {
x: Theme.paddingLarge
width: parent.width - 2 * Theme.paddingLarge
wrapMode: Text.Wrap
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeSmall
text: qsTr("The app saves automatically when you leave a note. ") +
qsTr("Supported: headings, bold, italic, code, lists, quotes, links, tables and rules.")
}
}
}
Connections {
target: dav
onConnOk: banner.show(qsTr("Connection OK"), false)
onFailed: banner.show(message, true)
}
Banner { id: banner }
}