ChangelogPanel: nice formatting for changelogs

AboutTab: update version connection
GitHubService: cleanup, move changelog logic to UpdateService
UpdateService: use new changelog host
This commit is contained in:
Ly-sec
2025-11-21 11:01:59 +01:00
parent 972ac47c1b
commit 2f735eda81
14 changed files with 326 additions and 196 deletions
+2 -1
View File
@@ -397,7 +397,8 @@
},
"changelog": {
"error": {
"rate-limit": "GitHub-Limit erreicht. Bitte versuche es in ein paar Minuten erneut."
"rate-limit": "GitHub-Limit erreicht. Bitte versuche es in ein paar Minuten erneut.",
"fetch-failed": "Changelog-Daten konnten nicht geladen werden. Bitte versuche es später erneut."
},
"panel": {
"buttons": {
+2 -1
View File
@@ -397,7 +397,8 @@
},
"changelog": {
"error": {
"rate-limit": "GitHub rate limit exceeded. Please try again in a few minutes."
"rate-limit": "GitHub rate limit exceeded. Please try again in a few minutes.",
"fetch-failed": "Unable to load changelog data. Please try again later."
},
"panel": {
"buttons": {
+2 -1
View File
@@ -397,7 +397,8 @@
},
"changelog": {
"error": {
"rate-limit": "Se alcanzó el límite de GitHub. Inténtalo de nuevo en unos minutos."
"rate-limit": "Se alcanzó el límite de GitHub. Inténtalo de nuevo en unos minutos.",
"fetch-failed": "No se pudieron cargar los datos del registro de cambios. Inténtalo de nuevo más tarde."
},
"panel": {
"buttons": {
+2 -1
View File
@@ -397,7 +397,8 @@
},
"changelog": {
"error": {
"rate-limit": "Limite de GitHub atteinte. Réessayez dans quelques minutes."
"rate-limit": "Limite de GitHub atteinte. Réessayez dans quelques minutes.",
"fetch-failed": "Impossible de charger les données du journal des modifications. Veuillez réessayer plus tard."
},
"panel": {
"buttons": {
+2 -1
View File
@@ -397,7 +397,8 @@
},
"changelog": {
"error": {
"rate-limit": "GitHub-limiet bereikt. Probeer het over enkele minuten opnieuw."
"rate-limit": "GitHub-limiet bereikt. Probeer het over enkele minuten opnieuw.",
"fetch-failed": "Kan changeloggegevens niet laden. Probeer het later opnieuw."
},
"panel": {
"buttons": {
+2 -1
View File
@@ -397,7 +397,8 @@
},
"changelog": {
"error": {
"rate-limit": "Limite do GitHub atingido. Tente novamente em alguns minutos."
"rate-limit": "Limite do GitHub atingido. Tente novamente em alguns minutos.",
"fetch-failed": "Não foi possível carregar os dados do changelog. Tente novamente mais tarde."
},
"panel": {
"buttons": {
+2 -1
View File
@@ -397,7 +397,8 @@
},
"changelog": {
"error": {
"rate-limit": "Превышен лимит GitHub. Попробуйте снова через несколько минут."
"rate-limit": "Превышен лимит GitHub. Попробуйте снова через несколько минут.",
"fetch-failed": "Не удалось загрузить данные журнала изменений. Пожалуйста, попробуйте позже."
},
"panel": {
"buttons": {
+2 -1
View File
@@ -397,7 +397,8 @@
},
"changelog": {
"error": {
"rate-limit": "GitHub sınırına ulaşıldı. Lütfen birkaç dakika sonra tekrar dene."
"rate-limit": "GitHub sınırına ulaşıldı. Lütfen birkaç dakika sonra tekrar dene.",
"fetch-failed": "Değişiklik günlüğü verileri yüklenemedi. Lütfen daha sonra tekrar dene."
},
"panel": {
"buttons": {
+2 -1
View File
@@ -397,7 +397,8 @@
},
"changelog": {
"error": {
"rate-limit": "Перевищено ліміт GitHub. Спробуйте ще раз за кілька хвилин."
"rate-limit": "Перевищено ліміт GitHub. Спробуйте ще раз за кілька хвилин.",
"fetch-failed": "Не вдалося завантажити дані журналу змін. Будь ласка, спробуйте пізніше."
},
"panel": {
"buttons": {
+2 -1
View File
@@ -397,7 +397,8 @@
},
"changelog": {
"error": {
"rate-limit": "已达到 GitHub 速率限制,请稍后再试。"
"rate-limit": "已达到 GitHub 速率限制,请稍后再试。",
"fetch-failed": "无法加载更新日志数据,请稍后再试。"
},
"panel": {
"buttons": {
+28 -30
View File
@@ -128,12 +128,6 @@ SmartPanel {
width: parent.width
spacing: Style.marginM
NText {
text: I18n.tr("changelog.panel.highlight-title")
font.weight: Style.fontWeightBold
color: Color.mOnSurface
}
NText {
visible: UpdateService.fetchError !== ""
text: UpdateService.fetchError
@@ -153,6 +147,7 @@ SmartPanel {
})
font.weight: Style.fontWeightBold
color: Color.mOnSurface
pointSize: Style.fontSizeL
}
NText {
@@ -164,30 +159,21 @@ SmartPanel {
pointSize: Style.fontSizeXS
}
Repeater {
model: modelData.entries
delegate: RowLayout {
width: parent.width
spacing: Style.marginS
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginXS
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
Repeater {
model: modelData.entries
delegate: NText {
readonly property bool isHeading: root.isEmojiHeading(modelData)
text: modelData.length === 0 ? "\u00A0" : modelData
wrapMode: Text.WordWrap
elide: Text.ElideNone
textFormat: Text.PlainText
color: isHeading ? Color.mPrimary : Color.mOnSurface
font.weight: isHeading ? Style.fontWeightBold : Style.fontWeightMedium
pointSize: isHeading ? Style.fontSizeXL : Style.fontSizeM
Layout.fillWidth: true
}
}
@@ -240,9 +226,21 @@ SmartPanel {
}
}
function isEmojiHeading(text) {
if (!text)
return false;
const trimmed = text.trim();
if (trimmed.length === 0)
return false;
if (/^##\s*/i.test(trimmed))
return false;
const emojiHeading = /^[\u2600-\u27BF\u{1F300}-\u{1FAFF}]\s+/u;
return emojiHeading.test(trimmed);
}
onClosed: {
if (GitHubService && GitHubService.clearReleaseCache) {
GitHubService.clearReleaseCache();
if (UpdateService && UpdateService.clearReleaseCache) {
UpdateService.clearReleaseCache();
}
if (UpdateService && UpdateService.changelogCurrentVersion) {
UpdateService.markChangelogSeen(UpdateService.changelogCurrentVersion);
+1 -1
View File
@@ -11,7 +11,7 @@ import qs.Widgets
ColumnLayout {
id: root
property string latestVersion: GitHubService.latestVersion
property string latestVersion: UpdateService.latestVersion
property string currentVersion: UpdateService.currentVersion
property var contributors: GitHubService.contributors
+11 -123
View File
@@ -5,22 +5,18 @@ import Quickshell
import Quickshell.Io
import qs.Commons
// GitHub API logic and caching
// GitHub API logic for contributors
Singleton {
id: root
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
@@ -48,8 +44,6 @@ Singleton {
property string version: I18n.tr("system.unknown-version")
property var contributors: []
property string releaseNotes: ""
property var releases: []
property real timestamp: 0
}
}
@@ -71,15 +65,6 @@ 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();
@@ -96,14 +81,13 @@ 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, "notes length:", data.releaseNotes ? data.releaseNotes.length : 0, "release count:", data.releases ? data.releases.length : 0);
Logger.d("GitHub", "Data to save - version:", data.version, "contributors:", data.contributors.length);
// Ensure cache directory exists
Quickshell.execDetached(["mkdir", "-p", Settings.cacheDir]);
@@ -115,25 +99,25 @@ Singleton {
});
}
// --------------------------------
function checkAndSaveData() {
// Only save when all processes are finished
if (!versionProcess.running && !contributorsProcess.running) {
root.isFetchingData = false;
root.saveData();
}
}
// --------------------------------
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
@@ -152,15 +136,9 @@ Singleton {
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");
}
@@ -206,94 +184,4 @@ 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 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");
}
}
+266 -32
View File
@@ -34,6 +34,15 @@ Singleton {
property bool changelogStateLoaded: false
property bool pendingShowRequest: false
// Changelog fetching
property string changelogBaseUrl: Quickshell.env("NOCTALIA_CHANGELOG_URL") || "https://noctalia.dev:7777/changelogs"
property int changelogFetchLimit: 25
property int changelogUpdateFrequency: 60 * 60 // 1 hour in seconds
property bool isFetchingChangelogs: false
property string releaseNotes: ""
property var releases: []
property string changelogDataFile: Quickshell.env("NOCTALIA_CHANGELOG_FILE") || (Settings.cacheDir + "changelogs.json")
// Fix for FileView race condition
property bool saveInProgress: false
property bool pendingSave: false
@@ -59,16 +68,42 @@ Singleton {
Logger.i("UpdateService", "Version:", root.currentVersion);
}
Connections {
target: GitHubService ? GitHubService : null
function onReleaseNotesChanged() {
rebuildHighlights();
// Watch for changes to trigger highlight rebuilds
onReleasesChanged: {
rebuildHighlights();
}
onReleaseNotesChanged: {
rebuildHighlights();
}
// Changelog data cache
FileView {
id: changelogDataFileView
path: root.changelogDataFile
watchChanges: true
onFileChanged: reload()
onAdapterUpdated: writeAdapter()
Component.onCompleted: {
reload();
}
function onReleasesChanged() {
rebuildHighlights();
onLoaded: {
loadChangelogCache();
}
function onReleaseFetchErrorChanged() {
fetchError = GitHubService ? GitHubService.releaseFetchError : "";
onLoadFailed: function (error) {
if (error.toString().includes("No such file") || error === 2) {
Qt.callLater(() => {
fetchChangelogs();
});
}
}
JsonAdapter {
id: changelogAdapter
property string releaseNotes: ""
property var releases: []
property real timestamp: 0
}
}
@@ -120,7 +155,6 @@ Singleton {
previousVersion = fromVersion;
changelogCurrentVersion = toVersion;
fetchError = GitHubService ? GitHubService.releaseFetchError : "";
releaseHighlights = buildReleaseHighlights(previousVersion, changelogCurrentVersion);
releaseNotesUrl = buildReleaseNotesUrl(toVersion);
@@ -134,12 +168,10 @@ Singleton {
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);
@@ -173,7 +205,7 @@ Singleton {
}
if (selected.length === 0 && toVersion) {
const fallback = parseReleaseNotes(GitHubService ? GitHubService.releaseNotes : "");
const fallback = parseReleaseNotes(releaseNotes);
if (fallback.length > 0) {
selected.push({
"version": toVersion,
@@ -221,10 +253,10 @@ Singleton {
if (!version)
return "";
const tag = version.startsWith("v") ? version : `v${version}`;
return `https://github.com/noctalia-dev/noctalia-shell/releases/tag/${tag}`;
return `${changelogBaseUrl}/CHANGELOG-${tag}.txt`;
}
function parseReleaseNotes(body) {
function parseReleaseNotes(body) {
if (!body)
return [];
@@ -232,32 +264,36 @@ Singleton {
var entries = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line)
continue;
const line = lines[i];
const trimmed = line.trim();
if (line.startsWith("- ") || line.startsWith("* ")) {
const text = cleanEntry(line.substring(2).trim());
if (text.length > 0 && !isVersionLine(text) && !isIgnoredEntry(text)) {
entries.push(text);
if (trimmed.match(/^Release\s+v[0-9]/i)) {
continue;
}
if (trimmed.match(/^##\s*Changes since/i)) {
break;
}
// If this line is just an emoji and the next line has text, merge them
if (trimmed.match(/^[\u{1F000}-\u{1F9FF}]$/u) && i + 1 < lines.length) {
const nextLine = lines[i + 1].trim();
if (nextLine.length > 0) {
entries.push(trimmed + " " + nextLine);
i++; // Skip the next line since we merged it
continue;
}
}
if (entries.length >= 6)
break;
entries.push(line);
}
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]);
// Remove trailing blank lines
while (entries.length > 0 && entries[entries.length - 1].trim().length === 0) {
entries.pop();
}
return uniqueEntries;
return entries;
}
function isVersionLine(text) {
@@ -441,4 +477,202 @@ Singleton {
// Immediate save (backward compatibility)
debouncedSaveChangelogState();
}
// Changelog fetching functions
function loadChangelogCache() {
const now = Time.timestamp;
var needsRefetch = false;
if (!changelogAdapter.timestamp || (now >= changelogAdapter.timestamp + changelogUpdateFrequency)) {
needsRefetch = true;
Logger.d("UpdateService", "Changelog cache expired or missing, scheduling fetch");
} else {
Logger.d("UpdateService", "Loading cached changelog data (age:", Math.round((now - changelogAdapter.timestamp) / 60), "minutes)");
}
if (changelogAdapter.releaseNotes) {
root.releaseNotes = changelogAdapter.releaseNotes;
}
if (changelogAdapter.releases && changelogAdapter.releases.length > 0) {
root.releases = changelogAdapter.releases;
} else {
Logger.d("UpdateService", "Cached releases missing, scheduling fetch");
needsRefetch = true;
}
if (needsRefetch) {
fetchChangelogs();
}
}
function fetchChangelogs() {
if (isFetchingChangelogs) {
Logger.w("UpdateService", "Changelog data is still fetching");
return;
}
isFetchingChangelogs = true;
fetchError = "";
fetchChangelogIndex();
}
function fetchChangelogIndex() {
const request = new XMLHttpRequest();
request.onreadystatechange = function () {
if (request.readyState === XMLHttpRequest.DONE) {
if (request.status >= 200 && request.status < 300) {
const entries = parseChangelogIndex(request.responseText || "");
if (entries.length === 0) {
Logger.w("UpdateService", "No changelog entries found at", changelogBaseUrl);
fetchError = I18n.tr("changelog.error.fetch-failed");
finalizeChangelogFetch([]);
} else {
fetchChangelogFiles(entries, 0, []);
}
} else {
Logger.e("UpdateService", "Failed to fetch changelog index:", request.status, request.responseText);
fetchError = I18n.tr("changelog.error.fetch-failed");
finalizeChangelogFetch([]);
}
}
};
request.open("GET", changelogBaseUrl);
request.send();
}
function parseChangelogIndex(content) {
if (!content)
return [];
const lines = content.split(/\r?\n/);
var entries = [];
for (var i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
const match = trimmed.match(/CHANGELOG-(v[0-9A-Za-z.\-]+)\.txt/);
if (match && match.length >= 2) {
const version = match[1];
const fileName = match[0];
var modified = "";
for (var j = i + 1; j < Math.min(lines.length, i + 4); j++) {
const modLine = lines[j].trim();
const modMatch = modLine.match(/^Last modified:\s*(.+)$/i);
if (modMatch && modMatch.length >= 2) {
modified = modMatch[1].trim();
break;
}
}
entries.push({
"version": version,
"fileName": fileName,
"url": `${changelogBaseUrl}/${fileName}`,
"createdAt": modified
});
}
}
entries.sort(function (a, b) {
return compareVersions(b.version, a.version);
});
if (entries.length > changelogFetchLimit) {
entries = entries.slice(0, changelogFetchLimit);
}
return entries;
}
function fetchChangelogFiles(entries, index, accumulator) {
if (!entries || entries.length === 0) {
finalizeChangelogFetch([]);
return;
}
if (index >= entries.length) {
finalizeChangelogFetch(accumulator);
return;
}
const entry = entries[index];
const request = new XMLHttpRequest();
request.onreadystatechange = function () {
if (request.readyState === XMLHttpRequest.DONE) {
if (request.status >= 200 && request.status < 300) {
accumulator.push({
"version": entry.version,
"createdAt": entry.createdAt || "",
"body": request.responseText || ""
});
} else {
Logger.e("UpdateService", "Failed to fetch changelog file:", entry.url, "status:", request.status);
if (!fetchError) {
fetchError = I18n.tr("changelog.error.fetch-failed");
}
}
fetchChangelogFiles(entries, index + 1, accumulator);
}
};
request.open("GET", entry.url);
request.send();
}
function finalizeChangelogFetch(releasesList) {
isFetchingChangelogs = false;
if (releasesList && releasesList.length > 0) {
releasesList.sort(function (a, b) {
return compareVersions(b.version, a.version);
});
changelogAdapter.releases = releasesList;
root.releases = releasesList;
const latest = releasesList[0];
if (latest) {
changelogAdapter.releaseNotes = latest.body || "";
root.releaseNotes = changelogAdapter.releaseNotes;
}
if (!fetchError) {
Logger.d("UpdateService", "Fetched changelog entries:", releasesList.length);
}
} else {
changelogAdapter.releases = [];
root.releases = [];
if (!fetchError) {
Logger.w("UpdateService", "No changelog entries fetched");
fetchError = I18n.tr("changelog.error.fetch-failed");
}
}
saveChangelogData();
}
function saveChangelogData() {
changelogAdapter.timestamp = Time.timestamp;
Logger.d("UpdateService", "Saving changelog data to cache file:", changelogDataFile);
// Ensure cache directory exists
Quickshell.execDetached(["mkdir", "-p", Settings.cacheDir]);
Qt.callLater(() => {
changelogDataFileView.writeAdapter();
Logger.d("UpdateService", "Changelog cache file written successfully");
});
}
function resetChangelogCache() {
changelogAdapter.version = I18n.tr("system.unknown-version");
changelogAdapter.releaseNotes = "";
changelogAdapter.releases = [];
changelogAdapter.timestamp = 0;
fetchChangelogs();
}
function clearReleaseCache() {
Logger.d("UpdateService", "Clearing cached release data");
changelogAdapter.releases = [];
root.releases = [];
changelogDataFileView.writeAdapter();
}
}