Skip to content

Commit aad43ee

Browse files
Make wizard smart detect deterministic
1 parent ea65b4c commit aad43ee

6 files changed

Lines changed: 142 additions & 57 deletions

File tree

archive/folderview.plus-2026.03.21.12.txz.sha256

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
9ac8c53bdcd9f271457c353762d57538750c53b3da082a2b0e9a679c9d3b6e5f folderview.plus-2026.03.21.41.txz

folderview.plus.plg

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,19 @@
66
<!ENTITY launch "Settings/FolderViewPlus">
77
<!ENTITY plugdir "/usr/local/emhttp/plugins/&name;">
88
<!ENTITY pluginURL "https://raw.githubusercontent.com/&github;/dev/folderview.plus.plg">
9-
<!ENTITY version "2026.03.21.40">
10-
<!ENTITY md5 "2d30f6054962dcf9c49c8c5e777e5608">
9+
<!ENTITY version "2026.03.21.41">
10+
<!ENTITY md5 "f7495fd47195a61f75848f0360a72f29">
1111
]>
1212

1313
<PLUGIN name="&name;" author="&author;" version="&version;" launch="&launch;" pluginURL="&pluginURL;" icon="folder-icon.png" support="https://forums.unraid.net/topic/197631-plugin-folderview-plus/" min="7.0.0">
1414
<CHANGES>
1515

16+
###2026.03.21.41
17+
- Fix: Smart template creation in the setup wizard now derives folders from per-item best matches instead of weaker aggregate workload guesses.
18+
- Fix: Improved starter-folder auto-assignment reliability so mixed app stacks create and use the folders they actually need.
19+
- Regression guard: Added wizard coverage to ensure smart selection stays aligned with real container-to-folder matches.
20+
21+
1622
###2026.03.21.40
1723
- Fix: Expanded smart-detect coverage for Docker starter folders so more real-world app families are recognized and auto-assigned during wizard onboarding.
1824
- Quality: Smart category selection now uses richer runtime signals, including compose stacks, template metadata, bind paths, project/support URLs, and broader fuzzy token matching.

src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.wizard.js

Lines changed: 98 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -603,7 +603,7 @@ const getSetupAssistantTemplateCategoryState = (type) => {
603603
categoryIndexBuckets.get(normalized).add(index);
604604
};
605605

