/* SPDX-FileCopyrightText: 2024 Evgeny Kazantsev SPDX-License-Identifier: MIT */ const scriptDir = "$HOME/.local/share/plasma/plasmoids/com.github.exequtic.apdatifier/contents/tools/sh/" const configDir = "$HOME/.config/apdatifier/" const configFile = configDir + "config.conf" const cacheFile = configDir + "updates.json" const rulesFile = configDir + "rules.json" const newsFile = configDir + "news.json" function execute(command, callback, stoppable) { const component = Qt.createComponent("../ui/components/Shell.qml") if (component.status === Component.Ready) { const componentObject = component.createObject(root) if (componentObject) { if (stoppable) check = componentObject componentObject.exec(command, callback) } else { Error(1, "Failed to create executable DataSource object") } } else { Error(1, "Executable DataSource component not ready") } } const readFile = (file) => `[ -f "${file}" ] && cat "${file}"` const writeFile = (data, redir, file) => `echo '${data}' ${redir} "${file}"` const bash = (script, ...args) => scriptDir + script + ' ' + args.join(' ') const runInTerminal = (script, ...args) => execute('kstart ' + bash('terminal', script, ...args)) const debug = true function log(message) { if (debug) console.log("[" + new Date().getTime().toString() + "] "+ "APDATIFIER: " + message) } function Error(code, err) { if (err) { cfg.notifyErrors && notify.send("error", i18n("Exit code: ") + code, err.trim()) sts.errMsg = err.trim().substring(0, 150) + "..." setStatusBar(code) return true } return false } function init() { execute(bash('init'), (cmd, out, err, code) => { if (Error(code, err)) return loadConfig() }) function loadConfig() { execute(readFile(configFile), (cmd, out, err, code) => { if (Error(code, err)) return if (out) { const config = out.trim().split("\n") const convert = value => { if (!isNaN(parseFloat(value))) return parseFloat(value) if (value === "true" || value === "false") return value === 'true' return value } config.forEach(line => { const match = line.match(/(\w+)="([^"]*)"/) if (match) plasmoid.configuration[match[1]] = convert(match[2]) }) } loadCache() }) } function loadCache() { execute(readFile(cacheFile), (cmd, out, err, code) => { if (Error(code, err)) return if (out && validJSON(out, cacheFile)) cache = keys(JSON.parse(out.trim())) loadRules() }) } function loadRules() { execute(readFile(rulesFile), (cmd, out, err, code) => { if (Error(code, err)) return if (out && validJSON(out, rulesFile)) plasmoid.configuration.rules = out loadNews() }) } function loadNews() { execute(readFile(newsFile), (cmd, out, err, code) => { if (Error(code, err)) return if (out && validJSON(out, newsFile)) JSON.parse(out.trim()).forEach(item => newsModel.append(item)) onStartup() }) } function onStartup() { checkDependencies() refreshListModel() updateActiveNews() upgradingState(true) } } function saveConfig() { if (saveTimer.running) return let config = "" Object.keys(cfg).forEach(key => { if (key.endsWith("Default")) { let name = key.slice(0, -7) config += `${name}="${cfg[name]}"\n` } }) execute(writeFile(config, ">", configFile)) } function checkDependencies() { const pkgs = "pacman checkupdates flatpak paru yay jq tmux alacritty foot ghostty gnome-terminal kitty konsole lxterminal ptyxis terminator tilix wezterm xterm yakuake" const checkPkg = (pkgs) => `for pkg in ${pkgs}; do command -v $pkg || echo; done` const populate = (data) => data.map(item => ({ "name": item.split("/").pop(), "value": item })) execute(checkPkg(pkgs), (cmd, out, err, code) => { if (Error(code, err)) return const output = out.split("\n") const [pacman, checkupdates, flatpak, paru, yay, jq, tmux ] = output.map(Boolean) cfg.packages = { pacman, checkupdates, flatpak, paru, yay, jq, tmux } if (!cfg.wrapper) cfg.wrapper = paru ? "paru" : yay ? "yay" : "" const terminals = populate(output.slice(7).filter(Boolean)) cfg.terminals = terminals.length > 0 ? terminals : null if (!cfg.terminal) cfg.terminal = cfg.terminals.length > 0 ? cfg.terminals[0].value : "" if (!pacman) plasmoid.configuration.arch = false if (!pacman || (!yay && !paru)) plasmoid.configuration.aur = false if (!flatpak) plasmoid.configuration.flatpak = false if (!checkupdates) plasmoid.configuration.mirrors = "false" if (!tmux) plasmoid.configuration.tmuxSession = false if (!jq) { plasmoid.configuration.widgets = false plasmoid.configuration.newsArch = false plasmoid.configuration.newsKDE = false plasmoid.configuration.newsTWIK = false plasmoid.configuration.newsTWIKA = false } }) } function upgradePackage(name, appID, contentID) { if (sts.upgrading) return enableUpgrading(true) if (appID) { runInTerminal("upgrade", "flatpak", appID, name) } else if (contentID) { runInTerminal("upgrade", "widget", contentID, name) } } function management() { runInTerminal("management") } function enableUpgrading(state) { sts.busy = sts.upgrading = state if (state) { if (upgradeTimer.running) return upgradeTimer.start() searchTimer.stop() sts.statusMsg = i18n("Upgrade in progress") + "..." sts.statusIco = cfg.ownIconsUI ? "toolbar_upgrade" : "akonadiconsole" } else { upgradeTimer.stop() setStatusBar() } } function upgradingState(startup) { execute(`ps aux | grep "[a]pdatifier/contents/tools/sh/upgrade"`, (cmd, out, err, code) => { if (out || err) { enableUpgrading(true) } else if (startup) { if (!cfg.interval) return cfg.checkOnStartup ? searchTimer.triggered() : searchTimer.start() } else { enableUpgrading(false) execute(bash('upgrade', "postUpgrade"), (cmd, out, err, code) => postUpgrade(out)) } }) } function postUpgrade(out) { const newList = cache.filter(cached => { const current = JSON.parse(out).find(current => current.NM.replace(/ /g, "-").toLowerCase() === cached.NM) return current && current.VO === cached.VO + cached.AC }) if (JSON.stringify(cache) !== JSON.stringify(newList)) { cache = newList refreshListModel() saveCache(cache) } } function upgradeSystem() { if (sts.upgrading && !cfg.tmuxSession) return enableUpgrading(true) runInTerminal("upgrade", "full") } function checkUpdates() { if (sts.upgrading) return if (sts.busy) { check.cleanup() setStatusBar() return } searchTimer.stop() sts.busy = true sts.errMsg = "" const dbPath = pkg.checkupdates ? " --dbpath /tmp/apdatifier-db" : "" const pkgsync = "pacman -Sl" + dbPath const pkginfo = "pacman -Qi" + dbPath const pkgfiles = "pacman -Ql" + dbPath const checkupDB = "export CHECKUPDATES_DB=/tmp/apdatifier-db; checkupdates" const checkupAUR = `${cfg.wrapper} -Qua` + dbPath let arch = [], flatpak = [], widgets = [] const archCmd = !pkg.pacman || !cfg.arch ? false : pkg.checkupdates ? cfg.aur ? `${checkupDB}; ${checkupAUR}` : `${checkupDB}` : cfg.aur ? `${cfg.wrapper} -Qu` : "pacman -Qu" const feeds = [ cfg.newsArch && "'https://archlinux.org/feeds/news/'", cfg.newsKDE && "'https://kde.org/index.xml'", cfg.newsTWIK && "'https://blogs.kde.org/categories/this-week-in-plasma/index.xml'", cfg.newsTWIKA && "'https://blogs.kde.org/categories/this-week-in-kde-apps/index.xml'" ].filter(Boolean).join(' ') feeds ? checkNews() : archCmd ? checkArch() : cfg.flatpak ? checkFlatpak() : cfg.widgets ? checkWidgets() : merge() function checkNews() { sts.statusIco = cfg.ownIconsUI ? "status_news" : "news-subscribe" sts.statusMsg = i18n("Checking latest news...") execute(bash('utils', 'rss', feeds), (cmd, out, err, code) => { if (code) { cfg.notifyErrors && notify.send("error", i18n("Cannot fetch news "), out) } else { if (out) updateNews(out) } archCmd ? checkArch() : cfg.flatpak ? checkFlatpak() : cfg.widgets ? checkWidgets() : merge() }, true ) } function checkArch() { sts.statusIco = cfg.ownIconsUI ? "status_package" : "apdatifier-package" sts.statusMsg = i18n("Checking system updates...") execute(archCmd, (cmd, out, err, code) => { if (Error(code, err)) return out ? allArch(out.split("\n")) : cfg.flatpak ? checkFlatpak() : cfg.widgets ? checkWidgets() : merge() }, true ) } function allArch(upd) { execute(pkgsync, (cmd, out, err, code) => { if (Error(code, err)) return descArch(upd, out.split("\n").filter(line => /\[.*\]/.test(line))) }, true ) } function descArch(upd, all) { const pkgs = upd.map(l => l.split(" ")[0]).join(' ') execute(`${pkginfo} ${pkgs}`, (cmd, out, err, code) => { if (Error(code, err)) return iconsArch(upd, all, out, pkgs) }, true ) } function iconsArch(upd, all, desc, pkgs) { const getIcons = `\ while read -r pkg file; do [[ "$processed" == *"$pkg"* ]] && continue icon=$(awk -F= '/^Icon=/ {print $2; exit}' "$file" 2>/dev/null || true) && [ -n "$icon" ] || continue processed="$processed $pkg" echo "$pkg $icon" done < <(${pkgfiles} ${pkgs} | grep '/usr/share/applications/.*\.desktop$')` execute(getIcons, (cmd, out, err, code) => { const icons = (out && !err) ? out.split('\n').map(l => ({ NM: l.split(' ')[0], IN: l.split(' ')[1] })) : [] arch = makeArchList(upd, all, desc, icons) cfg.flatpak ? checkFlatpak() : cfg.widgets ? checkWidgets() : merge() }, true ) } function checkFlatpak() { sts.statusIco = cfg.ownIconsUI ? "status_flatpak" : "apdatifier-flatpak" sts.statusMsg = i18n("Checking flatpak updates...") execute("flatpak update --appstream >/dev/null 2>&1; flatpak remote-ls --app --updates --show-details", (cmd, out, err, code) => { if (Error(code, err)) return out ? descFlatpak(out.trim()) : cfg.widgets ? checkWidgets() : merge() }, true ) } function descFlatpak(upd) { execute("flatpak list --app --columns=application,version,active", (cmd, out, err, code) => { if (Error(code, err)) return flatpak = out ? makeFlatpakList(upd, out.trim()) : [] cfg.widgets ? checkWidgets() : merge() }, true ) } function checkWidgets() { sts.statusIco = cfg.ownIconsUI ? "status_widgets" : "start-here-kde-plasma-symbolic" sts.statusMsg = i18n("Checking widgets updates...") execute(bash('widgets', 'check'), (cmd, out, err, code) => { if (Error(code, err)) return out = out.trim() const errorTexts = { "127": i18n("Unable check widgets: ") + i18n("Required installed") + " jq", "1": i18n("Unable check widgets: ") + i18n("Failed to retrieve data from the API"), "2": i18n("Unable check widgets: ") + i18n("Too many API requests in the last 15 minutes from your IP address, please try again later"), "3": i18n("Unable check widgets: ") + i18n("Unkwnown error") } if (out in errorTexts) { Error(out, errorTexts[out]) return } widgets = JSON.parse(out) merge() }, true ) } function merge() { finalize(keys(arch.concat(flatpak, widgets))) } } function updateNews(out) { const news = JSON.parse(out.trim()) if (cfg.notifyNews) { const currentNews = Array.from(Array(newsModel.count), (_, i) => newsModel.get(i)) news.forEach(item => { if (!currentNews.some(currentItem => currentItem.link === item.link)) { notify.send("news", item.title, item.article, item.link) } }) } newsModel.clear() news.forEach(item => newsModel.append(item)) updateActiveNews() } function updateActiveNews() { const activeItems = Array.from({ length: newsModel.count }, (_, i) => newsModel.get(i)).filter(item => !item.removed) activeNewsModel.clear() activeItems.forEach(item => activeNewsModel.append(item)) } function removeNewsItem(index) { for (let i = 0; i < newsModel.count; i++) { if (newsModel.get(i).link === activeNewsModel.get(index).link) { newsModel.setProperty(i, "removed", true) activeNewsModel.remove(index) break } } let array = Array.from(Array(newsModel.count), (_, i) => newsModel.get(i)) execute(writeFile(toFileFormat(array), '>', newsFile)) } function restoreNewsList() { let array = [] for (let i = 0; i < newsModel.count; i++) { newsModel.setProperty(i, "removed", false) array.push(newsModel.get(i)) } execute(writeFile(toFileFormat(array), '>', newsFile)) updateActiveNews() } function makeArchList(updates, all, description, icons) { if (!updates || !all || !description) return [] description = description.replace(/^Installed From\s*:.+\n?/gm, '') const packagesData = description.split("\n\n") const skip = new Set([1, 3, 5, 9, 11, 15, 16, 19, 20]) const empty = new Set([6, 7, 8, 10, 12, 13]) const keyNames = { 0: "NM", 2: "DE", 4: "LN", 6: "GR", 7: "PR", 8: "DP", 10: "RQ", 12: "CF", 13: "RP", 14: "IS", 17: "DT", 18: "RN" } let extendedList = packagesData.map(packageData => { packageData = packageData.split('\n').filter(line => line.includes(" : ")) let packageObj = {} packageData.forEach((line, index) => { if (skip.has(index)) return const [, value] = line.split(/\s* : \s*/) if (empty.has(index) && value.charAt(0) === value.charAt(0).toUpperCase()) return if (keyNames[index]) packageObj[keyNames[index]] = value.trim() }) if (Object.keys(packageObj).length > 0) { updates.forEach(str => { const [name, verold, , vernew] = str.split(" ") if (packageObj.NM === name) { const verNew = (vernew === "latest-commit") ? i18n("latest commit") : vernew Object.assign(packageObj, { VO: verold, VN: verNew }) } }) const foundRepo = all.find(str => packageObj.NM === str.split(" ")[1]) packageObj.RE = foundRepo ? foundRepo.split(" ")[0] : (packageObj.NM.endsWith("-git") || packageObj.VN === i18n("latest commit") ? "devel" : "aur") const foundIcon = icons.find(item => item.NM === packageObj.NM) if (foundIcon) packageObj.IN = foundIcon.IN } return packageObj }) extendedList.pop() return extendedList } function makeFlatpakList(updates, description) { if (!updates || !description) return [] const list = description.split("\n").reduce((obj, line) => { const [ID, VO, AC] = line.split("\t").map(entry => entry.trim()) obj[ID] = { VO, AC } return obj }, {}) return updates.split("\n").map(line => { const [NM, DE, ID, VN, BR, , RE, , CM, RT, IS, DS] = line.split("\t").map(entry => entry.trim()) const { VO, AC } = list[ID] return { NM: NM.replace(/ /g, "-").toLowerCase(), DE, LN: "https://flathub.org/apps/" + ID, ID, BR, RE, AC, CM, RT, IS, DS, VO, VN: VO === VN ? i18n("latest commit") : VN } }) } function sortList(list, byName) { if (!list) return return list.sort((a, b) => { const name = a.NM.localeCompare(b.NM) const repo = a.RE.localeCompare(b.RE) if (byName || !cfg.sorting) return name if (a.IM !== b.IM) return a.IM ? -1 : 1 const develA = a.RE.includes("devel") const develB = b.RE.includes("devel") if (develA !== develB) return develA ? -1 : 1 const aurA = a.RE.includes("aur") const aurB = b.RE.includes("aur") if (aurA !== aurB) return aurA ? -1 : 1 return repo || name }) } function refreshListModel(list) { list = sortList(applyRules(list || cache)) || [] sts.count = list.length || 0 setStatusBar() if (!list) return listModel.clear() list.forEach(item => listModel.append(item)) } function finalize(list) { cfg.timestamp = new Date().getTime().toString() if (!list) { listModel.clear() execute(writeFile("[]", '>', cacheFile)) cache = [] sts.count = 0 setStatusBar() return } refreshListModel(list) if (cfg.notifyUpdates) { const cached = new Map(cache.map(el => [el.NM, el.VN])) const newList = applyRules(list).filter(el => !cached.has(el.NM) || (cfg.notifyEveryBump && cached.get(el.NM) !== el.VN)) if (newList.length > 0) { const title = i18np("+%1 new update", "+%1 new updates", newList.length) const body = newList.map(pkg => `${pkg.NM} → ${pkg.VN}`).join("\n") notify.send("updates", title, body) } } cache = list saveCache(cache) } function saveCache(list) { if (JSON.stringify(list).length > 130000) { let start = 0 const chunkSize = 200 const json = JSON.stringify(keys(sortList(JSON.parse(JSON.stringify(list)), true))).replace(/},/g, "},\n").replace(/'/g, "") const lines = json.split("\n") while (start < lines.length) { const chunk = lines.slice(start, start + chunkSize).join("\n") const redir = start === 0 ? ">" : ">>" execute(writeFile(chunk, redir, `${cacheFile}_${Math.ceil(start / chunkSize)}`)) start += chunkSize } execute(bash('utils', 'combineFiles', cacheFile)) } else { const json = toFileFormat(keys(sortList(JSON.parse(JSON.stringify(list)), true))) execute(writeFile(json, '>', cacheFile)) } } function setStatusBar(code) { sts.statusIco = sts.err ? "0" : sts.count > 0 ? "1" : "2" sts.statusMsg = sts.err ? "Exit code: " + code : sts.count > 0 ? sts.count + " " + i18np("update is pending", "updates are pending", sts.count) : "" sts.busy = false !cfg.interval ? searchTimer.stop() : searchTimer.restart() } function getLastCheckTime() { if (!cfg.timestamp) return "" const diff = new Date().getTime() - parseInt(cfg.timestamp) const sec = Math.round((diff / 1000) % 60) const min = Math.floor((diff / (1000 * 60)) % 60) const hrs = Math.floor(diff / (1000 * 60 * 60)) const lastcheck = i18n("Last check:") const second = i18np("%1 second", "%1 seconds", sec) const minute = i18np("%1 minute", "%1 minutes", min) const hour = i18np("%1 hour", "%1 hours", hrs) const ago = i18n("ago") if (hrs === 0 && min === 0) return `${lastcheck} ${second} ${ago}` if (hrs === 0) return `${lastcheck} ${minute} ${second} ${ago}` if (min === 0) return `${lastcheck} ${hour} ${ago}` return `${lastcheck} ${hour} ${minute} ${ago}` } function setIndex(value, arr) { let index = 0 for (let i = 0; i < arr.length; i++) { if (arr[i]["value"] == value) { index = i break } } return index } const defaultIcon = "apdatifier-plasmoid" function setIcon(icon) { return icon === "" ? defaultIcon : icon } function applyRules(list) { const rules = !cfg.rules ? [] : JSON.parse(cfg.rules) list.forEach(el => { el.IC = el.IN ? el.IN : el.ID ? el.ID : "apdatifier-package" el.EX = false el.IM = false }) function applyRule(el, rule) { const types = { 'all' : () => true, 'repo' : () => el.RE === rule.value, 'group' : () => el.GR.includes(rule.value), 'match' : () => el.NM.includes(rule.value), 'name' : () => el.NM === rule.value } if (types[rule.type]()) { el.IC = rule.icon el.EX = rule.excluded el.IM = rule.important } } rules.forEach(rule => list.forEach(el => applyRule(el, rule))) return list.filter(el => !el.EX) } function keys(list) { const keysList = ["GR", "PR", "DP", "RQ", "CF", "RP", "IS", "DT", "RN", "ID", "BR", "AC", "CM", "RT", "DS", "CN", "AU"] list.forEach(el => { keysList.forEach(key => { if (!el.hasOwnProperty(key)) el[key] = "" else if (el[key] === "") delete el[key] }) if (el.hasOwnProperty("IC")) delete el["IC"] if (el.hasOwnProperty("EX")) delete el["EX"] if (el.hasOwnProperty("IM")) delete el["IM"] }) return list } function switchInterval() { cfg.interval = !cfg.interval } function toFileFormat(obj) { const jsonStringWithSpace = JSON.stringify(obj, null, 2) const writebleJsonStrings = jsonStringWithSpace.replace(/'/g, "") return writebleJsonStrings } function validJSON(string, file) { try { const json = JSON.parse(string) if (json && typeof json === "object") return json } catch (e) { file ? Error(1, `JSON data at ${file} is corrupted or broken and cannot be processed`) : Error(1, "JSON data is broken and cannot be processed") } return false }