Merge branch 'changelog'

This commit is contained in:
Ly-sec
2025-11-20 11:54:33 +01:00
17 changed files with 967 additions and 11 deletions
+126 -7
View File
@@ -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");
}
}
+292 -3
View File
@@ -3,16 +3,34 @@ pragma Singleton
import QtQuick
import Quickshell
import qs.Commons
import qs.Services.Noctalia
import qs.Services.UI
Singleton {
id: root
// Public properties
// Version properties
property string baseVersion: "3.1.1"
property bool isDevelopment: true
property string currentVersion: `v${!isDevelopment ? baseVersion : baseVersion + "-dev"}`
// Changelog properties
property bool initialized: false
property bool changelogPending: false
property string changelogFromVersion: ""
property string changelogToVersion: ""
property string previousVersion: ""
property string changelogCurrentVersion: ""
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)
// Internal helpers
function getVersion() {
return root.currentVersion;
@@ -24,7 +42,278 @@ Singleton {
}
function init() {
// Ensure the singleton is created
if (initialized)
return;
initialized = true;
Logger.i("UpdateService", "Version:", root.currentVersion);
}
Connections {
target: GitHubService ? GitHubService : null
function onReleaseNotesChanged() {
rebuildHighlights();
}
function onReleasesChanged() {
rebuildHighlights();
}
function onReleaseFetchErrorChanged() {
fetchError = GitHubService ? GitHubService.releaseFetchError : "";
}
}
function handleChangelogRequest() {
const fromVersion = changelogFromVersion || "";
const toVersion = changelogToVersion || "";
if (!toVersion)
return;
if (popupScheduled && changelogCurrentVersion === toVersion)
return;
if (!popupScheduled && lastShownVersion === toVersion)
return;
previousVersion = fromVersion;
changelogCurrentVersion = toVersion;
fetchError = GitHubService ? GitHubService.releaseFetchError : "";
releaseHighlights = buildReleaseHighlights(previousVersion, changelogCurrentVersion);
releaseNotesUrl = buildReleaseNotesUrl(toVersion);
popupScheduled = true;
root.popupQueued(previousVersion, changelogCurrentVersion);
clearChangelogRequest();
openWhenReady();
}
function rebuildHighlights() {
if (!changelogCurrentVersion)
return;
fetchError = GitHubService ? GitHubService.releaseFetchError : "";
releaseHighlights = buildReleaseHighlights(previousVersion, changelogCurrentVersion);
}
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 = changelogCurrentVersion;
}
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 (!currentVersion)
return;
const lastSeen = Settings.data.changelog.lastSeenVersion || "";
if (lastSeen === currentVersion)
return;
changelogFromVersion = lastSeen;
changelogToVersion = currentVersion;
changelogPending = true;
handleChangelogRequest();
}
function clearChangelogRequest() {
changelogPending = false;
changelogFromVersion = "";
changelogToVersion = "";
}
}