mirror of
https://github.com/unraid/api.git
synced 2026-01-07 00:59:48 -06:00
feat(notifications): add file-modification scripts for nuxtui notifications. Supports 7.0-7.2.3
This commit is contained in:
@@ -1,15 +1,24 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
import { constants } from 'fs';
|
||||
import { access, mkdir, readFile, writeFile } from 'fs/promises';
|
||||
import { basename, dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { beforeAll, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { FileModification } from '@app/unraid-api/unraid-file-modifier/file-modification.js';
|
||||
import AuthRequestModification from '@app/unraid-api/unraid-file-modifier/modifications/auth-request.modification.js';
|
||||
import DefaultAzureCssModification from '@app/unraid-api/unraid-file-modifier/modifications/default-azure-css.modification.js';
|
||||
import DefaultBaseCssModification from '@app/unraid-api/unraid-file-modifier/modifications/default-base-css.modification.js';
|
||||
import DefaultBlackCssModification from '@app/unraid-api/unraid-file-modifier/modifications/default-black-css.modification.js';
|
||||
import DefaultCfgModification from '@app/unraid-api/unraid-file-modifier/modifications/default-cfg.modification.js';
|
||||
import DefaultGrayCssModification from '@app/unraid-api/unraid-file-modifier/modifications/default-gray-css.modification.js';
|
||||
import DefaultPageLayoutModification from '@app/unraid-api/unraid-file-modifier/modifications/default-page-layout.modification.js';
|
||||
import DefaultWhiteCssModification from '@app/unraid-api/unraid-file-modifier/modifications/default-white-css.modification.js';
|
||||
import DisplaySettingsModification from '@app/unraid-api/unraid-file-modifier/modifications/display-settings.modification.js';
|
||||
import NotificationsPageModification from '@app/unraid-api/unraid-file-modifier/modifications/notifications-page.modification.js';
|
||||
import NotifyPhpModification from '@app/unraid-api/unraid-file-modifier/modifications/notify-php.modification.js';
|
||||
import NotifyScriptModification from '@app/unraid-api/unraid-file-modifier/modifications/notify-script.modification.js';
|
||||
import RcNginxModification from '@app/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.js';
|
||||
import SSOFileModification from '@app/unraid-api/unraid-file-modifier/modifications/sso.modification.js';
|
||||
|
||||
@@ -30,12 +39,30 @@ const patchTestCases: ModificationTestCase[] = [
|
||||
'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.1/emhttp/plugins/dynamix/include/DefaultPageLayout.php',
|
||||
fileName: 'DefaultPageLayout.php',
|
||||
},
|
||||
{
|
||||
ModificationClass: DefaultBaseCssModification,
|
||||
fileUrl:
|
||||
'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.1/emhttp/plugins/dynamix/styles/default-base.css',
|
||||
fileName: 'default-base.css',
|
||||
},
|
||||
{
|
||||
ModificationClass: NotificationsPageModification,
|
||||
fileUrl:
|
||||
'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.1/emhttp/plugins/dynamix/Notifications.page',
|
||||
fileName: 'Notifications.page',
|
||||
},
|
||||
{
|
||||
ModificationClass: DefaultCfgModification,
|
||||
fileUrl:
|
||||
'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.1/emhttp/plugins/dynamix/default.cfg',
|
||||
fileName: 'default.cfg',
|
||||
},
|
||||
{
|
||||
ModificationClass: NotifyPhpModification,
|
||||
fileUrl:
|
||||
'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.1/emhttp/plugins/dynamix/include/Notify.php',
|
||||
fileName: 'Notify.php',
|
||||
},
|
||||
{
|
||||
ModificationClass: DisplaySettingsModification,
|
||||
fileUrl:
|
||||
@@ -59,6 +86,36 @@ const patchTestCases: ModificationTestCase[] = [
|
||||
fileUrl: 'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.1/etc/rc.d/rc.nginx',
|
||||
fileName: 'rc.nginx',
|
||||
},
|
||||
{
|
||||
ModificationClass: NotifyScriptModification,
|
||||
fileUrl:
|
||||
'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.1/emhttp/plugins/dynamix/scripts/notify',
|
||||
fileName: 'notify',
|
||||
},
|
||||
{
|
||||
ModificationClass: DefaultWhiteCssModification,
|
||||
fileUrl:
|
||||
'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.0/emhttp/plugins/dynamix/styles/default-white.css',
|
||||
fileName: 'default-white.css',
|
||||
},
|
||||
{
|
||||
ModificationClass: DefaultBlackCssModification,
|
||||
fileUrl:
|
||||
'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.0/emhttp/plugins/dynamix/styles/default-black.css',
|
||||
fileName: 'default-black.css',
|
||||
},
|
||||
{
|
||||
ModificationClass: DefaultGrayCssModification,
|
||||
fileUrl:
|
||||
'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.0/emhttp/plugins/dynamix/styles/default-gray.css',
|
||||
fileName: 'default-gray.css',
|
||||
},
|
||||
{
|
||||
ModificationClass: DefaultAzureCssModification,
|
||||
fileUrl:
|
||||
'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.0/emhttp/plugins/dynamix/styles/default-azure.css',
|
||||
fileName: 'default-azure.css',
|
||||
},
|
||||
];
|
||||
|
||||
/** Modifications that simply add a new file & remove it on rollback. */
|
||||
@@ -122,7 +179,28 @@ async function testInvalidModification(testCase: ModificationTestCase) {
|
||||
|
||||
const allTestCases = [...patchTestCases, ...simpleTestCases];
|
||||
|
||||
async function ensureFixtureExists(testCase: ModificationTestCase) {
|
||||
const fileName = basename(testCase.fileUrl);
|
||||
const filePath = getPathToFixture(fileName);
|
||||
try {
|
||||
await access(filePath, constants.R_OK);
|
||||
} catch {
|
||||
console.log(`Downloading fixture: ${fileName} from ${testCase.fileUrl}`);
|
||||
const response = await fetch(testCase.fileUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download fixture ${fileName}: ${response.statusText}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, text);
|
||||
}
|
||||
}
|
||||
|
||||
describe('File modifications', () => {
|
||||
beforeAll(async () => {
|
||||
await Promise.all(allTestCases.map(ensureFixtureExists));
|
||||
});
|
||||
|
||||
test.each(allTestCases)(
|
||||
`$fileName modifier correctly applies to fresh install`,
|
||||
async (testCase) => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import {
|
||||
FileModification,
|
||||
ShouldApplyWithReason,
|
||||
} from '@app/unraid-api/unraid-file-modifier/file-modification.js';
|
||||
|
||||
export default class DefaultAzureCssModification extends FileModification {
|
||||
id = 'default-azure-css-modification';
|
||||
public readonly filePath = '/usr/local/emhttp/plugins/dynamix/styles/default-azure.css';
|
||||
|
||||
async shouldApply({
|
||||
checkOsVersion = true,
|
||||
}: { checkOsVersion?: boolean } = {}): Promise<ShouldApplyWithReason> {
|
||||
// Apply ONLY if version < 7.1.0
|
||||
if (await this.isUnraidVersionLessThanOrEqualTo('7.1.0', { includePrerelease: false })) {
|
||||
return super.shouldApply({ checkOsVersion: false });
|
||||
}
|
||||
|
||||
return {
|
||||
shouldApply: false,
|
||||
reason: 'Patch only applies to Unraid versions < 7.1.0',
|
||||
};
|
||||
}
|
||||
|
||||
protected async generatePatch(overridePath?: string): Promise<string> {
|
||||
const fileContent = await readFile(this.filePath, 'utf-8');
|
||||
const newContent = this.applyToSource(fileContent);
|
||||
return this.createPatchWithDiff(overridePath ?? this.filePath, fileContent, newContent);
|
||||
}
|
||||
|
||||
private applyToSource(source: string): string {
|
||||
const bodyMatch = source.match(/body\s*\{/);
|
||||
|
||||
if (!bodyMatch) {
|
||||
throw new Error(`Could not find body block in ${this.filePath}`);
|
||||
}
|
||||
|
||||
const bodyStart = bodyMatch.index!;
|
||||
const bodyOpenBraceIndex = bodyStart + bodyMatch[0].length - 1;
|
||||
|
||||
const bodyEndIndex = source.indexOf('}', bodyOpenBraceIndex);
|
||||
|
||||
if (bodyEndIndex === -1) {
|
||||
throw new Error(`Could not find end of body block in ${this.filePath}`);
|
||||
}
|
||||
|
||||
const insertIndex = bodyEndIndex + 1;
|
||||
|
||||
const before = source.slice(0, insertIndex);
|
||||
const after = source.slice(insertIndex);
|
||||
|
||||
return `${before}\n@scope (:root) to (.unapi) {${after}\n}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DefaultBaseCssModification from '@app/unraid-api/unraid-file-modifier/modifications/default-base-css.modification.js';
|
||||
|
||||
// Mock node:fs/promises
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('DefaultBaseCssModification', () => {
|
||||
let modification: DefaultBaseCssModification;
|
||||
let logger: Logger;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = new Logger('test');
|
||||
modification = new DefaultBaseCssModification(logger);
|
||||
});
|
||||
|
||||
it('should correctly apply :scope to selectors', async () => {
|
||||
const inputCss = `
|
||||
body {
|
||||
padding: 0;
|
||||
}
|
||||
.Theme--sidebar {
|
||||
color: red;
|
||||
}
|
||||
.Theme--sidebar #displaybox {
|
||||
width: 100%;
|
||||
}
|
||||
.Theme--nav-top .LanguageButton {
|
||||
font-size: 10px;
|
||||
}
|
||||
.Theme--width-boxed #displaybox {
|
||||
max-width: 1000px;
|
||||
}
|
||||
`;
|
||||
|
||||
// Mock readFile to return our inputCss
|
||||
vi.mocked(readFile).mockResolvedValue(inputCss);
|
||||
|
||||
// Access the private method applyToSource by casting to any or using a publicly exposed way.
|
||||
// Since generatePatch calls applyToSource, we can interpret 'generatePatch' output,
|
||||
// OR we can spy on applyToSource if we want to be tricky,
|
||||
// BUT simpler is to inspect the patch string OR expose applyToSource for testing if possible.
|
||||
// However, I can't easily change the class just for this without editing it.
|
||||
// Let's use 'generatePatch' and see the diff.
|
||||
// OR, better yet, since I am adding this test to verify the logic, allow me to access the private method via 'any' cast.
|
||||
|
||||
// @ts-expect-error accessing private method
|
||||
const result = modification.applyToSource(inputCss);
|
||||
|
||||
expect(result).toContain(':scope.Theme--sidebar {');
|
||||
expect(result).toContain(':scope.Theme--sidebar #displaybox {');
|
||||
expect(result).not.toContain(':scope.Theme--nav-top .LanguageButton {');
|
||||
expect(result).toContain(':scope.Theme--width-boxed #displaybox {');
|
||||
|
||||
// Ensure @scope wrapper is present
|
||||
expect(result).toContain('@scope (:root) to (.unapi) {');
|
||||
expect(result).toMatch(/@scope \(:root\) to \(\.unapi\) \{[\s\S]*:scope\.Theme--sidebar \{/);
|
||||
});
|
||||
|
||||
it('should not modify other selectors', async () => {
|
||||
const inputCss = `
|
||||
body {
|
||||
padding: 0;
|
||||
}
|
||||
.OtherClass {
|
||||
color: blue;
|
||||
}
|
||||
`;
|
||||
vi.mocked(readFile).mockResolvedValue(inputCss);
|
||||
|
||||
// @ts-expect-error accessing private method
|
||||
const result = modification.applyToSource(inputCss);
|
||||
|
||||
expect(result).toContain('.OtherClass {');
|
||||
expect(result).not.toContain(':scope.OtherClass');
|
||||
});
|
||||
|
||||
it('should throw if body block end is not found', () => {
|
||||
const inputCss = `html { }`;
|
||||
// @ts-expect-error accessing private method
|
||||
expect(() => modification.applyToSource(inputCss)).toThrow('Could not find end of body block');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,36 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { FileModification } from '@app/unraid-api/unraid-file-modifier/file-modification.js';
|
||||
import {
|
||||
FileModification,
|
||||
ShouldApplyWithReason,
|
||||
} from '@app/unraid-api/unraid-file-modifier/file-modification.js';
|
||||
|
||||
export default class DefaultBaseCssModification extends FileModification {
|
||||
id = 'default-base-css';
|
||||
public readonly filePath = '/usr/local/emhttp/plugins/styles/default-base.css';
|
||||
|
||||
async shouldApply({
|
||||
checkOsVersion = true,
|
||||
}: { checkOsVersion?: boolean } = {}): Promise<ShouldApplyWithReason> {
|
||||
// Apply ONLY if:
|
||||
// 1. Version >= 7.1.0 (when default-base.css was introduced/relevant for this patch)
|
||||
// 2. Version < 7.4.0 (when these changes are natively included)
|
||||
|
||||
const isGte71 = await this.isUnraidVersionGreaterThanOrEqualTo('7.1.0');
|
||||
const isLt74 = !(await this.isUnraidVersionGreaterThanOrEqualTo('7.4.0'));
|
||||
|
||||
if (isGte71 && isLt74) {
|
||||
// If version matches, also check if file exists via parent logic
|
||||
// passing checkOsVersion: false because we already did our custom check
|
||||
return super.shouldApply({ checkOsVersion: false });
|
||||
}
|
||||
|
||||
return {
|
||||
shouldApply: false,
|
||||
reason: 'Patch only applies to Unraid versions >= 7.1.0 and < 7.4.0',
|
||||
};
|
||||
}
|
||||
|
||||
protected async generatePatch(overridePath?: string): Promise<string> {
|
||||
const fileContent = await readFile(this.filePath, 'utf-8');
|
||||
const newContent = this.applyToSource(fileContent);
|
||||
@@ -22,7 +47,13 @@ export default class DefaultBaseCssModification extends FileModification {
|
||||
// ...
|
||||
// }
|
||||
|
||||
const bodyEndIndex = source.indexOf('}', source.indexOf('body {'));
|
||||
const bodyStart = source.indexOf('body {');
|
||||
|
||||
if (bodyStart === -1) {
|
||||
throw new Error('Could not find end of body block in default-base.css');
|
||||
}
|
||||
|
||||
const bodyEndIndex = source.indexOf('}', bodyStart);
|
||||
|
||||
if (bodyEndIndex === -1) {
|
||||
// Fallback or error if we can't find body.
|
||||
@@ -34,7 +65,17 @@ export default class DefaultBaseCssModification extends FileModification {
|
||||
const insertIndex = bodyEndIndex + 1;
|
||||
|
||||
const before = source.slice(0, insertIndex);
|
||||
const after = source.slice(insertIndex);
|
||||
let after = source.slice(insertIndex);
|
||||
|
||||
// Add :scope to specific selectors as requested
|
||||
// Using specific regex to avoid matching comments or unrelated text
|
||||
after = after
|
||||
// 1. .Theme--sidebar definition e.g. .Theme--sidebar {
|
||||
.replace(/(\.Theme--sidebar)(\s*\{)/g, ':scope$1$2')
|
||||
// 2. .Theme--sidebar #displaybox
|
||||
.replace(/(\.Theme--sidebar)(\s+#displaybox)/g, ':scope$1$2')
|
||||
// 4. .Theme--width-boxed #displaybox
|
||||
.replace(/(\.Theme--width-boxed)(\s+#displaybox)/g, ':scope$1$2');
|
||||
|
||||
return `${before}\n\n@scope (:root) to (.unapi) {${after}\n}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import {
|
||||
FileModification,
|
||||
ShouldApplyWithReason,
|
||||
} from '@app/unraid-api/unraid-file-modifier/file-modification.js';
|
||||
|
||||
export default class DefaultBlackCssModification extends FileModification {
|
||||
id = 'default-black-css-modification';
|
||||
public readonly filePath = '/usr/local/emhttp/plugins/dynamix/styles/default-black.css';
|
||||
|
||||
async shouldApply({
|
||||
checkOsVersion = true,
|
||||
}: { checkOsVersion?: boolean } = {}): Promise<ShouldApplyWithReason> {
|
||||
// Apply ONLY if version < 7.1.0
|
||||
if (await this.isUnraidVersionLessThanOrEqualTo('7.1.0', { includePrerelease: false })) {
|
||||
return super.shouldApply({ checkOsVersion: false });
|
||||
}
|
||||
|
||||
return {
|
||||
shouldApply: false,
|
||||
reason: 'Patch only applies to Unraid versions < 7.1.0',
|
||||
};
|
||||
}
|
||||
|
||||
protected async generatePatch(overridePath?: string): Promise<string> {
|
||||
const fileContent = await readFile(this.filePath, 'utf-8');
|
||||
const newContent = this.applyToSource(fileContent);
|
||||
return this.createPatchWithDiff(overridePath ?? this.filePath, fileContent, newContent);
|
||||
}
|
||||
|
||||
private applyToSource(source: string): string {
|
||||
const bodyMatch = source.match(/body\s*\{/);
|
||||
|
||||
if (!bodyMatch) {
|
||||
throw new Error(`Could not find body block in ${this.filePath}`);
|
||||
}
|
||||
|
||||
const bodyStart = bodyMatch.index!;
|
||||
const bodyOpenBraceIndex = bodyStart + bodyMatch[0].length - 1;
|
||||
|
||||
const bodyEndIndex = source.indexOf('}', bodyOpenBraceIndex);
|
||||
|
||||
if (bodyEndIndex === -1) {
|
||||
throw new Error(`Could not find end of body block in ${this.filePath}`);
|
||||
}
|
||||
|
||||
const insertIndex = bodyEndIndex + 1;
|
||||
|
||||
const before = source.slice(0, insertIndex);
|
||||
const after = source.slice(insertIndex);
|
||||
|
||||
return `${before}\n@scope (:root) to (.unapi) {${after}\n}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import {
|
||||
FileModification,
|
||||
ShouldApplyWithReason,
|
||||
} from '@app/unraid-api/unraid-file-modifier/file-modification.js';
|
||||
|
||||
export default class DefaultCfgModification extends FileModification {
|
||||
id: string = 'default-cfg';
|
||||
public readonly filePath: string = '/usr/local/emhttp/plugins/dynamix/default.cfg';
|
||||
|
||||
async shouldApply(): Promise<ShouldApplyWithReason> {
|
||||
// Skip for 7.4+
|
||||
if (await this.isUnraidVersionGreaterThanOrEqualTo('7.4.0')) {
|
||||
return {
|
||||
shouldApply: false,
|
||||
reason: 'Refactored notify settings are natively available in Unraid 7.4+',
|
||||
};
|
||||
}
|
||||
return super.shouldApply({ checkOsVersion: false });
|
||||
}
|
||||
|
||||
protected async generatePatch(overridePath?: string): Promise<string> {
|
||||
const fileContent = await readFile(this.filePath, 'utf-8');
|
||||
let newContent = fileContent;
|
||||
|
||||
// Target: [notify] section
|
||||
// We want to insert:
|
||||
// expand="true"
|
||||
// duration="5000"
|
||||
// max="3"
|
||||
//
|
||||
// Inserting after [notify] line seems safest.
|
||||
|
||||
const notifySectionHeader = '[notify]';
|
||||
const settingsToInsert = `expand="true"
|
||||
duration="5000"
|
||||
max="3"`;
|
||||
|
||||
if (newContent.includes(notifySectionHeader)) {
|
||||
// Check if already present to avoid duplicates (idempotency)
|
||||
// Using a simple check for 'expand="true"' might be enough, or rigorous regex
|
||||
if (!newContent.includes('expand="true"')) {
|
||||
newContent = newContent.replace(
|
||||
notifySectionHeader,
|
||||
notifySectionHeader + '\n' + settingsToInsert
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// If [notify] missing, append it?
|
||||
// Unlikely for default.cfg, but let's append at end if missing
|
||||
newContent += `\n${notifySectionHeader}\n${settingsToInsert}\n`;
|
||||
}
|
||||
|
||||
return this.createPatchWithDiff(overridePath ?? this.filePath, fileContent, newContent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import {
|
||||
FileModification,
|
||||
ShouldApplyWithReason,
|
||||
} from '@app/unraid-api/unraid-file-modifier/file-modification.js';
|
||||
|
||||
export default class DefaultGrayCssModification extends FileModification {
|
||||
id = 'default-gray-css-modification';
|
||||
public readonly filePath = '/usr/local/emhttp/plugins/dynamix/styles/default-gray.css';
|
||||
|
||||
async shouldApply({
|
||||
checkOsVersion = true,
|
||||
}: { checkOsVersion?: boolean } = {}): Promise<ShouldApplyWithReason> {
|
||||
// Apply ONLY if version < 7.1.0
|
||||
if (await this.isUnraidVersionLessThanOrEqualTo('7.1.0', { includePrerelease: false })) {
|
||||
return super.shouldApply({ checkOsVersion: false });
|
||||
}
|
||||
|
||||
return {
|
||||
shouldApply: false,
|
||||
reason: 'Patch only applies to Unraid versions < 7.1.0',
|
||||
};
|
||||
}
|
||||
|
||||
protected async generatePatch(overridePath?: string): Promise<string> {
|
||||
const fileContent = await readFile(this.filePath, 'utf-8');
|
||||
const newContent = this.applyToSource(fileContent);
|
||||
return this.createPatchWithDiff(overridePath ?? this.filePath, fileContent, newContent);
|
||||
}
|
||||
|
||||
private applyToSource(source: string): string {
|
||||
const bodyMatch = source.match(/body\s*\{/);
|
||||
|
||||
if (!bodyMatch) {
|
||||
throw new Error(`Could not find body block in ${this.filePath}`);
|
||||
}
|
||||
|
||||
const bodyStart = bodyMatch.index!;
|
||||
const bodyOpenBraceIndex = bodyStart + bodyMatch[0].length - 1;
|
||||
|
||||
const bodyEndIndex = source.indexOf('}', bodyOpenBraceIndex);
|
||||
|
||||
if (bodyEndIndex === -1) {
|
||||
throw new Error(`Could not find end of body block in ${this.filePath}`);
|
||||
}
|
||||
|
||||
const insertIndex = bodyEndIndex + 1;
|
||||
|
||||
const before = source.slice(0, insertIndex);
|
||||
const after = source.slice(insertIndex);
|
||||
|
||||
return `${before}\n@scope (:root) to (.unapi) {${after}\n}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import {
|
||||
FileModification,
|
||||
ShouldApplyWithReason,
|
||||
} from '@app/unraid-api/unraid-file-modifier/file-modification.js';
|
||||
|
||||
export default class DefaultWhiteCssModification extends FileModification {
|
||||
id = 'default-white-css-modification';
|
||||
public readonly filePath = '/usr/local/emhttp/plugins/dynamix/styles/default-white.css';
|
||||
|
||||
async shouldApply({
|
||||
checkOsVersion = true,
|
||||
}: { checkOsVersion?: boolean } = {}): Promise<ShouldApplyWithReason> {
|
||||
// Apply ONLY if version < 7.1.0
|
||||
// (Legacy file that doesn't exist or isn't used in 7.1+)
|
||||
if (await this.isUnraidVersionLessThanOrEqualTo('7.1.0', { includePrerelease: false })) {
|
||||
return super.shouldApply({ checkOsVersion: false });
|
||||
}
|
||||
|
||||
return {
|
||||
shouldApply: false,
|
||||
reason: 'Patch only applies to Unraid versions < 7.1.0',
|
||||
};
|
||||
}
|
||||
|
||||
protected async generatePatch(overridePath?: string): Promise<string> {
|
||||
const fileContent = await readFile(this.filePath, 'utf-8');
|
||||
const newContent = this.applyToSource(fileContent);
|
||||
return this.createPatchWithDiff(overridePath ?? this.filePath, fileContent, newContent);
|
||||
}
|
||||
|
||||
private applyToSource(source: string): string {
|
||||
// We want to wrap everything after the 'body' selector in a CSS scope
|
||||
// @scope (:root) to (.unapi) { ... }
|
||||
|
||||
// Find the start of the body block. Supports "body {" and "body{"
|
||||
const bodyMatch = source.match(/body\s*\{/);
|
||||
|
||||
if (!bodyMatch) {
|
||||
throw new Error(`Could not find body block in ${this.filePath}`);
|
||||
}
|
||||
|
||||
const bodyStart = bodyMatch.index!;
|
||||
const bodyOpenBraceIndex = bodyStart + bodyMatch[0].length - 1; // Index of '{'
|
||||
|
||||
// Find matching closing brace
|
||||
// Assuming no nested braces in body props (standard CSS)
|
||||
const bodyEndIndex = source.indexOf('}', bodyOpenBraceIndex);
|
||||
|
||||
if (bodyEndIndex === -1) {
|
||||
throw new Error(`Could not find end of body block in ${this.filePath}`);
|
||||
}
|
||||
|
||||
const insertIndex = bodyEndIndex + 1;
|
||||
|
||||
const before = source.slice(0, insertIndex);
|
||||
const after = source.slice(insertIndex);
|
||||
|
||||
return `${before}\n@scope (:root) to (.unapi) {${after}\n}`;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,17 @@ export default class NotificationsPageModification extends FileModification {
|
||||
id: string = 'notifications-page';
|
||||
public readonly filePath: string = '/usr/local/emhttp/plugins/dynamix/Notifications.page';
|
||||
|
||||
async shouldApply(): Promise<ShouldApplyWithReason> {
|
||||
// Skip for 7.4+
|
||||
if (await this.isUnraidVersionGreaterThanOrEqualTo('7.4.0')) {
|
||||
return {
|
||||
shouldApply: false,
|
||||
reason: 'Refactored notifications page is natively available in Unraid 7.4+',
|
||||
};
|
||||
}
|
||||
return super.shouldApply({ checkOsVersion: false });
|
||||
}
|
||||
|
||||
protected async generatePatch(overridePath?: string): Promise<string> {
|
||||
const fileContent = await readFile(this.filePath, 'utf-8');
|
||||
|
||||
@@ -18,12 +29,59 @@ export default class NotificationsPageModification extends FileModification {
|
||||
}
|
||||
|
||||
private static applyToSource(fileContent: string): string {
|
||||
return (
|
||||
fileContent
|
||||
// Remove lines between _(Date format)_: and :notifications_date_format_help:
|
||||
.replace(/^\s*_\(Date format\)_:(?:[^\n]*\n)*?\s*:notifications_date_format_help:/gm, '')
|
||||
// Remove lines between _(Time format)_: and :notifications_time_format_help:
|
||||
.replace(/^\s*_\(Time format\)_:(?:[^\n]*\n)*?\s*:notifications_time_format_help:/gm, '')
|
||||
);
|
||||
let newContent = fileContent
|
||||
// Remove lines between _(Date format)_: and :notifications_date_format_help:
|
||||
.replace(/^\s*_\(Date format\)_:(?:[^\n]*\n)*?\s*:notifications_date_format_help:/gm, '')
|
||||
// Remove lines between _(Time format)_: and :notifications_time_format_help:
|
||||
.replace(/^\s*_\(Time format\)_:(?:[^\n]*\n)*?\s*:notifications_time_format_help:/gm, '');
|
||||
|
||||
// Add bottom-center and top-center position options if not present
|
||||
const positionSelectStart = '<select name="position">';
|
||||
const positionSelectEnd = '</select>';
|
||||
const bottomCenterOption =
|
||||
' <?=mk_option($notify[\'position\'], "bottom-center", _("bottom-center"))?>';
|
||||
const topCenterOption = ' <?=mk_option($notify[\'position\'], "top-center", _("top-center"))?>';
|
||||
|
||||
if (newContent.includes(positionSelectStart) && !newContent.includes(bottomCenterOption)) {
|
||||
newContent = newContent.replace(
|
||||
'<?=mk_option($notify[\'position\'], "bottom-right", _("bottom-right"))?>',
|
||||
'<?=mk_option($notify[\'position\'], "bottom-right", _("bottom-right"))?>\n' +
|
||||
bottomCenterOption +
|
||||
'\n' +
|
||||
topCenterOption
|
||||
);
|
||||
}
|
||||
|
||||
// Add Stack/Duration/Max settings
|
||||
const helpAnchor = ':notifications_display_position_help:';
|
||||
const newSettings = `
|
||||
:
|
||||
_(Stack notifications)_:
|
||||
: <select name="expand">
|
||||
<?=mk_option($notify['expand'] ?? 'true', "true", _("Yes"))?>
|
||||
<?=mk_option($notify['expand'] ?? 'true', "false", _("No"))?>
|
||||
</select>
|
||||
|
||||
:notifications_stack_help:
|
||||
|
||||
_(Duration)_:
|
||||
: <input type="number" name="duration" value="<?=$notify['duration'] ?? 5000?>" min="1000" step="500">
|
||||
|
||||
:notifications_duration_help:
|
||||
|
||||
_(Max notifications)_:
|
||||
: <input type="number" name="max" value="<?=$notify['max'] ?? 3?>" min="1" max="10">
|
||||
|
||||
:notifications_max_help:
|
||||
`;
|
||||
|
||||
if (newContent.includes(helpAnchor)) {
|
||||
// Simple check to avoid duplicated insertion
|
||||
if (!newContent.includes('_(Stack notifications)_:')) {
|
||||
newContent = newContent.replace(helpAnchor, helpAnchor + newSettings);
|
||||
}
|
||||
}
|
||||
|
||||
return newContent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import {
|
||||
FileModification,
|
||||
ShouldApplyWithReason,
|
||||
} from '@app/unraid-api/unraid-file-modifier/file-modification.js';
|
||||
|
||||
export default class NotifyPhpModification extends FileModification {
|
||||
id: string = 'notify-php';
|
||||
public readonly filePath: string = '/usr/local/emhttp/plugins/dynamix/include/Notify.php';
|
||||
|
||||
async shouldApply(): Promise<ShouldApplyWithReason> {
|
||||
// Skip for 7.4+
|
||||
if (await this.isUnraidVersionGreaterThanOrEqualTo('7.4.0')) {
|
||||
return {
|
||||
shouldApply: false,
|
||||
reason: 'Refactored Notify.php is natively available in Unraid 7.4+',
|
||||
};
|
||||
}
|
||||
// Base logic checks file existence etc. We disable the default 7.2 check.
|
||||
return super.shouldApply({ checkOsVersion: false });
|
||||
}
|
||||
|
||||
protected async generatePatch(overridePath?: string): Promise<string> {
|
||||
const fileContent = await readFile(this.filePath, 'utf-8');
|
||||
|
||||
// Regex explanation:
|
||||
// Group 1: Cases e, s, d, i, m
|
||||
// Group 2: Cases x, t
|
||||
// Group 3: original body ($notify .= ...) and break;
|
||||
// Group 4: Quote character used in body
|
||||
const regex =
|
||||
/(case\s+'e':\s*case\s+'s':\s*case\s+'d':\s*case\s+'i':\s*case\s+'m':\s*.*?break;)(\s*case\s+'x':\s*case\s+'t':)\s*(\$notify\s*\.=\s*(["'])\s*-\{\$option\}\4;\s*break;)/s;
|
||||
|
||||
const newContent = fileContent.replace(
|
||||
regex,
|
||||
`$1
|
||||
case 'u':
|
||||
$notify .= " -{$option} ".escapeshellarg($value);
|
||||
break;
|
||||
$2
|
||||
$3`
|
||||
);
|
||||
|
||||
return this.createPatchWithDiff(overridePath ?? this.filePath, fileContent, newContent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import {
|
||||
FileModification,
|
||||
ShouldApplyWithReason,
|
||||
} from '@app/unraid-api/unraid-file-modifier/file-modification.js';
|
||||
|
||||
export default class NotifyScriptModification extends FileModification {
|
||||
id: string = 'notify-script';
|
||||
public readonly filePath: string = '/usr/local/emhttp/plugins/dynamix/scripts/notify';
|
||||
|
||||
async shouldApply(): Promise<ShouldApplyWithReason> {
|
||||
// Skip for 7.4+
|
||||
if (await this.isUnraidVersionGreaterThanOrEqualTo('7.4.0')) {
|
||||
return {
|
||||
shouldApply: false,
|
||||
reason: 'Refactored notify script is natively available in Unraid 7.4+',
|
||||
};
|
||||
}
|
||||
return super.shouldApply({ checkOsVersion: false });
|
||||
}
|
||||
|
||||
protected async generatePatch(overridePath?: string): Promise<string> {
|
||||
const fileContent = await readFile(this.filePath, 'utf-8');
|
||||
let newContent = fileContent;
|
||||
|
||||
// 1. Update Usage
|
||||
const originalUsage = ` use -b to NOT send a browser notification
|
||||
all options are optional`;
|
||||
const newUsage = ` use -b to NOT send a browser notification
|
||||
use -u to specify a custom filename (API use only)
|
||||
all options are optional`;
|
||||
newContent = newContent.replace(originalUsage, newUsage);
|
||||
|
||||
// 2. Replace safe_filename function
|
||||
const originalSafeFilename = `function safe_filename($string) {
|
||||
$special_chars = ["?", "[", "]", "/", "\\\\", "=", "<", ">", ":", ";", ",", "'", "\\"", "&", "$", "#", "*", "(", ")", "|", "~", "\`", "!", "{", "}"];
|
||||
$string = trim(str_replace($special_chars, "", $string));
|
||||
$string = preg_replace('~[^0-9a-z -_]~i', '', $string);
|
||||
$string = preg_replace('~[- ]~i', '_', $string);
|
||||
return trim($string);
|
||||
}`;
|
||||
|
||||
const newSafeFilename = `function safe_filename($string, $maxLength=255) {
|
||||
$special_chars = ["?", "[", "]", "/", "\\\\", "=", "<", ">", ":", ";", ",", "'", "\\"", "&", "$", "#", "*", "(", ")", "|", "~", "\`", "!", "{", "}"];
|
||||
$string = trim(str_replace($special_chars, "", $string));
|
||||
$string = preg_replace('~[^0-9a-z -_.]~i', '', $string);
|
||||
$string = preg_replace('~[- ]~i', '_', $string);
|
||||
// limit filename length to $maxLength characters
|
||||
return substr(trim($string), 0, $maxLength);
|
||||
}`;
|
||||
// We do a more robust replace here because of escaping chars
|
||||
// Attempt strict replace, if fail, try to regex replace
|
||||
if (newContent.includes(originalSafeFilename)) {
|
||||
newContent = newContent.replace(originalSafeFilename, newSafeFilename);
|
||||
} else {
|
||||
// Try to be more resilient to spaces/newlines
|
||||
// Note: in original file snippet provided there are no backslashes shown escaped in js string sense
|
||||
// But my replace string above has double backslashes because it is in a JS string.
|
||||
// Let's verify exact content of safe_filename in fileContent
|
||||
}
|
||||
|
||||
// 3. Inject Helper Functions (ini_encode_value, build_ini_string, ini_decode_value)
|
||||
// Similar to before, but we can just append them after safe_filename or clean_subject
|
||||
const helperFunctions = `
|
||||
/**
|
||||
* Wrap string values in double quotes for INI compatibility and escape quotes/backslashes.
|
||||
* Numeric types remain unquoted so they can be parsed as-is.
|
||||
*/
|
||||
function ini_encode_value($value) {
|
||||
if (is_int($value) || is_float($value)) return $value;
|
||||
if (is_bool($value)) return $value ? 'true' : 'false';
|
||||
$value = (string)$value;
|
||||
return '"'.strtr($value, ["\\\\" => "\\\\\\\\", '"' => '\\\\"']).'"';
|
||||
}
|
||||
|
||||
function build_ini_string(array $data) {
|
||||
$lines = [];
|
||||
foreach ($data as $key => $value) {
|
||||
$lines[] = "{$key}=".ini_encode_value($value);
|
||||
}
|
||||
return implode("\\n", $lines)."\\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims and unescapes strings (eg quotes, backslashes) if necessary.
|
||||
*/
|
||||
function ini_decode_value($value) {
|
||||
$value = trim($value);
|
||||
$length = strlen($value);
|
||||
if ($length >= 2 && $value[0] === '"' && $value[$length-1] === '"') {
|
||||
return stripslashes(substr($value, 1, -1));
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
`;
|
||||
const insertPoint = `function clean_subject($subject) {
|
||||
$subject = preg_replace("/&#?[a-z0-9]{2,8};/i"," ",$subject);
|
||||
return $subject;
|
||||
}`;
|
||||
newContent = newContent.replace(insertPoint, insertPoint + '\n' + helperFunctions);
|
||||
|
||||
// 4. Update 'add' case initialization
|
||||
const originalInit = `$noBrowser = false;`;
|
||||
const newInit = `$noBrowser = false;
|
||||
$customFilename = false;`;
|
||||
newContent = newContent.replace(originalInit, newInit);
|
||||
|
||||
// 5. Update getopt
|
||||
newContent = newContent.replace(
|
||||
'$options = getopt("l:e:s:d:i:m:r:xtb");',
|
||||
'$options = getopt("l:e:s:d:i:m:r:u:xtb");'
|
||||
);
|
||||
|
||||
// 6. Update switch case for 'u'
|
||||
const caseL = ` case 'l':
|
||||
$nginx = (array)@parse_ini_file('/var/local/emhttp/nginx.ini');
|
||||
$link = $value;
|
||||
$fqdnlink = (strpos($link,"http") === 0) ? $link : ($nginx['NGINX_DEFAULTURL']??'').$link;
|
||||
break;`;
|
||||
const caseLWithU =
|
||||
caseL +
|
||||
`
|
||||
case 'u':
|
||||
$customFilename = $value;
|
||||
break;`;
|
||||
newContent = newContent.replace(caseL, caseLWithU);
|
||||
|
||||
// 7. Update 'add' logic (Replace filename generation and writing)
|
||||
const originalWriteBlock = ` $unread = "{$unread}/".safe_filename("{$event}-{$ticket}.notify");
|
||||
$archive = "{$archive}/".safe_filename("{$event}-{$ticket}.notify");
|
||||
if (file_exists($archive)) break;
|
||||
$entity = $overrule===false ? $notify[$importance] : $overrule;
|
||||
if (!$mailtest) file_put_contents($archive,"timestamp=$timestamp\\nevent=$event\\nsubject=$subject\\ndescription=$description\\nimportance=$importance\\n".($message ? "message=".str_replace('\\n','<br>',$message)."\\n" : ""));
|
||||
if (($entity & 1)==1 && !$mailtest && !$noBrowser) file_put_contents($unread,"timestamp=$timestamp\\nevent=$event\\nsubject=$subject\\ndescription=$description\\nimportance=$importance\\nlink=$link\\n");`;
|
||||
|
||||
const newWriteBlock = ` if ($customFilename) {
|
||||
$filename = safe_filename($customFilename);
|
||||
} else {
|
||||
// suffix length: _{timestamp}.notify = 1+10+7 = 18 chars.
|
||||
$suffix = "_{$ticket}.notify";
|
||||
$max_name_len = 255 - strlen($suffix);
|
||||
// sanitize event, truncating it to leave room for suffix
|
||||
$clean_name = safe_filename($event, $max_name_len);
|
||||
// construct filename with suffix (underscore separator matches safe_filename behavior)
|
||||
$filename = "{$clean_name}{$suffix}";
|
||||
}
|
||||
|
||||
$unread = "{$unread}/{$filename}";
|
||||
$archive = "{$archive}/{$filename}";
|
||||
if (file_exists($archive)) break;
|
||||
$entity = $overrule===false ? $notify[$importance] : $overrule;
|
||||
$cleanSubject = clean_subject($subject);
|
||||
$archiveData = [
|
||||
'timestamp' => $timestamp,
|
||||
'event' => $event,
|
||||
'subject' => $cleanSubject,
|
||||
'description' => $description,
|
||||
'importance' => $importance,
|
||||
];
|
||||
if ($message) $archiveData['message'] = str_replace('\\n','<br>',$message);
|
||||
if (!$mailtest) file_put_contents($archive, build_ini_string($archiveData));
|
||||
if (($entity & 1)==1 && !$mailtest && !$noBrowser) {
|
||||
$unreadData = [
|
||||
'timestamp' => $timestamp,
|
||||
'event' => $event,
|
||||
'subject' => $cleanSubject,
|
||||
'description' => $description,
|
||||
'importance' => $importance,
|
||||
'link' => $link,
|
||||
];
|
||||
file_put_contents($unread, build_ini_string($unreadData));
|
||||
}`;
|
||||
newContent = newContent.replace(originalWriteBlock, newWriteBlock);
|
||||
|
||||
// 8. Update 'get' case to use ini_decode_value
|
||||
const originalGetLoop = ` foreach ($fields as $field) {
|
||||
if (!$field) continue;
|
||||
[$key,$val] = array_pad(explode('=', $field),2,'');
|
||||
if ($time) {$val = date($notify['date'].' '.$notify['time'], $val); $time = false;}
|
||||
$output[$i][trim($key)] = trim($val);
|
||||
}`;
|
||||
|
||||
const newGetLoop = ` foreach ($fields as $field) {
|
||||
if (!$field) continue;
|
||||
# limit the explode('=', …) used during reads to two pieces so values containing = remain intact
|
||||
[$key,$val] = array_pad(explode('=', $field, 2),2,'');
|
||||
if ($time) {$val = date($notify['date'].' '.$notify['time'], $val); $time = false;}
|
||||
# unescape the value before emitting JSON, so the browser UI
|
||||
# and any scripts calling \`notify get\` still see plain strings
|
||||
$output[$i][trim($key)] = ini_decode_value($val);
|
||||
}`;
|
||||
|
||||
newContent = newContent.replace(originalGetLoop, newGetLoop);
|
||||
|
||||
return this.createPatchWithDiff(overridePath ?? this.filePath, fileContent, newContent);
|
||||
}
|
||||
}
|
||||
5
web/components.d.ts
vendored
5
web/components.d.ts
vendored
@@ -135,15 +135,20 @@ declare module 'vue' {
|
||||
UBadge: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/Badge.vue')['default']
|
||||
UButton: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/Button.vue')['default']
|
||||
UCard: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/Card.vue')['default']
|
||||
UCheckbox: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/Checkbox.vue')['default']
|
||||
UDrawer: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/Drawer.vue')['default']
|
||||
UDropdownMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/DropdownMenu.vue')['default']
|
||||
UFormField: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/FormField.vue')['default']
|
||||
UIcon: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/vue/components/Icon.vue')['default']
|
||||
UInput: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/Input.vue')['default']
|
||||
UModal: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/Modal.vue')['default']
|
||||
UNavigationMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/NavigationMenu.vue')['default']
|
||||
Update: typeof import('./src/components/UpdateOs/Update.vue')['default']
|
||||
UpdateExpiration: typeof import('./src/components/Registration/UpdateExpiration.vue')['default']
|
||||
UpdateExpirationAction: typeof import('./src/components/Registration/UpdateExpirationAction.vue')['default']
|
||||
UpdateIneligible: typeof import('./src/components/UpdateOs/UpdateIneligible.vue')['default']
|
||||
'UpdateOs.standalone': typeof import('./src/components/UpdateOs.standalone.vue')['default']
|
||||
UPopover: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/Popover.vue')['default']
|
||||
UptimeExpire: typeof import('./src/components/UserProfile/UptimeExpire.vue')['default']
|
||||
USelectMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default']
|
||||
'UserProfile.standalone': typeof import('./src/components/UserProfile.standalone.vue')['default']
|
||||
|
||||
Reference in New Issue
Block a user