diff --git a/Assets/Translations/de.json b/Assets/Translations/de.json index 59aafe492..4a469cd1b 100644 --- a/Assets/Translations/de.json +++ b/Assets/Translations/de.json @@ -391,6 +391,32 @@ "loading": "Wetter wird geladen…" } }, + "changelog": { + "panel": { + "title": "Was ist neu in {version}", + "subtitle": { + "fresh": "Danke, dass du Noctalia installiert hast! Das ist in diesem Build enthalten.", + "updated": "Aktualisiert von {previousVersion}" + }, + "version": { + "new-user": "Neuinstallation" + }, + "highlight-title": "Highlights", + "empty": "Es sind noch keine Versionshinweise verfügbar.", + "section": { + "version": "Version {version}", + "released": "Veröffentlicht am {date}" + }, + "buttons": { + "discord": "Unserem Discord beitreten", + "feedback": "Feedback senden", + "dismiss": "Verstanden" + } + }, + "error": { + "rate-limit": "GitHub-Limit erreicht. Bitte versuche es in ein paar Minuten erneut." + } + }, "clock": { "tooltip": "Kalender öffnen" }, diff --git a/Assets/Translations/en.json b/Assets/Translations/en.json index 3b1aee6e1..ac157fc71 100644 --- a/Assets/Translations/en.json +++ b/Assets/Translations/en.json @@ -391,6 +391,31 @@ "loading": "Loading weather…" } }, + "changelog": { + "panel": { + "title": "What's new in {version}", + "subtitle": { + "fresh": "Thanks for installing Noctalia! Here is what’s included in this build.", + "updated": "Updated from {previousVersion}" + }, + "version": { + "new-user": "Fresh install" + }, + "highlight-title": "Highlights", + "empty": "Release notes are not available yet.", + "section": { + "version": "Version {version}", + "released": "Released on {date}" + }, + "buttons": { + "discord": "Join our Discord", + "dismiss": "Got it" + } + }, + "error": { + "rate-limit": "GitHub rate limit exceeded. Please try again in a few minutes." + } + }, "clock": { "tooltip": "Open calendar" }, diff --git a/Assets/Translations/es.json b/Assets/Translations/es.json index 2e8239c08..72dc23279 100644 --- a/Assets/Translations/es.json +++ b/Assets/Translations/es.json @@ -391,6 +391,32 @@ "loading": "Cargando el clima…" } }, + "changelog": { + "panel": { + "title": "Novedades en {version}", + "subtitle": { + "fresh": "Gracias por instalar Noctalia. Esto es lo que incluye esta compilación.", + "updated": "Actualizado desde {previousVersion}" + }, + "version": { + "new-user": "Instalación nueva" + }, + "highlight-title": "Cambios destacados", + "empty": "Las notas de la versión aún no están disponibles.", + "section": { + "version": "Versión {version}", + "released": "Publicado el {date}" + }, + "buttons": { + "discord": "Únete a nuestro Discord", + "feedback": "Enviar comentarios", + "dismiss": "Entendido" + } + }, + "error": { + "rate-limit": "Se alcanzó el límite de GitHub. Inténtalo de nuevo en unos minutos." + } + }, "clock": { "tooltip": "Abrir calendario" }, diff --git a/Assets/Translations/fr.json b/Assets/Translations/fr.json index 70b11ec6e..9d21eed81 100644 --- a/Assets/Translations/fr.json +++ b/Assets/Translations/fr.json @@ -391,6 +391,32 @@ "loading": "Chargement de la météo…" } }, + "changelog": { + "panel": { + "title": "Quoi de neuf dans {version}", + "subtitle": { + "fresh": "Merci d’avoir installé Noctalia ! Voici ce que contient cette version.", + "updated": "Mise à jour depuis {previousVersion}" + }, + "version": { + "new-user": "Nouvelle installation" + }, + "highlight-title": "Points importants", + "empty": "Les notes de version ne sont pas encore disponibles.", + "section": { + "version": "Version {version}", + "released": "Publié le {date}" + }, + "buttons": { + "discord": "Rejoindre notre Discord", + "feedback": "Envoyer un retour", + "dismiss": "Compris" + } + }, + "error": { + "rate-limit": "Limite de GitHub atteinte. Réessayez dans quelques minutes." + } + }, "clock": { "tooltip": "Ouvrir le calendrier" }, diff --git a/Assets/Translations/nl.json b/Assets/Translations/nl.json index b711e1b1d..2750e1230 100644 --- a/Assets/Translations/nl.json +++ b/Assets/Translations/nl.json @@ -391,6 +391,32 @@ "loading": "Weer laden…" } }, + "changelog": { + "panel": { + "title": "Wat is er nieuw in {version}", + "subtitle": { + "fresh": "Bedankt voor het installeren van Noctalia! Dit zit er in deze build.", + "updated": "Bijgewerkt vanaf {previousVersion}" + }, + "version": { + "new-user": "Nieuwe installatie" + }, + "highlight-title": "Hoogtepunten", + "empty": "Er zijn nog geen release-opmerkingen beschikbaar.", + "section": { + "version": "Versie {version}", + "released": "Uitgebracht op {date}" + }, + "buttons": { + "discord": "Word lid van onze Discord", + "feedback": "Feedback verzenden", + "dismiss": "Begrepen" + } + }, + "error": { + "rate-limit": "GitHub-limiet bereikt. Probeer het over enkele minuten opnieuw." + } + }, "clock": { "tooltip": "Kalender openen" }, diff --git a/Assets/Translations/pt.json b/Assets/Translations/pt.json index 5f071fe0a..d834629f2 100644 --- a/Assets/Translations/pt.json +++ b/Assets/Translations/pt.json @@ -391,6 +391,32 @@ "loading": "Carregando o clima…" } }, + "changelog": { + "panel": { + "title": "Novidades na {version}", + "subtitle": { + "fresh": "Obrigado por instalar o Noctalia! Veja o que está incluído nesta compilação.", + "updated": "Atualizado a partir da {previousVersion}" + }, + "version": { + "new-user": "Nova instalação" + }, + "highlight-title": "Destaques", + "empty": "As notas da versão ainda não estão disponíveis.", + "section": { + "version": "Versão {version}", + "released": "Lançado em {date}" + }, + "buttons": { + "discord": "Entre no nosso Discord", + "feedback": "Enviar feedback", + "dismiss": "Entendi" + } + }, + "error": { + "rate-limit": "Limite do GitHub atingido. Tente novamente em alguns minutos." + } + }, "clock": { "tooltip": "Abrir calendário" }, diff --git a/Assets/Translations/ru.json b/Assets/Translations/ru.json index 6c25c87e1..023d0474b 100644 --- a/Assets/Translations/ru.json +++ b/Assets/Translations/ru.json @@ -391,6 +391,32 @@ "loading": "Загрузка погоды…" } }, + "changelog": { + "panel": { + "title": "Что нового в {version}", + "subtitle": { + "fresh": "Спасибо за установку Noctalia! Вот что входит в этот билд.", + "updated": "Обновлено с {previousVersion}" + }, + "version": { + "new-user": "Новая установка" + }, + "highlight-title": "Основные изменения", + "empty": "Примечания к выпуску пока недоступны.", + "section": { + "version": "Версия {version}", + "released": "Выпущено {date}" + }, + "buttons": { + "discord": "Присоединиться к нашему Discord", + "feedback": "Отправить отзыв", + "dismiss": "Понятно" + } + }, + "error": { + "rate-limit": "Превышен лимит GitHub. Попробуйте снова через несколько минут." + } + }, "clock": { "tooltip": "Открыть календарь" }, diff --git a/Assets/Translations/tr.json b/Assets/Translations/tr.json index 4bf5a8b23..52541d2c4 100644 --- a/Assets/Translations/tr.json +++ b/Assets/Translations/tr.json @@ -391,6 +391,32 @@ "loading": "Hava durumu yükleniyor..." } }, + "changelog": { + "panel": { + "title": "{version} sürümünde neler yeni", + "subtitle": { + "fresh": "Noctalia’yı kurduğun için teşekkürler! Bu sürümde gelenler bunlar.", + "updated": "{previousVersion} sürümünden güncellendi" + }, + "version": { + "new-user": "Yeni kurulum" + }, + "highlight-title": "Öne çıkanlar", + "empty": "Sürüm notları henüz hazır değil.", + "section": { + "version": "Sürüm {version}", + "released": "{date} tarihinde yayımlandı" + }, + "buttons": { + "discord": "Discord sunucumuza katıl", + "feedback": "Geri bildirim gönder", + "dismiss": "Anladım" + } + }, + "error": { + "rate-limit": "GitHub sınırına ulaşıldı. Lütfen birkaç dakika sonra tekrar dene." + } + }, "clock": { "tooltip": "Takvimi aç" }, diff --git a/Assets/Translations/uk-UA.json b/Assets/Translations/uk-UA.json index 7089e65c2..efcadfbcb 100644 --- a/Assets/Translations/uk-UA.json +++ b/Assets/Translations/uk-UA.json @@ -391,6 +391,32 @@ "loading": "Завантаження погоди…" } }, + "changelog": { + "panel": { + "title": "Що нового у {version}", + "subtitle": { + "fresh": "Дякуємо, що встановили Noctalia! Ось що містить цей білд.", + "updated": "Оновлено з {previousVersion}" + }, + "version": { + "new-user": "Нове встановлення" + }, + "highlight-title": "Основні зміни", + "empty": "Примітки до релізу ще недоступні.", + "section": { + "version": "Версія {version}", + "released": "Випущено {date}" + }, + "buttons": { + "discord": "Приєднатися до нашого Discord", + "feedback": "Надіслати відгук", + "dismiss": "Зрозуміло" + } + }, + "error": { + "rate-limit": "Перевищено ліміт GitHub. Спробуйте ще раз за кілька хвилин." + } + }, "clock": { "tooltip": "Відкрити календар" }, diff --git a/Assets/Translations/zh-CN.json b/Assets/Translations/zh-CN.json index b6a1e7c55..56fdcfb3e 100644 --- a/Assets/Translations/zh-CN.json +++ b/Assets/Translations/zh-CN.json @@ -391,6 +391,32 @@ "loading": "正在加载天气…" } }, + "changelog": { + "panel": { + "title": "{version} 有哪些更新", + "subtitle": { + "fresh": "感谢安装 Noctalia!以下是本次构建包含的内容。", + "updated": "已从 {previousVersion} 更新" + }, + "version": { + "new-user": "全新安装" + }, + "highlight-title": "重点更新", + "empty": "暂时没有可用的发行说明。", + "section": { + "version": "版本 {version}", + "released": "{date} 发布" + }, + "buttons": { + "discord": "加入我们的 Discord", + "feedback": "发送反馈", + "dismiss": "知道了" + } + }, + "error": { + "rate-limit": "已达到 GitHub 速率限制,请稍后再试。" + } + }, "clock": { "tooltip": "打开日历" }, diff --git a/Commons/Settings.qml b/Commons/Settings.qml index d549f4704..a2404734a 100644 --- a/Commons/Settings.qml +++ b/Commons/Settings.qml @@ -5,6 +5,7 @@ import Quickshell import Quickshell.Io import "../Helpers/QtObj2JS.js" as QtObj2JS import qs.Commons +import qs.Services.Noctalia import qs.Services.UI Singleton { @@ -16,6 +17,9 @@ Singleton { property bool directoriesCreated: false property int settingsVersion: 23 property bool isDebug: Quickshell.env("NOCTALIA_DEBUG") === "1" + property bool changelogPending: false + property string changelogFromVersion: "" + property string changelogToVersion: "" // Define our app directories // Default config directory: ~/.config/noctalia @@ -36,6 +40,7 @@ Singleton { // Signal emitted when settings are loaded after startupcale changes signal settingsLoaded signal settingsSaved + signal changelogTriggered(string previousVersion, string currentVersion) // ----------------------------------------------------- // ----------------------------------------------------- @@ -99,6 +104,7 @@ Singleton { upgradeSettingsData(); validateMonitorConfigurations(); + evaluateChangelogState(); isLoaded = true; // Emit the signal @@ -523,12 +529,51 @@ Singleton { property string darkModeChange: "" } + property JsonObject changelog: JsonObject { + property string lastSeenVersion: "" + property bool forceShowNextStart: false + } + // battery property JsonObject battery: JsonObject { property int chargingMode: 0 } } + // ----------------------------------------------------- + function evaluateChangelogState() { + const currentVersion = UpdateService ? (UpdateService.currentVersion || "") : ""; + if (!currentVersion) { + return; + } + + const storedVersion = adapter.changelog?.lastSeenVersion || ""; + const forceShow = adapter.changelog?.forceShowNextStart || false; + const hasSeenBefore = storedVersion !== ""; + const versionChanged = storedVersion !== currentVersion; + const shouldTrigger = forceShow || (hasSeenBefore && versionChanged); + + if (shouldTrigger) { + changelogFromVersion = storedVersion; + changelogToVersion = currentVersion; + changelogPending = true; + root.changelogTriggered(storedVersion, currentVersion); + } + + adapter.changelog.lastSeenVersion = currentVersion; + adapter.changelog.forceShowNextStart = false; + + if (shouldTrigger || !hasSeenBefore) { + Qt.callLater(saveImmediate); + } + } + + function clearChangelogRequest() { + changelogPending = false; + changelogFromVersion = ""; + changelogToVersion = ""; + } + // ----------------------------------------------------- // Function to preprocess paths by expanding "~" to user's home directory function preprocessPath(path) { diff --git a/Modules/MainScreen/Backgrounds/AllBackgrounds.qml b/Modules/MainScreen/Backgrounds/AllBackgrounds.qml index c5dbcda80..40d0fcf3e 100644 --- a/Modules/MainScreen/Backgrounds/AllBackgrounds.qml +++ b/Modules/MainScreen/Backgrounds/AllBackgrounds.qml @@ -90,6 +90,13 @@ Item { backgroundColor: panelBackgroundColor } + // Changelog + PanelBackground { + panel: root.windowRoot.changelogPanelPlaceholder + shapeContainer: backgroundsShape + backgroundColor: panelBackgroundColor + } + // Launcher PanelBackground { panel: root.windowRoot.launcherPanelPlaceholder diff --git a/Modules/MainScreen/MainScreen.qml b/Modules/MainScreen/MainScreen.qml index 6eda2dea5..c90804905 100644 --- a/Modules/MainScreen/MainScreen.qml +++ b/Modules/MainScreen/MainScreen.qml @@ -12,6 +12,7 @@ import qs.Modules.Bar.Extras import qs.Modules.Panels.Audio import qs.Modules.Panels.Bluetooth import qs.Modules.Panels.Calendar +import qs.Modules.Panels.Changelog import qs.Modules.Panels.ControlCenter import qs.Modules.Panels.Launcher import qs.Modules.Panels.NotificationHistory @@ -33,6 +34,7 @@ PanelWindow { readonly property alias audioPanel: audioPanel readonly property alias bluetoothPanel: bluetoothPanel readonly property alias calendarPanel: calendarPanel + readonly property alias changelogPanel: changelogPanel readonly property alias controlCenterPanel: controlCenterPanel readonly property alias launcherPanel: launcherPanel readonly property alias notificationHistoryPanel: notificationHistoryPanel @@ -47,6 +49,7 @@ PanelWindow { readonly property var audioPanelPlaceholder: audioPanel.panelPlaceholder readonly property var bluetoothPanelPlaceholder: bluetoothPanel.panelPlaceholder readonly property var calendarPanelPlaceholder: calendarPanel.panelPlaceholder + readonly property var changelogPanelPlaceholder: changelogPanel.panelPlaceholder readonly property var controlCenterPanelPlaceholder: controlCenterPanel.panelPlaceholder readonly property var launcherPanelPlaceholder: launcherPanel.panelPlaceholder readonly property var notificationHistoryPanelPlaceholder: notificationHistoryPanel.panelPlaceholder @@ -174,6 +177,12 @@ PanelWindow { screen: root.screen } + ChangelogPanel { + id: changelogPanel + objectName: "changelogPanel-" + (root.screen?.name || "unknown") + screen: root.screen + } + CalendarPanel { id: calendarPanel objectName: "calendarPanel-" + (root.screen?.name || "unknown") diff --git a/Modules/Panels/Changelog/ChangelogPanel.qml b/Modules/Panels/Changelog/ChangelogPanel.qml new file mode 100644 index 000000000..db839091c --- /dev/null +++ b/Modules/Panels/Changelog/ChangelogPanel.qml @@ -0,0 +1,289 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import qs.Commons +import qs.Modules.MainScreen +import qs.Services.Noctalia +import qs.Services.UI +import qs.Widgets + +SmartPanel { + id: root + + preferredWidth: Math.round(540 * Style.uiScaleRatio) + preferredHeight: Math.round(620 * Style.uiScaleRatio) + panelAnchorHorizontalCenter: true + panelAnchorVerticalCenter: true + + readonly property string currentVersion: ChangelogService.currentVersion || UpdateService.currentVersion + readonly property string previousVersion: ChangelogService.previousVersion + readonly property bool hasPreviousVersion: previousVersion && previousVersion.length > 0 + readonly property var releaseHighlights: ChangelogService.releaseHighlights || [] + readonly property string subtitleText: hasPreviousVersion ? I18n.tr("changelog.panel.subtitle.updated", { + "previousVersion": previousVersion + }) : I18n.tr("changelog.panel.subtitle.fresh") + + panelContent: Rectangle { + color: Color.mSurfaceVariant + radius: Style.radiusM + border.color: Color.mOutline + border.width: Style.borderS + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.marginL + spacing: Style.marginM + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + + NIcon { + icon: "sparkles" + color: Color.mPrimary + pointSize: Style.fontSizeXXL + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXS + + NText { + text: I18n.tr("changelog.panel.title", { + "version": currentVersion || UpdateService.currentVersion + }) + pointSize: Style.fontSizeXL + font.weight: Style.fontWeightBold + color: Color.mPrimary + wrapMode: Text.WordWrap + } + + NText { + text: subtitleText + color: Color.mOnSurface + opacity: Style.opacityMedium + wrapMode: Text.WordWrap + } + } + + Item { + Layout.fillWidth: true + } + + NIconButton { + icon: "close" + tooltipText: I18n.tr("tooltips.close") + onClicked: root.close() + Layout.alignment: Qt.AlignTop | Qt.AlignRight + Layout.preferredHeight: Style.baseWidgetSize + Layout.preferredWidth: Style.baseWidgetSize + } + } + + Rectangle { + clip: true + Layout.fillWidth: true + color: Qt.alpha(Color.mPrimary, 0.08) + radius: Style.radiusS + border.color: Color.mPrimary + border.width: Style.borderS + + RowLayout { + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginS + + NText { + text: hasPreviousVersion ? previousVersion : I18n.tr("changelog.panel.version.new-user") + font.weight: Style.fontWeightSemiBold + color: Color.mPrimary + } + + NIcon { + icon: "arrow-right" + color: Color.mPrimary + } + + NText { + text: currentVersion || UpdateService.currentVersion + font.weight: Style.fontWeightSemiBold + color: Color.mPrimary + } + } + } + + NDivider { + Layout.fillWidth: true + } + + NScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + horizontalPolicy: ScrollBar.AlwaysOff + verticalPolicy: ScrollBar.AsNeeded + padding: 0 + + ColumnLayout { + width: parent.width + spacing: Style.marginM + + NText { + text: I18n.tr("changelog.panel.highlight-title") + font.weight: Style.fontWeightBold + color: Color.mOnSurface + } + + NText { + visible: ChangelogService.fetchError !== "" + text: ChangelogService.fetchError + color: Color.mError + wrapMode: Text.WordWrap + } + + Repeater { + model: releaseHighlights + delegate: ColumnLayout { + width: parent.width + spacing: Style.marginS + + NText { + text: I18n.tr("changelog.panel.section.version", { + "version": modelData.version || I18n.tr("system.unknown-version") + }) + font.weight: Style.fontWeightBold + color: Color.mOnSurface + } + + NText { + visible: modelData.date && modelData.date.length > 0 + text: I18n.tr("changelog.panel.section.released", { + "date": root.formatReleaseDate(modelData.date) + }) + color: Color.mOnSurfaceVariant + pointSize: Style.fontSizeXS + } + + Repeater { + model: modelData.entries + delegate: RowLayout { + width: parent.width + spacing: Style.marginS + + Rectangle { + width: Style.marginXL + height: Style.marginXL + radius: Style.radiusS + color: Qt.alpha(Color.mPrimary, 0.12) + + NIcon { + anchors.centerIn: parent + icon: "check" + color: Color.mPrimary + pointSize: Style.fontSizeM + } + } + + NText { + text: modelData + color: Color.mOnSurface + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + } + + NDivider { + Layout.fillWidth: true + visible: index < releaseHighlights.length - 1 + } + } + } + + NText { + visible: releaseHighlights.length === 0 + text: I18n.tr("changelog.panel.empty") + color: Color.mOnSurfaceVariant + wrapMode: Text.WordWrap + } + } + } + + Rectangle { + Layout.fillWidth: true + color: Qt.alpha(Color.mOnSurfaceVariant, 0.08) + radius: Style.radiusS + border.color: Color.mOutline + border.width: Style.borderS + + RowLayout { + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginS + + NIcon { + icon: "palette" + color: Color.mOnSurface + } + + NText { + text: I18n.tr("changelog.panel.notes.color-schemes") + color: Color.mOnSurface + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + NButton { + Layout.fillWidth: true + icon: "brand-discord" + text: I18n.tr("changelog.panel.buttons.discord") + onClicked: ChangelogService.openDiscord() + } + + NButton { + Layout.fillWidth: true + visible: ChangelogService.feedbackUrl !== "" + icon: "forms" + text: I18n.tr("changelog.panel.buttons.feedback") + outlined: true + onClicked: ChangelogService.openFeedbackForm() + } + + NButton { + Layout.fillWidth: true + icon: "check" + text: I18n.tr("changelog.panel.buttons.dismiss") + backgroundColor: Color.mSurface + textColor: Color.mOnSurface + onClicked: root.close() + } + } + } + } + + onClosed: { + if (GitHubService && GitHubService.clearReleaseCache) { + GitHubService.clearReleaseCache(); + } + } + + function formatReleaseDate(dateString) { + if (!dateString || dateString.length === 0) + return ""; + try { + const date = new Date(dateString); + if (isNaN(date.getTime())) + return dateString; + return Qt.formatDate(date, Qt.DefaultLocaleLongDate); + } catch (error) { + return dateString; + } + } +} + diff --git a/Services/Noctalia/GitHubService.qml b/Services/Noctalia/GitHubService.qml index dff19b2ff..01d353cfe 100644 --- a/Services/Noctalia/GitHubService.qml +++ b/Services/Noctalia/GitHubService.qml @@ -12,11 +12,15 @@ Singleton { property string githubDataFile: Quickshell.env("NOCTALIA_GITHUB_FILE") || (Settings.cacheDir + "github.json") property int githubUpdateFrequency: 60 * 60 // 1 hour expressed in seconds property bool isFetchingData: false + property bool isReleasesFetching: false readonly property alias data: adapter // Used to access via GitHubService.data.xxx.yyy // Public properties for easy access property string latestVersion: I18n.tr("system.unknown-version") property var contributors: [] + property string releaseNotes: "" + property var releases: [] + property string releaseFetchError: "" FileView { id: githubDataFileView @@ -44,6 +48,8 @@ Singleton { property string version: I18n.tr("system.unknown-version") property var contributors: [] + property string releaseNotes: "" + property var releases: [] property real timestamp: 0 } } @@ -51,12 +57,13 @@ Singleton { // -------------------------------- function loadFromCache() { const now = Time.timestamp; + var needsRefetch = false; if (!data.timestamp || (now >= data.timestamp + githubUpdateFrequency)) { - Logger.d("GitHub", "Cache expired or missing, fetching new data"); - fetchFromGitHub(); - return; + needsRefetch = true; + Logger.d("GitHub", "Cache expired or missing, scheduling fetch"); + } else { + Logger.d("GitHub", "Loading cached GitHub data (age:", Math.round((now - data.timestamp) / 60), "minutes)"); } - Logger.d("GitHub", "Loading cached GitHub data (age:", Math.round((now - data.timestamp) / 60), "minutes)"); if (data.version) { root.latestVersion = data.version; @@ -64,6 +71,19 @@ Singleton { if (data.contributors) { root.contributors = data.contributors; } + if (data.releaseNotes) { + root.releaseNotes = data.releaseNotes; + } + if (data.releases && data.releases.length > 0) { + root.releases = data.releases; + } else { + Logger.d("GitHub", "Cached releases missing, scheduling fetch"); + needsRefetch = true; + } + + if (needsRefetch) { + fetchFromGitHub(); + } } // -------------------------------- @@ -76,13 +96,14 @@ Singleton { isFetchingData = true; versionProcess.running = true; contributorsProcess.running = true; + fetchAllReleases(); } // -------------------------------- function saveData() { data.timestamp = Time.timestamp; Logger.d("GitHub", "Saving data to cache file:", githubDataFile); - Logger.d("GitHub", "Data to save - version:", data.version, "contributors:", data.contributors.length); + Logger.d("GitHub", "Data to save - version:", data.version, "contributors:", data.contributors.length, "notes length:", data.releaseNotes ? data.releaseNotes.length : 0, "release count:", data.releases ? data.releases.length : 0); // Ensure cache directory exists Quickshell.execDetached(["mkdir", "-p", Settings.cacheDir]); @@ -98,12 +119,21 @@ Singleton { function resetCache() { data.version = I18n.tr("system.unknown-version"); data.contributors = []; + data.releaseNotes = ""; + data.releases = []; data.timestamp = 0; // Try to fetch immediately fetchFromGitHub(); } + function clearReleaseCache() { + Logger.d("GitHub", "Clearing cached release data"); + data.releases = []; + root.releases = []; + githubDataFileView.writeAdapter(); + } + Process { id: versionProcess @@ -120,9 +150,17 @@ Singleton { root.data.version = version; root.latestVersion = version; Logger.d("GitHub", "Latest version fetched from GitHub:", version); + } else if (data.message) { + Logger.w("GitHub", "Latest release fetch warning:", data.message); + handleRateLimitError(data.message); } else { Logger.w("GitHub", "No tag_name in GitHub response"); } + + if (data.body) { + root.data.releaseNotes = data.body; + root.releaseNotes = root.data.releaseNotes; + } } else { Logger.w("GitHub", "Empty response from GitHub API"); } @@ -169,12 +207,93 @@ Singleton { } } + // -------------------------------- + function fetchAllReleases(page, accumulator) { + if (isReleasesFetching && page === undefined) { + return; + } + + const perPage = 100; + var currentPage = page || 1; + var releasesAccumulator = accumulator || []; + isReleasesFetching = true; + + var request = new XMLHttpRequest(); + request.onreadystatechange = function () { + if (request.readyState === XMLHttpRequest.DONE) { + if (request.status >= 200 && request.status < 300) { + try { + const responseText = request.responseText || ""; + const parsed = responseText ? JSON.parse(responseText) : []; + if (Array.isArray(parsed) && parsed.length > 0) { + const mapped = parsed.map(rel => ({ + "version": rel.tag_name || "", + "createdAt": rel.published_at || rel.created_at || "", + "body": rel.body || "" + })).filter(rel => rel.version !== ""); + releasesAccumulator = releasesAccumulator.concat(mapped); + + if (parsed.length === perPage) { + fetchAllReleases(currentPage + 1, releasesAccumulator); + return; + } + } + finalizeReleaseFetch(releasesAccumulator); + } catch (error) { + Logger.e("GitHub", "Failed to parse releases:", error); + finalizeReleaseFetch([]); + } + } else { + if (request.status === 403) { + handleRateLimitError(); + } + Logger.e("GitHub", "Failed to fetch releases, status:", request.status); + finalizeReleaseFetch([]); + } + } + }; + + const url = `https://api.github.com/repos/noctalia-dev/noctalia-shell/releases?per_page=${perPage}&page=${currentPage}`; + request.open("GET", url); + request.send(); + } + + function finalizeReleaseFetch(releasesList) { + isReleasesFetching = false; + + if (releasesList && releasesList.length > 0) { + releasesList.sort(function (a, b) { + const dateA = a.createdAt ? Date.parse(a.createdAt) : 0; + const dateB = b.createdAt ? Date.parse(b.createdAt) : 0; + return dateB - dateA; + }); + root.data.releases = releasesList; + root.releases = releasesList; + releaseFetchError = ""; + Logger.d("GitHub", "Fetched releases:", releasesList.length); + } else { + root.data.releases = []; + root.releases = []; + if (!releaseFetchError) { + Logger.w("GitHub", "No releases fetched"); + } + } + + checkAndSaveData(); + } + // -------------------------------- function checkAndSaveData() { - // Only save when both processes are finished - if (!versionProcess.running && !contributorsProcess.running) { + // Only save when all processes are finished + if (!versionProcess.running && !contributorsProcess.running && !isReleasesFetching) { root.isFetchingData = false; root.saveData(); } } + + function handleRateLimitError(message) { + const limitMessage = message && message.length > 0 ? message : "API rate limit exceeded"; + Logger.w("GitHub", "Rate limit warning:", limitMessage); + releaseFetchError = I18n.tr("changelog.error.rate-limit"); + } } diff --git a/Services/UI/ChangelogService.qml b/Services/UI/ChangelogService.qml new file mode 100644 index 000000000..f1e73f1ed --- /dev/null +++ b/Services/UI/ChangelogService.qml @@ -0,0 +1,295 @@ +pragma Singleton + +import QtQuick +import Quickshell +import qs.Commons +import qs.Services.Noctalia +import qs.Services.UI + +Singleton { + id: root + + property bool initialized: false + property string previousVersion: "" + property string currentVersion: "" + property var releaseHighlights: [] + property string releaseNotesUrl: "" + property string discordUrl: "https://discord.noctalia.dev" + property string lastShownVersion: "" + property bool popupScheduled: false + property string feedbackUrl: Quickshell.env("NOCTALIA_CHANGELOG_FEEDBACK_URL") || "" + property string fetchError: "" + + signal popupQueued(string fromVersion, string toVersion) + + function init() { + if (initialized) + return; + + initialized = true; + Logger.i("ChangelogService", "Initialized"); + + if (Settings.changelogPending) { + handleChangelogRequest(Settings.changelogFromVersion, Settings.changelogToVersion); + } + } + + Connections { + target: Settings ? Settings : null + function onChangelogTriggered(fromVersion, toVersion) { + handleChangelogRequest(fromVersion, toVersion); + } + } + + Connections { + target: GitHubService ? GitHubService : null + function onReleaseNotesChanged() { + rebuildHighlights(); + } + function onReleasesChanged() { + rebuildHighlights(); + } + function onReleaseFetchErrorChanged() { + fetchError = GitHubService ? GitHubService.releaseFetchError : ""; + } + } + + function handleChangelogRequest(fromVersion, toVersion) { + if (!toVersion) + return; + + if (popupScheduled && currentVersion === toVersion) + return; + + if (!popupScheduled && lastShownVersion === toVersion) + return; + + previousVersion = fromVersion || ""; + currentVersion = toVersion; + fetchError = GitHubService ? GitHubService.releaseFetchError : ""; + releaseHighlights = buildReleaseHighlights(previousVersion, currentVersion); + releaseNotesUrl = buildReleaseNotesUrl(toVersion); + + popupScheduled = true; + root.popupQueued(previousVersion, currentVersion); + + Settings.clearChangelogRequest(); + openWhenReady(); + } + + function rebuildHighlights() { + if (!currentVersion) + return; + fetchError = GitHubService ? GitHubService.releaseFetchError : ""; + releaseHighlights = buildReleaseHighlights(previousVersion, currentVersion); + } + + function buildReleaseHighlights(fromVersion, toVersion) { + const releases = GitHubService && GitHubService.releases ? GitHubService.releases : []; + const selected = []; + const fromNorm = normalizeVersion(fromVersion); + const toNorm = normalizeVersion(toVersion); + + if (releases.length > 0) { + for (var i = 0; i < releases.length; i++) { + const rel = releases[i]; + const tag = rel.version || ""; + const tagNorm = normalizeVersion(tag); + if (!tagNorm) + continue; + + if (toNorm && compareVersions(tagNorm, toNorm) > 0) { + continue; + } + + if (fromNorm && compareVersions(tagNorm, fromNorm) <= 0) { + break; + } + + const entries = parseReleaseNotes(rel.body); + if (entries.length === 0) + continue; + + selected.push({ + "version": tag, + "date": rel.createdAt || "", + "entries": entries + }); + } + } + + if (selected.length === 0 && toVersion) { + const fallback = parseReleaseNotes(GitHubService ? GitHubService.releaseNotes : ""); + if (fallback.length > 0) { + selected.push({ + "version": toVersion, + "date": "", + "entries": fallback + }); + fetchError = ""; + } + } + + return selected; + } + + function normalizeVersion(version) { + if (!version) + return ""; + return version.startsWith("v") ? version.substring(1) : version; + } + + function parseVersionParts(version) { + const clean = normalizeVersion(version); + if (!clean) + return []; + return clean.split(/[^0-9]+/).filter(part => part.length > 0).map(part => parseInt(part)); + } + + function compareVersions(a, b) { + if (a === b) + return 0; + const partsA = parseVersionParts(a); + const partsB = parseVersionParts(b); + const length = Math.max(partsA.length, partsB.length); + for (var i = 0; i < length; i++) { + const valA = partsA[i] || 0; + const valB = partsB[i] || 0; + if (valA > valB) + return 1; + if (valA < valB) + return -1; + } + return 0; + } + + function buildReleaseNotesUrl(version) { + if (!version) + return ""; + const tag = version.startsWith("v") ? version : `v${version}`; + return `https://github.com/noctalia-dev/noctalia-shell/releases/tag/${tag}`; + } + + function parseReleaseNotes(body) { + if (!body) + return []; + + const lines = body.split(/\r?\n/); + var entries = []; + + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim(); + if (!line) + continue; + + if (line.startsWith("- ") || line.startsWith("* ")) { + const text = cleanEntry(line.substring(2).trim()); + if (text.length > 0 && !isVersionLine(text) && !isIgnoredEntry(text)) { + entries.push(text); + } + } + + if (entries.length >= 6) + break; + } + + var uniqueEntries = []; + var seen = {}; + for (var j = 0; j < entries.length; j++) { + const key = entries[j].toLowerCase(); + if (seen[key]) + continue; + seen[key] = true; + uniqueEntries.push(entries[j]); + } + + return uniqueEntries; + } + + function isVersionLine(text) { + return /^v?\d/i.test(text); + } + + function cleanEntry(text) { + if (!text) + return ""; + + var cleaned = text; + + // Strip markdown links [label](url) + cleaned = cleaned.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1").trim(); + + // Drop bare URLs or parentheses wrapping URLs + cleaned = cleaned.replace(/\((https?:\/\/[^)]+)\)/gi, "").trim(); + + cleaned = cleaned.replace(/\([0-9a-f]{7,}\)/gi, "").trim(); + cleaned = cleaned.replace(/\s+by\s+[A-Za-z0-9_-]+$/i, "").trim(); + cleaned = cleaned.replace(/\s{2,}/g, " "); + + if (cleaned.toLowerCase().startsWith("merge branch")) { + const ofIndex = cleaned.indexOf(" of "); + if (ofIndex > -1) { + cleaned = cleaned.substring(0, ofIndex).trim(); + } + } + + return cleaned; + } + + function isIgnoredEntry(text) { + const lower = text.toLowerCase(); + if (lower.startsWith("release v")) + return true; + if (lower.includes("autoformat") || lower.includes("auto-formatting")) + return true; + if (lower.includes("qmlfmt")) + return true; + return false; + } + + function openWhenReady() { + if (!popupScheduled) + return; + + if (!Quickshell.screens || Quickshell.screens.length === 0) { + Qt.callLater(openWhenReady); + return; + } + + const targetScreen = Quickshell.screens[0]; + const panel = PanelService.getPanel("changelogPanel", targetScreen); + if (!panel) { + Qt.callLater(openWhenReady); + return; + } + + panel.open(); + popupScheduled = false; + lastShownVersion = currentVersion; + } + + function openReleaseNotes() { + if (!releaseNotesUrl) + return; + Quickshell.execDetached(["xdg-open", releaseNotesUrl]); + } + + function openDiscord() { + if (!discordUrl) + return; + Quickshell.execDetached(["xdg-open", discordUrl]); + } + + function openFeedbackForm() { + if (!feedbackUrl) + return; + Quickshell.execDetached(["xdg-open", feedbackUrl]); + } + + function showLatestChangelog() { + if (!UpdateService || !UpdateService.currentVersion) + return; + handleChangelogRequest(Settings.data.changelog.lastSeenVersion, UpdateService.currentVersion); + } +} + diff --git a/shell.qml b/shell.qml index d41a0c05b..e70c4013e 100644 --- a/shell.qml +++ b/shell.qml @@ -80,6 +80,7 @@ ShellRoot { PowerProfileService.init(); HostService.init(); FontService.init(); + ChangelogService.init(); // Only open the setup wizard for new users if (!Settings.data.setupCompleted) {