diff --git a/core/internal/plugins/feedback.go b/core/internal/plugins/feedback.go index fa9ba14cc..78b73b45c 100644 --- a/core/internal/plugins/feedback.go +++ b/core/internal/plugins/feedback.go @@ -11,13 +11,19 @@ import ( const feedbackURL = site.API + "/plugins" type Feedback struct { - Upvotes int - Status []string - IssueURL string - Similar []string + Upvotes int + Status []string + IssueURL string + Similar []string + Version string + Icon string + Permissions []string + CreatedAt string + UpdatedAt string } -// FetchFeedback retrieves community upvotes and moderator status from the directory API. +// FetchFeedback retrieves community upvotes, moderator status and directory metadata +// (version, icon, permissions, dates) from the directory API. // Best-effort: any failure returns a nil map. func FetchFeedback() map[string]Feedback { client := &http.Client{Timeout: 5 * time.Second} @@ -39,6 +45,12 @@ func FetchFeedback() map[string]Feedback { Status []string `json:"status"` IssueURL string `json:"issueUrl"` Similar []string `json:"similar"` + + Version string `json:"version"` + Icon string `json:"icon"` + Permissions []string `json:"permissions"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } `json:"plugins"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { @@ -47,7 +59,17 @@ func FetchFeedback() map[string]Feedback { feedback := make(map[string]Feedback, len(payload.Plugins)) for _, p := range payload.Plugins { - feedback[p.ID] = Feedback{Upvotes: p.Upvotes, Status: p.Status, IssueURL: p.IssueURL, Similar: p.Similar} + feedback[p.ID] = Feedback{ + Upvotes: p.Upvotes, + Status: p.Status, + IssueURL: p.IssueURL, + Similar: p.Similar, + Version: p.Version, + Icon: p.Icon, + Permissions: p.Permissions, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + } } return feedback } diff --git a/core/internal/server/plugins/handlers_test.go b/core/internal/server/plugins/handlers_test.go index 40e0a5a5b..be111d67b 100644 --- a/core/internal/server/plugins/handlers_test.go +++ b/core/internal/server/plugins/handlers_test.go @@ -171,3 +171,43 @@ func TestPluginInfoFromPluginIncludesScreenshot(t *testing.T) { assert.Equal(t, "https://raw.githubusercontent.com/AvengeMedia/dms-plugins/master/DankNotepadModule/screenshot.png", info.Screenshot) assert.True(t, info.FirstParty) } + +func TestApplyFeedbackCarriesDirectoryMetadata(t *testing.T) { + info := pluginInfoFromPlugin(coreplugins.Plugin{ID: "demo", Name: "Demo"}) + + applyFeedback(&info, coreplugins.Feedback{ + Upvotes: 7, + Status: []string{"reviewed"}, + IssueURL: "https://example.com/issues/1", + Similar: []string{"other"}, + Version: "1.2.0", + Icon: "extension", + Permissions: []string{"settings_read"}, + CreatedAt: "2026-01-02T03:04:05Z", + UpdatedAt: "2026-02-03T04:05:06Z", + }) + + raw, err := json.Marshal(info) + assert.NoError(t, err) + + var decoded map[string]any + assert.NoError(t, json.Unmarshal(raw, &decoded)) + assert.Equal(t, float64(7), decoded["upvotes"]) + assert.Equal(t, "1.2.0", decoded["version"]) + assert.Equal(t, "extension", decoded["icon"]) + assert.Equal(t, []any{"settings_read"}, decoded["permissions"]) + assert.Equal(t, "2026-01-02T03:04:05Z", decoded["created_at"]) + assert.Equal(t, "2026-02-03T04:05:06Z", decoded["updated_at"]) +} + +func TestApplyFeedbackOmitsMissingMetadata(t *testing.T) { + info := pluginInfoFromPlugin(coreplugins.Plugin{ID: "demo", Name: "Demo"}) + + applyFeedback(&info, coreplugins.Feedback{}) + + raw, err := json.Marshal(info) + assert.NoError(t, err) + assert.NotContains(t, string(raw), "created_at") + assert.NotContains(t, string(raw), "updated_at") + assert.NotContains(t, string(raw), "version") +} diff --git a/core/internal/server/plugins/list.go b/core/internal/server/plugins/list.go index f7dbc1c0d..3436410b1 100644 --- a/core/internal/server/plugins/list.go +++ b/core/internal/server/plugins/list.go @@ -34,13 +34,9 @@ func HandleList(conn *ipc.ConnWriter, req ipc.Request) { result := make([]PluginInfo, len(pluginList)) for i, p := range pluginList { installed, _ := manager.IsInstalled(p) - fb := feedback[p.ID] info := pluginInfoFromPlugin(p) info.Installed = installed - info.Upvotes = fb.Upvotes - info.Status = fb.Status - info.IssueURL = fb.IssueURL - info.Similar = fb.Similar + applyFeedback(&info, feedback[p.ID]) result[i] = info } diff --git a/core/internal/server/plugins/types.go b/core/internal/server/plugins/types.go index 5734b2d5a..464809a91 100644 --- a/core/internal/server/plugins/types.go +++ b/core/internal/server/plugins/types.go @@ -24,6 +24,11 @@ type PluginInfo struct { Status []string `json:"status,omitempty"` IssueURL string `json:"issueUrl,omitempty"` Similar []string `json:"similar,omitempty"` + Version string `json:"version,omitempty"` + Icon string `json:"icon,omitempty"` + Permissions []string `json:"permissions,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` } type SuccessResult struct { diff --git a/core/internal/server/plugins/utils.go b/core/internal/server/plugins/utils.go index 06cd5539a..8e36ff7f6 100644 --- a/core/internal/server/plugins/utils.go +++ b/core/internal/server/plugins/utils.go @@ -27,6 +27,18 @@ func pluginInfoFromPlugin(plugin coreplugins.Plugin) PluginInfo { } } +func applyFeedback(info *PluginInfo, fb coreplugins.Feedback) { + info.Upvotes = fb.Upvotes + info.Status = fb.Status + info.IssueURL = fb.IssueURL + info.Similar = fb.Similar + info.Version = fb.Version + info.Icon = fb.Icon + info.Permissions = fb.Permissions + info.CreatedAt = fb.CreatedAt + info.UpdatedAt = fb.UpdatedAt +} + func isFirstPartyRepo(repo string) bool { return strings.HasPrefix(repo, "https://github.com/AvengeMedia") } diff --git a/quickshell/Common/PluginRanking.js b/quickshell/Common/PluginRanking.js new file mode 100644 index 000000000..965f6e4e9 --- /dev/null +++ b/quickshell/Common/PluginRanking.js @@ -0,0 +1,303 @@ +.pragma library + +// Ranking for the plugin browser: recommendations, trending, picks of the +// day and of the week. Pure functions over the registry list, no I/O. +// +// A plugin is what the plugin list returns: id, category, author, upvotes, +// status, similar, compositors, firstParty, featured, installed, created_at, +// updated_at. Dates are ISO strings; a missing date is treated as old. + +const DAY_MS = 86400000; + +function _time(value) { + const t = Date.parse(value || ""); + return isNaN(t) ? 0 : t; +} + +function _days(from, now) { + if (!from) + return 3650; + return Math.max(0, (now - from) / DAY_MS); +} + +// Registry lists reach QML as sequences that Array.isArray() rejects. +function _list(value) { + if (!value || typeof value !== "object" || typeof value.length !== "number") + return []; + const out = []; + for (let i = 0; i < value.length; i++) + out.push(value[i]); + return out; +} + +function _has(list, value) { + return _list(list).indexOf(value) >= 0; +} + +function isUsable(plugin, compositor) { + if (!plugin || !plugin.id) + return false; + if (_has(plugin.status, "broken") || _has(plugin.status, "unmaintained")) + return false; + const comps = _list(plugin.compositors); + if (!compositor || comps.length === 0 || _has(comps, "any")) + return true; + return _has(comps, compositor); +} + +// Compositors a plugin is limited to when the running one is not among them, else []. +function unsupportedOn(plugin, compositor) { + const comps = _list(plugin ? plugin.compositors : null); + if (!compositor || comps.length === 0 || _has(comps, "any") || _has(comps, compositor)) + return []; + return comps; +} + +// filters: { usable, fresh, official }, every set one has to match. +function matchesFilters(plugin, filters, compositor, now) { + if (!filters) + return true; + if (filters.usable && !isUsable(plugin, compositor)) + return false; + if (filters.fresh && recency(plugin, now) === "") + return false; + if (filters.official && !plugin.firstParty) + return false; + return true; +} + +// What the user already runs, read from the installed plugins. +function buildTaste(plugins) { + const taste = { + installed: {}, + similar: {}, + categories: {}, + authors: {}, + count: 0 + }; + for (let i = 0; i < plugins.length; i++) { + const p = plugins[i]; + if (!p.installed) + continue; + taste.installed[p.id] = true; + taste.count++; + const cat = (p.category || "").toLowerCase(); + if (cat) + taste.categories[cat] = (taste.categories[cat] || 0) + 1; + if (p.author) + taste.authors[p.author] = (taste.authors[p.author] || 0) + 1; + const sim = p.similar || []; + for (let s = 0; s < sim.length; s++) + taste.similar[sim[s]] = (taste.similar[sim[s]] || 0) + 1; + } + return taste; +} + +// Votes with diminishing returns: the 40th vote weighs less than the 4th. +function popularity(plugin) { + return Math.log(1 + Math.max(0, plugin.upvotes || 0)) / Math.LN2; +} + +// Votes per unit of age, so a young plugin that gathers votes fast outranks +// an old one sitting on the same count. The two-week offset keeps a plugin +// published an hour ago from winning on a single vote. +function trending(plugin, now) { + const age = _days(_time(plugin.created_at), now); + return (plugin.upvotes || 0) / Math.pow(age + 14, 0.8); +} + +// 1 on the day of the last update, halves every 45 days. +function freshness(plugin, now) { + const age = _days(_time(plugin.updated_at), now); + return Math.pow(0.5, age / 45); +} + +// "new" for two weeks after a plugin enters the registry, "updated" for a +// week after a release, "" otherwise. Drives the badge on the browser cards. +function recency(plugin, now) { + const t = now || Date.now(); + if (_days(_time(plugin.created_at), t) <= 14) + return "new"; + if (_days(_time(plugin.updated_at), t) <= 7) + return "updated"; + return ""; +} + +function affinity(plugin, taste) { + if (!taste || taste.count === 0) + return 0; + let score = 0; + if (taste.similar[plugin.id]) + score += 2 + Math.min(2, taste.similar[plugin.id] - 1); + const sim = plugin.similar || []; + for (let s = 0; s < sim.length; s++) { + if (taste.installed[sim[s]]) { + score += 1; + break; + } + } + const cat = (plugin.category || "").toLowerCase(); + if (cat && taste.categories[cat]) + score += Math.min(1, taste.categories[cat] / taste.count * 2); + if (plugin.author && taste.authors[plugin.author]) + score += 0.5; + return score; +} + +function trust(plugin) { + let score = 0; + if (_has(plugin.status, "reviewed")) + score += 1; + if (plugin.firstParty) + score += 0.5; + if (plugin.featured) + score += 1; + return score; +} + +function scoreParts(plugin, taste, now) { + return { + popularity: popularity(plugin), + trending: trending(plugin, now) * 4, + freshness: freshness(plugin, now) * 1.5, + affinity: affinity(plugin, taste) * 1.5, + trust: trust(plugin) + }; +} + +function score(plugin, taste, now) { + const p = scoreParts(plugin, taste, now); + return p.popularity + p.trending + p.freshness + p.affinity + p.trust; +} + +// The strongest reason a plugin is shown, as a stable key the UI translates. +function reason(plugin, taste, now) { + if (taste && taste.similar[plugin.id]) + return "similar"; + if (plugin.featured) + return "featured"; + const parts = scoreParts(plugin, taste, now); + if (_days(_time(plugin.created_at), now) <= 14) + return "new"; + if (parts.trending >= parts.popularity && parts.trending >= parts.affinity) + return "trending"; + if (parts.affinity > 0 && parts.affinity >= parts.popularity) + return "category"; + return "popular"; +} + +function _ranked(plugins, now, options, scoreFn) { + const compositor = options.compositor; + const exclude = options.exclude || []; + const rows = []; + for (let i = 0; i < plugins.length; i++) { + const p = plugins[i]; + if (p.installed || !isUsable(p, compositor) || _has(exclude, p.id)) + continue; + rows.push({ + plugin: p, + score: scoreFn(p) + }); + } + rows.sort((a, b) => { + if (b.score !== a.score) + return b.score - a.score; + return a.plugin.id < b.plugin.id ? -1 : a.plugin.id > b.plugin.id ? 1 : 0; + }); + return rows; +} + +// Best first, with at most `perAuthor` entries by the same author so one +// prolific author cannot fill the shelf. When that leaves the shelf short, +// the skipped entries fill the remaining places in rank order. +function _diverse(rows, limit, perAuthor) { + const out = []; + const skipped = []; + const seen = {}; + for (let i = 0; i < rows.length && out.length < limit; i++) { + const author = rows[i].plugin.author || ""; + seen[author] = (seen[author] || 0) + 1; + if (author && seen[author] > perAuthor) { + skipped.push(rows[i].plugin); + continue; + } + out.push(rows[i].plugin); + } + for (let i = 0; i < skipped.length && out.length < limit; i++) + out.push(skipped[i]); + return out; +} + +function recommend(plugins, options) { + const o = options || {}; + const now = o.now || Date.now(); + const taste = o.taste || buildTaste(plugins); + const rows = _ranked(plugins, now, o, p => score(p, taste, now)); + return _diverse(rows, o.limit || 8, o.perAuthor || 2); +} + +function trendingNow(plugins, options) { + const o = options || {}; + const now = o.now || Date.now(); + const rows = _ranked(plugins, now, o, p => trending(p, now)).filter(r => r.score > 0); + return _diverse(rows, o.limit || 8, o.perAuthor || 2); +} + +function newArrivals(plugins, options) { + const o = options || {}; + const now = o.now || Date.now(); + const maxAge = o.maxAgeDays || 14; + const rows = _ranked(plugins, now, o, p => _time(p.created_at)).filter(r => r.score > 0 && _days(r.score, now) <= maxAge); + return _diverse(rows, o.limit || 8, o.perAuthor || 2); +} + +function _dayNumber(now) { + return Math.floor(now / DAY_MS); +} + +// Weeks start on Monday (day 0 of the epoch was a Thursday). +function _weekNumber(now) { + return Math.floor((_dayNumber(now) + 3) / 7); +} + +function _hash(text) { + let h = 2166136261; + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +// One pick per period, the same for everybody on the same day: drawn from the +// best reviewed plugins that have a screenshot, rotated by the date so the +// spot moves on instead of showing the top plugin forever. Installed plugins +// stay eligible on purpose: the pick is a shared headline, not a suggestion. +function _pick(plugins, now, key, poolSize, excludeId) { + const rows = []; + for (let i = 0; i < plugins.length; i++) { + const p = plugins[i]; + if (!isUsable(p, "") || !_has(p.status, "reviewed") || !p.screenshot || p.id === excludeId) + continue; + rows.push({ + plugin: p, + score: popularity(p) + trending(p, now) * 4 + freshness(p, now) + }); + } + if (rows.length === 0) + return null; + rows.sort((a, b) => b.score !== a.score ? b.score - a.score : (a.plugin.id < b.plugin.id ? -1 : 1)); + const pool = rows.slice(0, Math.min(poolSize, rows.length)); + return pool[_hash(key) % pool.length].plugin; +} + +function pickOfTheWeek(plugins, now) { + const t = now || Date.now(); + return _pick(plugins, t, "week:" + _weekNumber(t), 12, ""); +} + +function pickOfTheDay(plugins, now) { + const t = now || Date.now(); + const week = pickOfTheWeek(plugins, t); + return _pick(plugins, t, "day:" + _dayNumber(t), 30, week ? week.id : ""); +} diff --git a/quickshell/Modules/Settings/PluginCard.qml b/quickshell/Modules/Settings/PluginCard.qml index a45dd0875..891d22f28 100644 --- a/quickshell/Modules/Settings/PluginCard.qml +++ b/quickshell/Modules/Settings/PluginCard.qml @@ -97,8 +97,10 @@ DCard { Row { anchors.top: parent.top anchors.left: parent.left + anchors.right: upvoteBadge.visible ? upvoteBadge.left : parent.right anchors.margins: Theme.spacingXS spacing: Theme.spacingXXS + clip: true Repeater { model: root.badges @@ -113,6 +115,7 @@ DCard { } PluginBadge { + id: upvoteBadge anchors.top: parent.top anchors.right: parent.right anchors.margins: Theme.spacingXS diff --git a/quickshell/Services/PluginService.qml b/quickshell/Services/PluginService.qml index 7b074334a..786c01b97 100644 --- a/quickshell/Services/PluginService.qml +++ b/quickshell/Services/PluginService.qml @@ -7,6 +7,7 @@ import Quickshell import Quickshell.Io import qs.Common import qs.Services +import "../Common/PluginRanking.js" as PluginRanking Singleton { id: root @@ -1365,6 +1366,13 @@ Singleton { icon: "", tone: "warning" }); + var recency = PluginRanking.recency(plugin); + if (recency) + badges.push({ + label: recency === "new" ? I18n.tr("new", "adjective, lowercase plugin badge, recently added") : I18n.tr("updated", "adjective, lowercase plugin badge, recently updated"), + icon: recency === "new" ? "fiber_new" : "update", + tone: "secondary" + }); var status = plugin.status || []; for (var i = 0; i < status.length; i++) badges.push({ diff --git a/quickshell/tests/plugin-ranking.test.mjs b/quickshell/tests/plugin-ranking.test.mjs new file mode 100644 index 000000000..21eba2f3d --- /dev/null +++ b/quickshell/tests/plugin-ranking.test.mjs @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import vm from "node:vm"; +import test from "node:test"; + +const rank = vm.createContext({}); +vm.runInContext(readFileSync(new URL("../Common/PluginRanking.js", import.meta.url), "utf8").replace(".pragma library", ""), rank); + +const NOW = Date.UTC(2026, 9, 10); +const DAY = 86400000; +const ago = days => new Date(NOW - days * DAY).toISOString(); +const plugin = (id, extra) => Object.assign({ id, name: id, author: "a-" + id, category: "utilities", upvotes: 0, status: ["reviewed"], compositors: ["any"], screenshot: "x.png", created_at: ago(100), updated_at: ago(100) }, extra || {}); +const ids = list => Array.from(list, p => p.id); + +test("installed, broken, unmaintained and wrong-compositor plugins are never recommended", () => { + const list = [plugin("mine", { installed: true, upvotes: 50 }), plugin("broken", { status: ["broken"], upvotes: 50 }), plugin("old", { status: ["unmaintained"], upvotes: 50 }), plugin("hypr", { compositors: ["hyprland"], upvotes: 50 }), plugin("ok", { upvotes: 1 })]; + assert.deepEqual(ids(rank.recommend(list, { now: NOW, compositor: "niri" })), ["ok"]); + assert.deepEqual(ids(rank.recommend(list, { now: NOW, compositor: "hyprland" })), ["hypr", "ok"]); +}); + +test("a plugin similar to an installed one outranks a slightly more voted stranger", () => { + const list = [plugin("shot", { installed: true, similar: ["capture"] }), plugin("capture", { upvotes: 3 }), plugin("stranger", { upvotes: 6, category: "media" })]; + assert.equal(rank.recommend(list, { now: NOW })[0].id, "capture"); + assert.equal(rank.reason(list[1], rank.buildTaste(list), NOW), "similar"); +}); + +test("votes still win when the gap is large", () => { + const list = [plugin("shot", { installed: true, similar: ["capture"] }), plugin("capture", { upvotes: 1 }), plugin("star", { upvotes: 400, category: "media" })]; + assert.equal(rank.recommend(list, { now: NOW })[0].id, "star"); +}); + +test("trending favours votes gathered fast over the same votes gathered slowly", () => { + const list = [plugin("veteran", { upvotes: 10, created_at: ago(400) }), plugin("rising", { upvotes: 10, created_at: ago(10) }), plugin("silent", { upvotes: 0, created_at: ago(1) })]; + assert.deepEqual(ids(rank.trendingNow(list, { now: NOW })), ["rising", "veteran"]); +}); + +test("a single vote on a brand-new plugin does not beat an established one", () => { + const list = [plugin("fresh", { upvotes: 1, created_at: ago(0) }), plugin("solid", { upvotes: 30, created_at: ago(60) })]; + assert.equal(rank.trendingNow(list, { now: NOW })[0].id, "solid"); +}); + +test("one author gets at most two places", () => { + const list = ["a", "b", "c", "d"].map((id, i) => plugin(id, { author: "same", upvotes: 40 - i })).concat([plugin("other", { upvotes: 1 })]); + assert.deepEqual(ids(rank.recommend(list, { now: NOW, limit: 3 })), ["a", "b", "other"]); +}); + +test("a short shelf is filled with the entries the author limit skipped", () => { + const list = [1, 2, 3, 4].map(n => plugin("p" + n, { author: "same", upvotes: 10 - n })); + assert.deepEqual(ids(rank.recommend(list, { now: NOW, limit: 3 })), ["p1", "p2", "p3"]); + list.push(plugin("other", { upvotes: 1 })); + assert.deepEqual(ids(rank.recommend(list, { now: NOW, limit: 3 })), ["p1", "p2", "other"]); +}); + +test("excluded ids are left out of every shelf", () => { + const list = [plugin("a", { upvotes: 9, created_at: ago(2) }), plugin("b", { upvotes: 5, created_at: ago(3) })]; + const options = { now: NOW, exclude: ["a"] }; + assert.deepEqual(ids(rank.recommend(list, options)), ["b"]); + assert.deepEqual(ids(rank.trendingNow(list, options)), ["b"]); + assert.deepEqual(ids(rank.newArrivals(list, options)), ["b"]); +}); + +test("new arrivals are the last two weeks, newest first", () => { + const list = [plugin("month", { created_at: ago(30) }), plugin("week", { created_at: ago(6) }), plugin("today", { created_at: ago(0.2) }), plugin("undated", { created_at: "" })]; + assert.deepEqual(ids(rank.newArrivals(list, { now: NOW })), ["today", "week"]); +}); + +test("picks are stable within their period, differ from each other and rotate", () => { + const list = []; + for (let i = 0; i < 60; i++) + list.push(plugin("p" + i, { upvotes: 60 - i })); + const day = rank.pickOfTheDay(list, NOW); + const week = rank.pickOfTheWeek(list, NOW); + assert.equal(rank.pickOfTheDay(list, NOW + 5 * 3600000).id, day.id); + assert.notEqual(day.id, week.id); + const monday = Date.UTC(2026, 9, 5); + const weekIds = new Set([0, 1, 2, 3, 4, 5, 6].map(d => rank.pickOfTheWeek(list, monday + d * DAY).id)); + assert.equal(weekIds.size, 1); + const days = new Set(); + for (let d = 0; d < 30; d++) + days.add(rank.pickOfTheDay(list, NOW + d * DAY).id); + assert.ok(days.size >= 12, "30 days gave only " + days.size + " different picks"); + const weeks = new Set(); + for (let w = 0; w < 12; w++) + weeks.add(rank.pickOfTheWeek(list, NOW + w * 7 * DAY).id); + assert.ok(weeks.size >= 5, "12 weeks gave only " + weeks.size + " different picks"); +}); + +test("picks need a review and a screenshot, and survive an empty list", () => { + assert.equal(rank.pickOfTheDay([], NOW), null); + assert.equal(rank.pickOfTheWeek([plugin("raw", { status: [] }), plugin("blind", { screenshot: "" })], NOW), null); +}); + +test("no taste, no dates, no votes: still a deterministic order", () => { + const list = [{ id: "b" }, { id: "a" }, { id: "c", upvotes: 1 }]; + assert.deepEqual(ids(rank.recommend(list, { now: NOW })), ["c", "a", "b"]); +}); + +test("a card is marked new for two weeks, then updated for a week after a release", () => { + assert.equal(rank.recency(plugin("a", { created_at: ago(3), updated_at: ago(1) }), NOW), "new"); + assert.equal(rank.recency(plugin("b", { created_at: ago(90), updated_at: ago(2) }), NOW), "updated"); + assert.equal(rank.recency(plugin("c", { created_at: ago(90), updated_at: ago(30) }), NOW), ""); + assert.equal(rank.recency(plugin("d", { created_at: undefined, updated_at: undefined }), NOW), ""); +}); + +test("a plugin limited to other compositors names them, a portable one names none", () => { + assert.deepEqual(Array.from(rank.unsupportedOn(plugin("a", { compositors: ["hyprland", "sway"] }), "niri")), ["hyprland", "sway"]); + assert.deepEqual(Array.from(rank.unsupportedOn(plugin("b", { compositors: ["niri"] }), "niri")), []); + assert.deepEqual(Array.from(rank.unsupportedOn(plugin("c", { compositors: ["any"] }), "niri")), []); + assert.deepEqual(Array.from(rank.unsupportedOn(plugin("d", { compositors: ["hyprland"] }), "")), []); + assert.deepEqual(Array.from(rank.unsupportedOn(plugin("e", { compositors: undefined }), "niri")), []); +}); + +test("quick filters keep only the plugins matching every set filter", () => { + const here = plugin("a", { compositors: ["niri"], firstParty: true, created_at: ago(2), updated_at: ago(1) }); + const elsewhere = plugin("b", { compositors: ["hyprland"], firstParty: false, created_at: ago(200), updated_at: ago(100) }); + assert.equal(rank.matchesFilters(elsewhere, {}, "niri", NOW), true); + assert.equal(rank.matchesFilters(elsewhere, null, "niri", NOW), true); + assert.equal(rank.matchesFilters(here, { usable: true, fresh: true, official: true }, "niri", NOW), true); + assert.equal(rank.matchesFilters(elsewhere, { usable: true }, "niri", NOW), false); + assert.equal(rank.matchesFilters(elsewhere, { fresh: true }, "niri", NOW), false); + assert.equal(rank.matchesFilters(elsewhere, { official: true }, "niri", NOW), false); + assert.equal(rank.matchesFilters(here, { usable: true }, "hyprland", NOW), false); +}); + +test("lists that are not real arrays (QML sequences) are still read", () => { + const seq = items => Object.assign(Object.create(null), items, { length: items.length }); + assert.equal(rank.isUsable(plugin("a", { compositors: seq(["hyprland"]) }), "niri"), false); + assert.equal(rank.isUsable(plugin("b", { status: seq(["broken"]) }), "niri"), false); + assert.deepEqual(Array.from(rank.unsupportedOn(plugin("c", { compositors: seq(["hyprland"]) }), "niri")), ["hyprland"]); +}); diff --git a/quickshell/translations/check_term_variants.py b/quickshell/translations/check_term_variants.py index 09cc2ef23..b176412ae 100644 --- a/quickshell/translations/check_term_variants.py +++ b/quickshell/translations/check_term_variants.py @@ -26,6 +26,7 @@ ALLOWED = [ {"Device", "device"}, # label vs inline generic-noun fallback {"Until %1", "until %1"}, # sentence-initial vs mid-sentence position {"Up", "up"}, # scroll direction vs uptime prefix + {"New", "new"}, # create action vs lowercase plugin recency badge {"Custom Color", "Custom color"}, # dms-plugins checkout, not this repo {"Media Player", "Media player"}, # dms-plugins checkout, not this repo ]