606-
const smartIndexes = resolveStarterTemplateSmartIndexes(resolvedType, blueprints);
606+
const smartIndexes = resolveSetupAssistantSmartBlueprintIndexes(resolvedType, blueprints).indexes;
607607
smartIndexes.forEach((index) => addCategoryIndex('smart', index));
608608
blueprints.forEach((entry, index) => {
609609
const categories = Array.isArray(entry?.categories) ? entry.categories : [];
@@ -691,37 +691,7 @@ const SETUP_ASSISTANT_TEMPLATE_FALLBACK_BY_TYPE = Object.freeze({
691691
docker: 'Utilities',
692692
vm: 'Utility VMs'
693693
});
694-
695-
const getSetupAssistantBlueprintHeuristicMap = (type, blueprintName) => {
696-
const resolvedType = normalizeManagedType(type);
697-
const normalizedName = normalizeSetupAssistantMatchText(blueprintName).replace(/\s+/g, '-');
698-
if (resolvedType === 'docker') {
699-
const dockerMap = {
700-
media: { contains: ['seerr', 'wizarr', 'listenarr', 'cleanuparr', 'agregarr', 'watch', 'request', 'discover'], pathContains: ['media', 'movies', 'shows', 'tv', 'music', 'books', 'audiobooks', 'anime', 'comics', 'photos'] },
701-
downloads: { contains: ['download', 'torrent', 'nzb', 'slsk', 'seed'] },
702-
monitoring: { contains: ['myspeed', 'speedtest', 'latency', 'uptime', 'metrics', 'telemetry'] },
703-
'cloud-&-sync': { contains: ['nextcloud', 'owncloud', 'seafile', 'cloud', 'sync', 'drive', 'collabora', 'onlyoffice'] },
704-
notifications: { contains: ['notify', 'notification', 'ntfy', 'gotify', 'apprise', 'notifiarr', 'pushover', 'webhook'] },
705-
utilities: { contains: ['qdirstat', 'diskspeed', 'ncdu', 'baobab', 'icons', 'icon', 'tool', 'utility', 'manager'], pathContains: ['appdata', 'storage', 'tools'] },
706-
automation: { contains: ['homeassistant', 'node red', 'n8n', 'mqtt', 'esphome', 'zigbee', 'zwave'] },
707-
database: { contains: ['postgres', 'mysql', 'mariadb', 'mongo', 'redis', 'database', 'db'] },
708-
security: { contains: ['clamav', 'antivirus', 'vaultwarden', 'authentik', 'authelia', 'crowdsec', 'fail2ban', 'security'] },
709-
development: { contains: ['git', 'code', 'dev', 'build', 'registry', 'runner', 'vscode'] },
710-
'ci-cd': { contains: ['jenkins', 'runner', 'drone', 'argocd', 'ci', 'cd'] },
711-
gaming: { contains: ['crafty', 'minecraft', 'palworld', 'valheim', 'satisfactory', 'steam', 'gameserver', 'server'] },
712-
'game-servers': { contains: ['crafty', 'pterodactyl', 'pelican', 'steamcmd', 'minecraft', 'palworld', 'valheim', 'satisfactory', 'terraria', 'enshrouded', 'gameserver', 'server'] }
713-
};
714-
return dockerMap[normalizedName] || null;
715-
}
716-
const vmMap = {
717-
'utility-vms': { contains: ['utility', 'tools', 'helper', 'management', 'admin'] },
718-
'network-vms': { contains: ['router', 'firewall', 'pfsense', 'opnsense', 'dns', 'proxy'] },
719-
'security-vms': { contains: ['security', 'siem', 'wazuh', 'ids', 'ips', 'firewall'] },
720-
'desktop-vms': { contains: ['desktop', 'workstation', 'windows', 'ubuntu', 'macos'] },
721-
'gaming-vms': { contains: ['gaming', 'steam', 'parsec', 'moonlight', 'sunshine', 'gpu'] }
722-
};
723-
return vmMap[normalizedName] || null;
724-
};
694+
const SETUP_ASSISTANT_TEMPLATE_MATCH_THRESHOLD = 4;
725695

726696
const collectSetupAssistantItemMatchProfile = (type, itemName, itemInfo) => {
727697
const resolvedType = normalizeManagedType(type);
@@ -863,21 +833,103 @@ const scoreSetupAssistantTemplateMatch = (profile, blueprint, type = 'docker') =
863833
}
864834
return false;
865835
};
836+
const getHeuristicBoost = () => {
837+
const normalizedName = normalizeSetupAssistantMatchText(blueprint?.name).replace(/\s+/g, '-');
838+
if (type !== 'docker') {
839+
return 0;
840+
}
841+
if (normalizedName === 'media') {
842+
let next = 0;
843+
if (hasTokenEndingWith('arr')) {
844+
next += 12;
845+
}
846+
if (hasTokenContainingAny(['seerr', 'wizarr', 'listenarr', 'cleanuparr', 'agregarr', 'watch', 'request', 'discover'])) {
847+
next += 10;
848+
}
849+
if (hasTokenContainingAny(['media', 'movies', 'shows', 'tv', 'music', 'books', 'audiobooks', 'anime', 'comics', 'photos'])) {
850+
next += 6;
851+
}
852+
return next;
853+
}
854+
if (normalizedName === 'game-servers' || normalizedName === 'gaming') {
855+
return hasTokenContainingAny(['crafty', 'pterodactyl', 'pelican', 'satisfactory', 'minecraft', 'palworld', 'valheim', 'steamcmd', 'gameserver', 'server']) ? 10 : 0;
856+
}
857+
if (normalizedName === 'monitoring') {
858+
return hasTokenContainingAny(['myspeed', 'speedtest', 'latency', 'uptime', 'metrics']) ? 10 : 0;
859+
}
860+
if (normalizedName === 'cloud-&-sync') {
861+
return hasTokenContainingAny(['nextcloud', 'owncloud', 'seafile', 'cloud', 'sync', 'drive', 'collabora', 'onlyoffice']) ? 10 : 0;
862+
}
863+
if (normalizedName === 'notifications') {
864+
return hasTokenContainingAny(['notify', 'notification', 'ntfy', 'gotify', 'apprise', 'notifiarr', 'pushover', 'webhook']) ? 10 : 0;
865+
}
866+
if (normalizedName === 'utilities') {
867+
let next = hasTokenContainingAny(['qdirstat', 'diskspeed', 'icons', 'icon', 'tool', 'utility', 'manager']) ? 10 : 0;
868+
if (hasTokenContainingAny(['appdata', 'storage', 'tools'])) {
869+
next += 6;
870+
}
871+
return next;
872+
}
873+
if (normalizedName === 'security') {
874+
return hasTokenContainingAny(['clamav', 'antivirus', 'vaultwarden', 'authentik', 'authelia', 'crowdsec', 'fail2ban', 'security']) ? 10 : 0;
875+
}
876+
return 0;
877+
};
866878
detectKeywords.forEach((keyword) => consumeKeyword(keyword, 1));
867879
consumeKeyword(blueprint.name, 0.6);
868-
const heuristic = getSetupAssistantBlueprintHeuristicMap(type, blueprint?.name || '');
869-
if (heuristic) {
870-
if (normalizeSetupAssistantMatchText(blueprint?.name).replace(/\s+/g, '-') === 'media' && hasTokenEndingWith('arr')) {
871-
score += 12;
880+
score += getHeuristicBoost();
881+
return score;
882+
};
883+
884+
const resolveSetupAssistantTemplateBestMatch = (type, profile, blueprints) => {
885+
const resolvedType = normalizeManagedType(type);
886+
let bestBlueprint = null;
887+
let bestScore = 0;
888+
let bestIndex = -1;
889+
(Array.isArray(blueprints) ? blueprints : []).forEach((blueprint, index) => {
890+
const score = scoreSetupAssistantTemplateMatch(profile, blueprint, resolvedType);
891+
if (score > bestScore) {
892+
bestBlueprint = blueprint;
893+
bestScore = score;
894+
bestIndex = index;
872895
}
873-
if (hasTokenContainingAny(heuristic.contains)) {
874-
score += 10;
896+
});
897+
return { bestBlueprint, bestScore, bestIndex };
898+
};
899+
900+
const resolveSetupAssistantSmartBlueprintIndexes = (type, blueprints) => {
901+
const resolvedType = normalizeManagedType(type);
902+
const safeBlueprints = Array.isArray(blueprints) ? blueprints : [];
903+
const itemNames = getBulkAssignableNames(resolvedType);
904+
const info = infoByType[resolvedType] || {};
905+
const matchedIndexes = new Set();
906+
const fallbackTemplateName = String(SETUP_ASSISTANT_TEMPLATE_FALLBACK_BY_TYPE[resolvedType] || '').trim();
907+
const fallbackIndex = safeBlueprints.findIndex((blueprint) => String(blueprint?.name || '').trim() === fallbackTemplateName);
908+
909+
let matched = 0;
910+
let unmatched = 0;
911+
itemNames.forEach((itemName) => {
912+
const profile = collectSetupAssistantItemMatchProfile(resolvedType, itemName, info[itemName] || {});
913+
const { bestScore, bestIndex } = resolveSetupAssistantTemplateBestMatch(resolvedType, profile, safeBlueprints);
914+
if (bestIndex >= 0 && bestScore >= SETUP_ASSISTANT_TEMPLATE_MATCH_THRESHOLD) {
915+
matchedIndexes.add(bestIndex);
916+
matched += 1;
917+
return;
875918
}
876-
if (hasTokenContainingAny(heuristic.pathContains)) {
877-
score += 6;
919+
if (fallbackIndex >= 0) {
920+
matchedIndexes.add(fallbackIndex);
921+
matched += 1;
922+
return;
878923
}
879-
}
880-
return score;
924+
unmatched += 1;
925+
});
926+
927+
return {
928+
indexes: matchedIndexes,
929+
totalItems: itemNames.length,
930+
matched,
931+
unmatched
932+
};
881933
};
882934

883935
const buildSetupAssistantTemplateAssignmentPreview = (type, selectedBlueprints) => {
@@ -899,20 +951,12 @@ const buildSetupAssistantTemplateAssignmentPreview = (type, selectedBlueprints)
899951
let unmatched = 0;
900952
itemNames.forEach((itemName) => {
901953
const profile = collectSetupAssistantItemMatchProfile(resolvedType, itemName, info[itemName] || {});
902-
let bestBlueprint = null;
903-
let bestScore = 0;
904-
blueprints.forEach((blueprint) => {
905-
const score = scoreSetupAssistantTemplateMatch(profile, blueprint, resolvedType);
906-
if (score > bestScore) {
907-
bestScore = score;
908-
bestBlueprint = blueprint;
909-
}
910-
});
911-
if ((!bestBlueprint || bestScore < 4) && fallbackBlueprint) {
954+
let { bestBlueprint, bestScore } = resolveSetupAssistantTemplateBestMatch(resolvedType, profile, blueprints);
955+
if ((!bestBlueprint || bestScore < SETUP_ASSISTANT_TEMPLATE_MATCH_THRESHOLD) && fallbackBlueprint) {
912956
bestBlueprint = fallbackBlueprint;
913-
bestScore = 4;
957+
bestScore = SETUP_ASSISTANT_TEMPLATE_MATCH_THRESHOLD;
914958
}
915-
if (!bestBlueprint || bestScore < 4) {
959+
if (!bestBlueprint || bestScore < SETUP_ASSISTANT_TEMPLATE_MATCH_THRESHOLD) {
916960
unmatched += 1;
917961
return;
918962
}

tests/setup-assistant-smart-detect.test.mjs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ const loadSmartDetectHelpers = (infoByType) => {
7373
' normalizeSetupAssistantMatchText,',
7474
' collectSetupAssistantItemMatchProfile,',
7575
' scoreSetupAssistantTemplateMatch,',
76+
' resolveSetupAssistantSmartBlueprintIndexes,',
7677
' buildSetupAssistantTemplateAssignmentPreview',
7778
'};'
7879
].join('\n')).runInContext(context);
@@ -271,6 +272,40 @@ test('wizard smart detect covers mixed real-world docker families without misses
271272
assert.deepEqual(Array.from(preview.assignedByTemplate.Notifications || []), ['Notify']);
272273
});
273274

275+
test('wizard smart starter selection derives folders from per-item best matches', () => {
276+
const infoByType = {
277+
docker: {
278+
listenarr: { info: { Config: { Image: 'ghcr.io/listenarr/listenarr:latest' } } },
279+
ClamAV: { info: { Config: { Image: 'clamav/clamav:latest' } } },
280+
'satisfactory-server': { info: { Config: { Image: 'wolveix/satisfactory-server:latest' } } },
281+
nextcloud: { info: { Config: { Image: 'nextcloud:latest' } } },
282+
Notify: { info: { Config: { Image: 'ghcr.io/notifiarr/notify:latest' } } },
283+
QDirStat: { info: { Config: { Image: 'ghcr.io/linuxserver/qdirstat:latest' } } }
284+
}
285+
};
286+
const runtime = loadSmartDetectHelpers(infoByType);
287+
const result = runtime.resolveSetupAssistantSmartBlueprintIndexes('docker', [
288+
{ name: 'Media', detect: ['listenarr', 'seerr', 'wizarr'] },
289+
{ name: 'Security', detect: ['vaultwarden', 'clamav'] },
290+
{ name: 'Utilities', detect: ['qdirstat', 'diskspeed', 'vm_custom_icons'] },
291+
{ name: 'Game Servers', detect: ['crafty', 'satisfactory-server'] },
292+
{ name: 'Cloud & Sync', detect: ['nextcloud'] },
293+
{ name: 'Notifications', detect: ['notify', 'notifiarr'] }
294+
]);
295+
const names = Array.from(result.indexes)
296+
.map((index) => ['Media', 'Security', 'Utilities', 'Game Servers', 'Cloud & Sync', 'Notifications'][index])
297+
.filter(Boolean);
298+
299+
assert.equal(result.totalItems, 6);
300+
assert.equal(result.unmatched, 0);
301+
assert.ok(names.includes('Media'));
302+
assert.ok(names.includes('Security'));
303+
assert.ok(names.includes('Utilities'));
304+
assert.ok(names.includes('Game Servers'));
305+
assert.ok(names.includes('Cloud & Sync'));
306+
assert.ok(names.includes('Notifications'));
307+
});
308+
274309
test('smart starter selection surfaces the needed folders for mixed docker workloads', () => {
275310
const infoByType = {
276311
docker: {

0 commit comments

Comments
 (0)