Files
2026-03-31 20:13:15 +08:00

670 lines
22 KiB
JavaScript

/*
SPDX-FileCopyrightText: 2024 Evgeny Kazantsev <exequtic@gmail.com>
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
}