plugins: ranking module and directory metadata for the plugin browser (1/3) (#3751)

* plugins: forward directory metadata (version, icon, permissions, dates) to the shell

* plugins: ranking module for recommendations, trending and picks

* plugins: mark new and recently updated plugins on the browser cards, keep badges clear of the vote count

* i18n: allow the lowercase plugin recency badge next to the New action
This commit is contained in:
lung595 authored and GitHub committed 2026-10-11 16:45:25 -04:00
1 parent 40b6eaa4fe
commit 846a0ff22f
10 files changed
+531 -11

No files matched your search

+28 -6
View File
@@ -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
}
@@ -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")
}
+1 -5
View File
@@ -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
}
+5
View File
@@ -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 {
+12
View File
@@ -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")
}
+303
View File
@@ -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 : "");
}
@@ -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
+8
View File
@@ -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({
+130
View File
@@ -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"]);
});
@@ -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
]