Skip to content

Commit cac6207

Browse files
Improve setup assistant smart-detect assignment
1 parent 47352ce commit cac6207

7 files changed

Lines changed: 221 additions & 29 deletions

File tree

archive/folderview.plus-2026.03.21.10.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+
78b27be37ecddd66dedff8ae529b78719c3ab313be01acb73dc31154f19d45f0 folderview.plus-2026.03.21.39.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.38">
10-
<!ENTITY md5 "25e5951678ea21c1ab8949215a895b2c">
9+
<!ENTITY version "2026.03.21.39">
10+
<!ENTITY md5 "b3503abd315c50f6a67443e14ef00a0a">
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.39
17+
- Fix: Improved setup wizard smart-detect matching so more Docker containers are auto-assigned into generated starter folders during new-install onboarding.
18+
- Quality: Smart-detect now scores compose-project names, Docker template metadata, project/support URLs, and richer runtime signals instead of relying on basic name/image tokens alone.
19+
- Quality: Added regression coverage for compose-based and template-metadata based smart-detect assignment paths.
20+
21+
1622
###2026.03.21.38
1723
- UX: Rebuilt the setup apply confirmation modal with compact metric cards, cleaner spacing, and consolidated summaries so it no longer feels stretched or cluttered.
1824
- UX: Refreshed the setup complete / partial-failure dialogs to match the same clean layout and readable grouped outcomes.

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

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3908,37 +3908,37 @@ const STARTER_TEMPLATE_BLUEPRINTS = Object.freeze({
39083908
name: 'Media',
39093909
icon: '/plugins/folderview.plus/images/icons/folder-media.svg',
39103910
categories: Object.freeze(['media', 'homelab']),
3911-
detect: Object.freeze(['plex', 'jellyfin', 'emby', 'sonarr', 'radarr', 'lidarr', 'readarr', 'bazarr', 'tautulli', 'audiobookshelf'])
3911+
detect: Object.freeze(['plex', 'jellyfin', 'emby', 'sonarr', 'radarr', 'lidarr', 'readarr', 'bazarr', 'tautulli', 'audiobookshelf', 'audiobook', 'prowlarr', 'overseerr', 'jellyseerr', 'whisparr', 'recyclarr', 'unpackerr', 'tdarr', 'fileflows', 'calibre', 'calibre-web', 'komga', 'kavita', 'navidrome', 'airsonic', 'tubearchivist', 'immich', 'photoprism', 'paperless', 'romm'])
39123912
}),
39133913
Object.freeze({
39143914
name: 'Downloads',
39153915
icon: '/plugins/folderview.plus/images/icons/folder-backup.svg',
39163916
categories: Object.freeze(['media', 'minimal']),
3917-
detect: Object.freeze(['qbittorrent', 'transmission', 'deluge', 'sabnzbd', 'jdownloader', 'aria2', 'torrent', 'nzb'])
3917+
detect: Object.freeze(['qbittorrent', 'transmission', 'deluge', 'sabnzbd', 'nzbget', 'jdownloader', 'aria2', 'slskd', 'soulseek', 'cross-seed', 'torrent', 'nzb'])
39183918
}),
39193919
Object.freeze({
39203920
name: 'Monitoring',
39213921
icon: '/plugins/folderview.plus/images/icons/folder-cloud.svg',
39223922
categories: Object.freeze(['ops', 'homelab', 'minimal']),
3923-
detect: Object.freeze(['grafana', 'prometheus', 'netdata', 'zabbix', 'telegraf', 'influx', 'loki', 'uptime', 'dozzle', 'glances'])
3923+
detect: Object.freeze(['grafana', 'prometheus', 'netdata', 'zabbix', 'telegraf', 'influx', 'influxdb', 'loki', 'promtail', 'uptime', 'uptime-kuma', 'dozzle', 'glances', 'beszel', 'scrutiny', 'cadvisor', 'healthchecks'])
39243924
}),
39253925
Object.freeze({
39263926
name: 'Network',
39273927
icon: '/plugins/folderview.plus/images/icons/folder-network.svg',
39283928
categories: Object.freeze(['network', 'homelab', 'minimal']),
3929-
detect: Object.freeze(['nginx', 'traefik', 'proxy', 'caddy', 'dns', 'pihole', 'wireguard', 'tailscale', 'cloudflared', 'vpn'])
3929+
detect: Object.freeze(['nginx', 'traefik', 'proxy', 'caddy', 'dns', 'pihole', 'adguard', 'adguardhome', 'wireguard', 'tailscale', 'zerotier', 'cloudflared', 'ddns', 'vpn'])
39303930
}),
39313931
Object.freeze({
39323932
name: 'Reverse Proxy',
39333933
icon: '/plugins/folderview.plus/images/icons/folder-network.svg',
39343934
categories: Object.freeze(['network', 'ops', 'homelab']),
3935-
detect: Object.freeze(['nginx-proxy-manager', 'traefik', 'caddy', 'swag', 'reverse-proxy'])
3935+
detect: Object.freeze(['nginx-proxy-manager', 'npm', 'traefik', 'caddy', 'swag', 'reverse-proxy'])
39363936
}),
39373937
Object.freeze({
39383938
name: 'DNS & Routing',
39393939
icon: '/plugins/folderview.plus/images/icons/folder-network.svg',
39403940
categories: Object.freeze(['network', 'ops', 'homelab']),
3941-
detect: Object.freeze(['pihole', 'adguard', 'dns', 'unbound', 'bind', 'dhcp'])
3941+
detect: Object.freeze(['pihole', 'adguard', 'adguardhome', 'dns', 'unbound', 'bind', 'dhcp'])
39423942
}),
39433943
Object.freeze({
39443944
name: 'Remote Access',
@@ -3950,7 +3950,7 @@ const STARTER_TEMPLATE_BLUEPRINTS = Object.freeze({
39503950
name: 'Utilities',
39513951
icon: '/plugins/folderview.plus/images/icons/folder-tools.svg',
39523952
categories: Object.freeze(['utility', 'ops', 'minimal', 'homelab']),
3953-
detect: Object.freeze(['portainer', 'watchtower', 'filebrowser', 'homarr', 'homepage', 'dashy', 'utilities', 'tools'])
3953+
detect: Object.freeze(['portainer', 'watchtower', 'filebrowser', 'homarr', 'homepage', 'dashy', 'krusader', 'commander', 'mc', 'it-tools', 'utilities', 'tools'])
39543954
}),
39553955
Object.freeze({
39563956
name: 'Dashboards',
@@ -3974,7 +3974,7 @@ const STARTER_TEMPLATE_BLUEPRINTS = Object.freeze({
39743974
name: 'Automation',
39753975
icon: '/plugins/folderview.plus/images/icons/folder-automation.svg',
39763976
categories: Object.freeze(['automation', 'homelab']),
3977-
detect: Object.freeze(['homeassistant', 'home-assistant', 'node-red', 'n8n', 'automation', 'mosquitto', 'mqtt', 'zigbee'])
3977+
detect: Object.freeze(['homeassistant', 'home-assistant', 'openhab', 'node-red', 'n8n', 'automation', 'mosquitto', 'mqtt', 'zigbee', 'zwave', 'zwavejs', 'esphome', 'scrypted', 'deconz'])
39783978
}),
39793979
Object.freeze({
39803980
name: 'Workflows',
@@ -3986,7 +3986,7 @@ const STARTER_TEMPLATE_BLUEPRINTS = Object.freeze({
39863986
name: 'Database',
39873987
icon: '/plugins/folderview.plus/images/icons/folder-database.svg',
39883988
categories: Object.freeze(['database', 'ops', 'homelab']),
3989-
detect: Object.freeze(['postgres', 'postgresql', 'mysql', 'mariadb', 'mongo', 'mongodb', 'redis', 'influxdb', 'database'])
3989+
detect: Object.freeze(['postgres', 'postgresql', 'pgadmin', 'mysql', 'mariadb', 'mongo', 'mongodb', 'redis', 'redisinsight', 'adminer', 'influxdb', 'database'])
39903990
}),
39913991
Object.freeze({
39923992
name: 'Cache & Queue',

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

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -680,15 +680,29 @@ const refreshSetupAssistantTemplateSelections = () => {
680680
}
681681
};
682682

683-
const collectSetupAssistantItemTokens = (type, itemName, itemInfo) => {
683+
const normalizeSetupAssistantMatchText = (value) => (
684+
String(value || '')
685+
.toLowerCase()
686+
.replace(/[^a-z0-9]+/g, ' ')
687+
.trim()
688+
);
689+
690+
const collectSetupAssistantItemMatchProfile = (type, itemName, itemInfo) => {
684691
const resolvedType = normalizeManagedType(type);
685692
const tokenSet = new Set();
686-
const addTokens = (value) => {
687-
const raw = String(value || '').toLowerCase();
688-
if (!raw) {
693+
const phraseSet = new Set();
694+
const textParts = [];
695+
const addTokens = (value, options = {}) => {
696+
const normalizedText = normalizeSetupAssistantMatchText(value);
697+
if (!normalizedText) {
689698
return;
690699
}
691-
raw.split(/[^a-z0-9]+/).forEach((token) => {
700+
const phraseEligible = options.allowPhrase !== false && normalizedText.length >= 3;
701+
if (phraseEligible) {
702+
phraseSet.add(normalizedText);
703+
textParts.push(normalizedText);
704+
}
705+
normalizedText.split(/\s+/).forEach((token) => {
692706
const normalized = String(token || '').trim();
693707
if (normalized.length >= 3) {
694708
tokenSet.add(normalized);
@@ -697,9 +711,21 @@ const collectSetupAssistantItemTokens = (type, itemName, itemInfo) => {
697711
};
698712
addTokens(itemName);
699713
if (resolvedType === 'docker') {
714+
const labels = itemInfo?.Labels || itemInfo?.info?.Config?.Labels || {};
700715
addTokens(itemInfo?.Image);
701716
addTokens(itemInfo?.info?.Config?.Image);
702-
const labels = itemInfo?.Labels || itemInfo?.info?.Config?.Labels || {};
717+
addTokens(itemInfo?.composeProject);
718+
addTokens(itemInfo?.folderLabel);
719+
addTokens(itemInfo?.manager);
720+
addTokens(itemInfo?.info?.State?.manager);
721+
addTokens(itemInfo?.info?.registry);
722+
addTokens(itemInfo?.info?.Project);
723+
addTokens(itemInfo?.info?.Support);
724+
addTokens(itemInfo?.info?.ReadMe);
725+
addTokens(itemInfo?.info?.template?.path);
726+
if (utils && typeof utils.getComposeProjectFromLabels === 'function') {
727+
addTokens(utils.getComposeProjectFromLabels(labels));
728+
}
703729
Object.entries(labels || {}).forEach(([key, value]) => {
704730
addTokens(key);
705731
addTokens(value);
@@ -710,31 +736,54 @@ const collectSetupAssistantItemTokens = (type, itemName, itemInfo) => {
710736
addTokens(itemInfo?.template);
711737
addTokens(itemInfo?.os);
712738
}
713-
return tokenSet;
739+
return {
740+
tokens: tokenSet,
741+
phrases: phraseSet,
742+
normalizedText: textParts.join(' ')
743+
};
714744
};
715745

716-
const scoreSetupAssistantTemplateMatch = (tokens, blueprint) => {
746+
const scoreSetupAssistantTemplateMatch = (profile, blueprint) => {
747+
const tokens = profile instanceof Set ? profile : profile?.tokens;
748+
const phrases = profile?.phrases instanceof Set ? profile.phrases : new Set();
749+
const normalizedText = String(profile?.normalizedText || '').trim();
717750
if (!(tokens instanceof Set) || tokens.size <= 0 || !blueprint || typeof blueprint !== 'object') {
718751
return 0;
719752
}
720753
const detectKeywords = Array.isArray(blueprint.detect) && blueprint.detect.length > 0
721754
? blueprint.detect
722755
: [String(blueprint.name || '')];
723756
let score = 0;
724-
const consumeKeyword = (keyword) => {
725-
const normalized = String(keyword || '').trim().toLowerCase();
757+
const consumeKeyword = (keyword, weight = 1) => {
758+
const normalized = normalizeSetupAssistantMatchText(keyword);
726759
if (!normalized) {
727760
return;
728761
}
729-
const parts = normalized.split(/[^a-z0-9]+/).filter((part) => part.length >= 3);
762+
const parts = normalized.split(/\s+/).filter((part) => part.length >= 3);
763+
if (parts.length <= 0) {
764+
return;
765+
}
766+
let keywordScore = 0;
767+
if (phrases.has(normalized)) {
768+
keywordScore = Math.max(keywordScore, 12 * weight);
769+
} else if (normalizedText.includes(normalized)) {
770+
keywordScore = Math.max(keywordScore, 8 * weight);
771+
}
772+
let matchedParts = 0;
730773
parts.forEach((part) => {
731774
if (tokens.has(part)) {
732-
score += 1;
775+
matchedParts += 1;
733776
}
734777
});
778+
if (matchedParts === parts.length) {
779+
keywordScore = Math.max(keywordScore, (5 + (matchedParts * 2)) * weight);
780+
} else if (matchedParts > 0) {
781+
keywordScore = Math.max(keywordScore, matchedParts * weight);
782+
}
783+
score += keywordScore;
735784
};
736-
detectKeywords.forEach(consumeKeyword);
737-
consumeKeyword(blueprint.name);
785+
detectKeywords.forEach((keyword) => consumeKeyword(keyword, 1));
786+
consumeKeyword(blueprint.name, 0.6);
738787
return score;
739788
};
740789

@@ -754,17 +803,17 @@ const buildSetupAssistantTemplateAssignmentPreview = (type, selectedBlueprints)
754803
let matched = 0;
755804
let unmatched = 0;
756805
itemNames.forEach((itemName) => {
757-
const tokens = collectSetupAssistantItemTokens(resolvedType, itemName, info[itemName] || {});
806+
const profile = collectSetupAssistantItemMatchProfile(resolvedType, itemName, info[itemName] || {});
758807
let bestBlueprint = null;
759808
let bestScore = 0;
760809
blueprints.forEach((blueprint) => {
761-
const score = scoreSetupAssistantTemplateMatch(tokens, blueprint);
810+
const score = scoreSetupAssistantTemplateMatch(profile, blueprint);
762811
if (score > bestScore) {
763812
bestScore = score;
764813
bestBlueprint = blueprint;
765814
}
766815
});
767-
if (!bestBlueprint || bestScore <= 0) {
816+
if (!bestBlueprint || bestScore < 4) {
768817
unmatched += 1;
769818
return;
770819
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import fs from 'node:fs';
4+
import path from 'node:path';
5+
import vm from 'node:vm';
6+
7+
const repoRoot = path.resolve(process.cwd());
8+
const wizardJsPath = path.join(
9+
repoRoot,
10+
'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.wizard.js'
11+
);
12+
const settingsJsPath = path.join(
13+
repoRoot,
14+
'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.js'
15+
);
16+
17+
const wizardJs = fs.readFileSync(wizardJsPath, 'utf8');
18+
const settingsJs = fs.readFileSync(settingsJsPath, 'utf8');
19+
20+
const startMarker = 'const normalizeSetupAssistantMatchText =';
21+
const endMarker = 'const buildSetupAssistantTemplatePlanForType =';
22+
const snippetStart = wizardJs.indexOf(startMarker);
23+
const snippetEnd = wizardJs.indexOf(endMarker);
24+
assert.ok(snippetStart >= 0 && snippetEnd > snippetStart, 'Expected smart-detect helper block in wizard script.');
25+
const smartDetectSnippet = wizardJs.slice(snippetStart, snippetEnd);
26+
27+
const loadSmartDetectHelpers = (infoByType) => {
28+
const context = {
29+
infoByType,
30+
normalizeManagedType: (value) => String(value || '').trim().toLowerCase(),
31+
getBulkAssignableNames: (type) => Object.keys(infoByType[String(type || '').trim().toLowerCase()] || {}),
32+
utils: {
33+
getComposeProjectFromLabels(labels = {}) {
34+
const direct = String(labels['com.docker.compose.project'] || '').trim();
35+
if (direct) {
36+
return direct;
37+
}
38+
const workingDir = String(labels['com.docker.compose.project.working_dir'] || '').trim();
39+
if (workingDir) {
40+
const parts = workingDir.split(/[\\/]+/).filter(Boolean);
41+
return parts[parts.length - 1] || '';
42+
}
43+
const configFiles = String(labels['com.docker.compose.project.config_files'] || '').trim();
44+
if (configFiles) {
45+
const normalized = configFiles.replace(/\\/g, '/');
46+
const match = normalized.match(/\/([^/]+)\/docker-compose[^/]*\.ya?ml$/i);
47+
if (match) {
48+
return match[1];
49+
}
50+
}
51+
return '';
52+
}
53+
},
54+
Set,
55+
Object,
56+
String,
57+
Number,
58+
Array,
59+
JSON
60+
};
61+
context.globalThis = context;
62+
vm.createContext(context);
63+
new vm.Script(`
64+
${smartDetectSnippet}
65+
globalThis.__smartDetect = {
66+
normalizeSetupAssistantMatchText,
67+
collectSetupAssistantItemMatchProfile,
68+
scoreSetupAssistantTemplateMatch,
69+
buildSetupAssistantTemplateAssignmentPreview
70+
};
71+
`).runInContext(context);
72+
return context.__smartDetect;
73+
};
74+
75+
test('wizard smart detect uses compose metadata and template metadata for docker assignment', () => {
76+
const infoByType = {
77+
docker: {
78+
'tdarr-node': {
79+
Labels: {
80+
'com.docker.compose.project.working_dir': '/mnt/user/appdata/media'
81+
},
82+
info: {
83+
Config: {
84+
Image: 'ghcr.io/haveagitgat/tdarr_node:latest'
85+
}
86+
}
87+
},
88+
overseerr: {
89+
info: {
90+
Config: {
91+
Image: 'sctx/overseerr:latest'
92+
},
93+
Project: 'https://github.com/sct/overseerr'
94+
}
95+
},
96+
'photos-app': {
97+
info: {
98+
Config: {
99+
Image: 'ghcr.io/custom/stack-base:latest'
100+
},
101+
template: {
102+
path: '/boot/config/plugins/dockerMan/templates-user/immich-server.xml'
103+
}
104+
}
105+
},
106+
'adguard-home': {
107+
info: {
108+
Config: {
109+
Image: 'adguard/adguardhome:latest'
110+
}
111+
}
112+
}
113+
}
114+
};
115+
const runtime = loadSmartDetectHelpers(infoByType);
116+
const preview = runtime.buildSetupAssistantTemplateAssignmentPreview('docker', [
117+
{ name: 'Media', detect: ['plex', 'overseerr', 'immich', 'tdarr'] },
118+
{ name: 'Network', detect: ['pihole', 'adguardhome', 'dns'] }
119+
]);
120+
121+
assert.equal(preview.matched, 4);
122+
assert.equal(preview.unmatched, 0);
123+
assert.deepEqual(Array.from(preview.assignedByTemplate.Media || []).sort(), ['overseerr', 'photos-app', 'tdarr-node']);
124+
assert.deepEqual(Array.from(preview.assignedByTemplate.Network || []), ['adguard-home']);
125+
});
126+
127+
test('starter template blueprints include broader docker smart-detect coverage for common stacks', () => {
128+
const mediaBlock = (settingsJs.match(/name:\s*'Media'[\s\S]*?detect:\s*Object\.freeze\(\[[\s\S]*?\]\)/) || [''])[0];
129+
const downloadsBlock = (settingsJs.match(/name:\s*'Downloads'[\s\S]*?detect:\s*Object\.freeze\(\[[\s\S]*?\]\)/) || [''])[0];
130+
const monitoringBlock = (settingsJs.match(/name:\s*'Monitoring'[\s\S]*?detect:\s*Object\.freeze\(\[[\s\S]*?\]\)/) || [''])[0];
131+
const networkBlock = (settingsJs.match(/name:\s*'Network'[\s\S]*?detect:\s*Object\.freeze\(\[[\s\S]*?\]\)/) || [''])[0];
132+
133+
assert.ok(mediaBlock.includes("'overseerr'") && mediaBlock.includes("'immich'") && mediaBlock.includes("'tdarr'"));
134+
assert.ok(downloadsBlock.includes("'nzbget'") && downloadsBlock.includes("'cross-seed'") && downloadsBlock.includes("'slskd'"));
135+
assert.ok(monitoringBlock.includes("'beszel'") && monitoringBlock.includes("'scrutiny'") && monitoringBlock.includes("'healthchecks'"));
136+
assert.ok(networkBlock.includes("'adguardhome'") && networkBlock.includes("'zerotier'") && networkBlock.includes("'cloudflared'"));
137+
});

0 commit comments

Comments
 (0)