diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-format.js b/containers/libreportal/frontend/components/tasks/js/tasks-format.js index 40a866e..643289c 100644 --- a/containers/libreportal/frontend/components/tasks/js/tasks-format.js +++ b/containers/libreportal/frontend/components/tasks/js/tasks-format.js @@ -11,7 +11,26 @@ Object.assign(TasksManager.prototype, { // Adding a new task: just append one row here. Add the matching command // shape in the WebUI submission site and you're done — no new branch // needed in this function. - const displayName = (slug) => window.getAppDisplayName ? window.getAppDisplayName(slug) : (slug.charAt(0).toUpperCase() + slug.slice(1)); + const displayName = (slug) => { + const cap = (x) => x.charAt(0).toUpperCase() + x.slice(1); + if (!window.getAppDisplayName) return cap(slug); + // getAppDisplayName never says "unknown" — it capitalises as its own + // fallback — so telling a real app from a leftover slug needs the same + // membership test it uses internally. The case this exists for: a + // REMOVED instance's tasks outlive it, and its slug (bookstack_uitest) + // is no longer in window.apps, so it rendered as a tech identifier. + // When the prefix before the underscore is still a real app, render the + // way live instances are shown: "Bookstack · uitest". + const apps = window.apps || []; + const known = (x) => apps.some(a => (a.command || '').endsWith(` ${x}`) || a.name?.toLowerCase() === x.toLowerCase()); + if (known(slug)) return window.getAppDisplayName(slug); + const us = slug.indexOf('_'); + if (us > 0 && known(slug.slice(0, us))) { + return `${window.getAppDisplayName(slug.slice(0, us))} · ${slug.slice(us + 1)}`; + } + return window.getAppDisplayName(slug); + }; + const PATTERNS = [ // -- System / setup ---------------------------------------------------- @@ -29,6 +48,10 @@ Object.assign(TasksManager.prototype, { { match: /^libreportal updater apply-all\b/, title: 'Apps - Update All' }, { match: /^libreportal updater apply (\S+)/, title: (m) => `${displayName(m[1])} - Update` }, { match: /^libreportal updater rollback (\S+)/, title: (m) => `${displayName(m[1])} - Roll Back` }, + // Stepped version upgrade — built in task-actions.js rather than + // task-commands.js, which is how it escaped lp-task-names' guard and + // shipped title-less: the row showed the raw command. + { match: /^libreportal updater upgrade (\S+)(?:\s+(\S+))?/, title: (m) => `${displayName(m[1])} - Upgrade${m[2] ? ` to ${m[2]}` : ''}` }, { match: /^libreportal artifact apply (\S+)/, title: (m) => `Hotfix ${m[1]} - Apply` }, { match: /^libreportal artifact revert (\S+)/, title: (m) => `Hotfix ${m[1]} - Revert` }, @@ -125,6 +148,7 @@ Object.assign(TasksManager.prototype, { const action = libreportalMatch[1]; const appName = libreportalMatch[2]; const actionMap = { + 'add': 'Add Application', 'install': 'Install Application', 'uninstall': 'Uninstall Application', 'restart': 'Restart Application', diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js b/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js index f3a53d0..e8cabb1 100644 --- a/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js +++ b/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js @@ -280,7 +280,16 @@ Object.assign(TasksManager.prototype, { const isSystemSentinel = task.app === 'system' || task.app === 'updater'; if (task.app && !isSystemSentinel) { const appIconPath = this.getAppIconPath(task); - return `${typeIcon}${task.app}`; + // Fallback chain instead of vanishing. A removed instance's tasks outlive + // its icon file (bookstack_uitest.svg is deleted with the instance), and + // display:none left a bare gap in the row. Try the TYPE's icon first — + // an instance slug is _, and the type's icon survives — then + // the LibrePortal logo, which every install ships. + const baseSlug = String(task.app).includes('_') ? String(task.app).split('_')[0] : ''; + const fb1 = baseSlug ? `/core/icons/apps/${baseSlug}.svg` : '/core/icons/libreportal.svg'; + return `${typeIcon}${task.app}`; } if (isSystemSentinel || this.isLibrePortalSystemTask(task)) { return `${typeIcon}LibrePortal`; diff --git a/scripts/dev/lp-task-names b/scripts/dev/lp-task-names index d445a5a..63c3e3d 100755 --- a/scripts/dev/lp-task-names +++ b/scripts/dev/lp-task-names @@ -41,6 +41,7 @@ import sys ROOT = os.path.realpath(os.path.join(os.path.dirname(__file__), "..", "..")) FRONTEND = os.path.join(ROOT, "containers", "libreportal", "frontend") COMMANDS = os.path.join(FRONTEND, "core", "tasks", "js", "task-commands.js") +ACTIONS = os.path.join(FRONTEND, "core", "tasks", "js", "task-actions.js") FORMAT = os.path.join(FRONTEND, "components", "tasks", "js", "tasks-format.js") # Stand-ins for the {placeholders} in a command template. Values only have to be @@ -54,6 +55,25 @@ SAMPLES = { "backupId": "backup_20260101", "toolId": "list_users", "args": "", + # task-actions.js interpolations (see actions_templates below) + "app": "bookstack", + "slug": "bookstack", + "serviceName": "bookstack-db", + "v": "1.2.3", + "cmd": "", + "id": "hotfix-1", + "flags.join(' ')": "", + "backupFile": "backup_20260101.tar.gz", + "changes": "CFG_X=1", + "flag": "", + "list": "bookstack,navidrome", + "list.join(',')": "bookstack,navidrome", + "deleteRemote1": "false", + "deleteRemote2": "false", + "action": "install", + "toolName": "list_users", + "version": "1.2.3", + "config.replace(/'/g, \"'\\\\''\")": "", } @@ -104,6 +124,37 @@ def command_templates(): return out +def actions_templates(): + """Every backtick `libreportal …` template built in task-actions.js. + + This is the file the original guard did not read, and precisely where the + gap it exists to catch actually happened: `libreportal updater upgrade + ` is assembled here, never appears in task-commands.js, and + shipped with no title row — every upgrade task rendered as its raw command. + A guard that only reads one of the two dispatch sites certifies half the + surface and reports it as all of it. + + JS ${expr} interpolations become {expr} placeholders and go through the + same SAMPLES fill as the {name} ones in task-commands.js. A template using + an expression SAMPLES does not know is a die(), not a skip — an unknown + placeholder is a command the guard cannot vouch for. + """ + src = open(ACTIONS, encoding="utf-8").read() + # Strip comment lines first: prose mentions commands in backticks too + # ("// index via `libreportal app add` as a task"), and reading those as + # dispatch sites reports fictional commands as unnamed. + src = "\n".join(l for l in src.splitlines() if not l.strip().startswith("//")) + out = [] + for m in re.finditer(r"`(libreportal [^`]*)`", src): + tmpl = re.sub(r"\$\{([^}]+)\}", r"{\1}", m.group(1)) + # conditional-suffix templates (`… ${v ? … : …}`) collapse to their bare + # form: the guard cares that the COMMAND SHAPE has a title row, and the + # regex rows match on prefix. + tmpl = re.sub(r"\{[^}]*\?[^}]*\}", "", tmpl) + out.append(("task-actions.js", tmpl.strip())) + return out + + def fill(template): def sub(m): key = m.group(1) @@ -122,6 +173,15 @@ def main(): cases = [(key, fill(tpl)) for key, tpl in command_templates()] if not cases: die("no command templates found in task-commands.js — has its shape changed?") + # Second dispatch site — the one the guard originally missed (see + # actions_templates). Dedupe by filled command so shapes both files build + # are not reported twice. + seen = {c for _, c in cases} + for key, tpl in actions_templates(): + filled = fill(tpl) + if filled not in seen: + seen.add(filled) + cases.append((key, filled)) harness = os.path.join("/tmp", f"lp-task-names-{os.getpid()}.js") with open(harness, "w", encoding="utf-8") as fh: