From ec5b9fd7020f0e3cc8678e674c4226958b074081 Mon Sep 17 00:00:00 2001 From: Bluefox Date: Tue, 5 Nov 2024 21:05:53 +0100 Subject: [PATCH] Remove double file browser (#2793) Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- package-lock.json | 123 +- .../src/Components/FileBrowser.tsx | 16 +- .../src/Components/FileViewer.tsx | 86 +- .../src/Components/ObjectBrowser.tsx | 59 +- packages/adapter-react-v5/src/index.ts | 8 +- packages/admin/package.json | 1 + .../components/Enums/DragObjectBrowser.tsx | 11 +- .../src-admin/src/components/FileBrowser.tsx | 2564 ----- .../src-admin/src/components/FileEditor.tsx | 78 + .../src-admin/src/components/FileViewer.tsx | 400 - .../src/components/ObjectBrowser.tsx | 8950 ----------------- .../src/dialogs/FileEditOfAccessControl.tsx | 6 +- packages/admin/src-admin/src/tabs/Files.tsx | 4 +- packages/admin/src-admin/src/tabs/Objects.tsx | 9 +- packages/admin/src/lib/testPassword.ts | 1 + packages/admin/src/lib/web.ts | 450 +- packages/admin/src/main.ts | 4 +- 17 files changed, 481 insertions(+), 12289 deletions(-) delete mode 100644 packages/admin/src-admin/src/components/FileBrowser.tsx create mode 100644 packages/admin/src-admin/src/components/FileEditor.tsx delete mode 100644 packages/admin/src-admin/src/components/FileViewer.tsx delete mode 100644 packages/admin/src-admin/src/components/ObjectBrowser.tsx diff --git a/package-lock.json b/package-lock.json index b6135b1a4..706d83282 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5950,6 +5950,92 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/@mui/x-date-pickers": { + "version": "7.22.1", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.22.1.tgz", + "integrity": "sha512-VBgicE+7PvJrdHSL6HyieHT6a/0dENH8RaMIM2VwUFrGoZzvik50WNwY5U+Hip1BwZLIEvlqtNRQIIj6kgBR6Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0", + "@mui/x-internals": "7.21.0", + "@types/react-transition-group": "^4.4.11", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0", + "@mui/system": "^5.15.14 || ^6.0.0", + "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, + "node_modules/@mui/x-internals": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.21.0.tgz", + "integrity": "sha512-94YNyZ0BhK5Z+Tkr90RKf47IVCW8R/1MvdUhh6MCQg6sZa74jsX+x+gEZ4kzuCqOsuyTyxikeQ8vVuCIQiP7UQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.4.tgz", @@ -23641,7 +23727,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "type": "github", @@ -35338,7 +35424,7 @@ "version": "3.5.0", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -36351,18 +36437,6 @@ "license": "MIT", "peer": true }, - "node_modules/mime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", - "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/mime-db": { "version": "1.53.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", @@ -52037,6 +52111,7 @@ "@types/cookie-parser": "^1.4.7", "@types/express-fileupload": "^1.5.1", "@types/express-session": "^1.18.0", + "@types/mime": "3.0.4", "@types/passport": "^1.0.16", "@types/passport-local": "^1.0.38", "@types/validator": "^13.12.2", @@ -52124,6 +52199,13 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "packages/admin/node_modules/@types/mime": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.4.tgz", + "integrity": "sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw==", + "dev": true, + "license": "MIT" + }, "packages/admin/node_modules/@typescript-eslint/eslint-plugin": { "version": "5.62.0", "dev": true, @@ -52794,6 +52876,18 @@ "dev": true, "license": "MIT" }, + "packages/admin/node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, "packages/admin/node_modules/pathval": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", @@ -52958,6 +53052,7 @@ "version": "7.2.6", "dependencies": { "@iobroker/adapter-react-v5": "file:../adapter-react-v5", + "@mui/x-date-pickers": "^7.22.0", "crypto-js": "^4.2.0", "react-ace": "^12.0.0", "react-qr-code": "^2.0.15" diff --git a/packages/adapter-react-v5/src/Components/FileBrowser.tsx b/packages/adapter-react-v5/src/Components/FileBrowser.tsx index cde2d42e2..715d279fe 100644 --- a/packages/adapter-react-v5/src/Components/FileBrowser.tsx +++ b/packages/adapter-react-v5/src/Components/FileBrowser.tsx @@ -73,7 +73,7 @@ import { Icon } from './Icon'; import { withWidth } from './withWidth'; import type { ThemeName, ThemeType, Translate, IobTheme } from '../types'; -import { FileViewer, EXTENSIONS } from './FileViewer'; +import { FileViewer, EXTENSIONS, type FileViewerProps } from './FileViewer'; const ROW_HEIGHT = 32; const BUTTON_WIDTH = 32; @@ -455,7 +455,7 @@ export interface FileBrowserProps { lang: ioBroker.Languages; /** The socket connection. */ socket: Connection; - /** Is the component data ready. */ + /** Shows if the component data ready. */ ready?: boolean; /** Is expert mode enabled? (default: false) */ expertMode?: boolean; @@ -506,6 +506,8 @@ export interface FileBrowserProps { allowNonRestricted?: boolean; showTypeSelector?: boolean; + + FileViewer?: React.FC; } export interface FolderOrFileItem { @@ -856,7 +858,7 @@ export class FileBrowserClass extends Component { if (this.browseList) { // if component still mounted @@ -2294,7 +2296,7 @@ export class FileBrowserClass extends Component - {this.props.t('ra_Confirm deletion of %s', this.state.deleteItem.split('/').pop() as string)} + {this.props.t('ra_Confirm deletion of %s', this.state.deleteItem.split('/').pop())} {this.props.t('ra_Are you sure?')} @@ -2333,8 +2335,10 @@ export class FileBrowserClass extends Component this.setState({ viewer: '', formatEditFile: '' })} /> ) : null; diff --git a/packages/adapter-react-v5/src/Components/FileViewer.tsx b/packages/adapter-react-v5/src/Components/FileViewer.tsx index fd5dbc360..8c666eed1 100644 --- a/packages/adapter-react-v5/src/Components/FileViewer.tsx +++ b/packages/adapter-react-v5/src/Components/FileViewer.tsx @@ -17,14 +17,8 @@ import type { Connection } from '@iobroker/socket-client'; import { IconNoIcon } from '../icons/IconNoIcon'; import { withWidth } from './withWidth'; import { Utils } from './Utils'; -import type { Translate } from '../types'; +import type { ThemeType, Translate } from '../types'; import { Icon } from './Icon'; -// File viewer in adapter-react does not use ace editor -// import * as ace from 'ace-builds'; -// import 'ace-builds/src-noconflict/ext-modelist'; -// import Editor from './Editor'; - -// const modelist = ace.require('ace/ext/modelist'); const styles: Record = { dialog: { @@ -69,23 +63,23 @@ function bufferToBase64(buffer: Buffer, isFull?: boolean): string { return window.btoa(binary); } -interface FileViewerProps { +export interface FileViewerProps { /** Translation function */ t: Translate; /** Callback when the viewer is closed. */ onClose: () => void; /** The URL (file path) to the file to be displayed. */ href: string; - // formatEditFile?: string; + formatEditFile?: string; socket: Connection; setStateBackgroundImage: () => void; - // themeType: ThemeType; + themeType: ThemeType; getStyleBackgroundImage: () => React.CSSProperties | null; /** Flag is the js-controller support subscribe on file */ supportSubscribes?: boolean; } -interface FileViewerState { +export interface FileViewerState { text: string | null; code: string | null; ext: string | null; @@ -224,37 +218,21 @@ export class FileViewerClass extends Component } }; - // eslint-disable-next-line class-methods-use-this - writeFile64 = (): void => { - /* - // File viewer in adapter-react does not support write - const parts = this.props.href.split('/'); - const data = this.state.editingValue; - parts.splice(0, 2); - const adapter = parts[0]; - const name = parts.splice(1).join('/'); - this.props.socket.writeFile64(adapter, name, Buffer.from(data).toString('base64')) - .then(() => this.props.onClose()) - .catch(e => window.alert(`Cannot write file: ${e}`)); - */ - }; - - static getEditFile(ext: string | null): 'json' | 'json5' | 'javascript' | 'html' | 'text' { - switch (ext) { - case 'json': - return 'json'; - case 'json5': - return 'json5'; - case 'js': - return 'javascript'; - case 'html': - return 'html'; - case 'txt': - return 'html'; - default: - // e.g. ace/mode/text - return 'text'; - } + getEditorOrViewer(): JSX.Element { + return ( + this.setState({ editingValue: newValue, changed: true })} + slotProps={{ + htmlInput: { + readOnly: !this.state.editing, + }, + }} + /> + ); } getContent(): React.JSX.Element | null { @@ -319,29 +297,21 @@ export class FileViewerClass extends Component if (this.state.code !== null || this.state.text !== null || this.state.editing) { // File viewer in adapter-react does not support write // return this.setState({ editingValue: newValue, changed: true }) : undefined} // />; - return ( - this.setState({ editingValue: newValue, changed: true })} - slotProps={{ - htmlInput: { - readOnly: !this.state.editing, - }, - }} - /> - ); + return this.getEditorOrViewer(); } return null; } + // eslint-disable-next-line class-methods-use-this + onSave(): void { + // Do nothing as the file viewer in adapter-react does not support writing + } + render(): JSX.Element { return ( this.state.editingValue === this.state.text } variant="contained" - onClick={this.writeFile64} + onClick={() => this.onSave()} startIcon={} > {this.props.t('Save')} diff --git a/packages/adapter-react-v5/src/Components/ObjectBrowser.tsx b/packages/adapter-react-v5/src/Components/ObjectBrowser.tsx index b54c91e29..a228e3190 100644 --- a/packages/adapter-react-v5/src/Components/ObjectBrowser.tsx +++ b/packages/adapter-react-v5/src/Components/ObjectBrowser.tsx @@ -1374,7 +1374,7 @@ function applyFilter( } if (!filteredOut && filters.role && common) { if (common) { - filteredOut = !(common.role && common.role.startsWith(context.role as string)); + filteredOut = !(common.role && common.role.startsWith(context.role)); } else { filteredOut = true; } @@ -1703,7 +1703,7 @@ function buildTree( cRoot = _cRoot; info.ids.push(curPath); // IDs will be added by alphabet } else if (cRoot.children) { - cRoot = cRoot.children.find(item => item.data.name === parts[k]) as TreeItem; + cRoot = cRoot.children.find(item => item.data.name === parts[k]); } } } @@ -1774,8 +1774,8 @@ function buildTree( } info.roomEnums.sort((a, b) => { - const aName: string = getName(objects[a]?.common?.name, options.lang) || (a.split('.').pop() as string); - const bName: string = getName(objects[b]?.common?.name, options.lang) || (b.split('.').pop() as string); + const aName: string = getName(objects[a]?.common?.name, options.lang) || a.split('.').pop(); + const bName: string = getName(objects[b]?.common?.name, options.lang) || b.split('.').pop(); if (aName > bName) { return 1; } @@ -1785,8 +1785,8 @@ function buildTree( return 0; }); info.funcEnums.sort((a, b) => { - const aName: string = getName(objects[a]?.common?.name, options.lang) || (a.split('.').pop() as string); - const bName: string = getName(objects[b]?.common?.name, options.lang) || (b.split('.').pop() as string); + const aName: string = getName(objects[a]?.common?.name, options.lang) || a.split('.').pop(); + const bName: string = getName(objects[b]?.common?.name, options.lang) || b.split('.').pop(); if (aName > bName) { return 1; } @@ -1820,7 +1820,7 @@ function findNode(root: TreeItem, id: string, _parts?: string[], _path?: string, if (_id === _path) { found = root.children[i]; break; - } else if (_id > (_path as string)) { + } else if (_id > _path) { break; } } @@ -3068,7 +3068,7 @@ export class ObjectBrowserClass extends Component { if (obj) { - this.info.objects[this.state.roleDialog as string] = obj; + this.info.objects[this.state.roleDialog] = obj; } this.setState({ roleDialog: null }); }} @@ -6043,14 +6045,7 @@ export class ObjectBrowserClass extends Component { - if ( - obj && - ObjectBrowserClass.setCustomValue( - obj, - this.state.columnsEditCustomDialog?.it as AdapterColumn, - value, - ) - ) { + if (obj && ObjectBrowserClass.setCustomValue(obj, this.state.columnsEditCustomDialog?.it, value)) { return this.props.socket.setObject(obj._id, obj); } throw new Error(this.props.t('ra_Cannot update attribute, because not found in the object')); @@ -6825,7 +6820,7 @@ export class ObjectBrowserClass extends Component this.onCopy(e, item.data?.title as string)} + onClick={e => this.onCopy(e, item.data?.title)} /> ) : null} @@ -7235,7 +7230,7 @@ export class ObjectBrowserClass extends Component this.onCopy(e, item.data?.title as string)} + onClick={e => this.onCopy(e, item.data?.title)} /> ) : null} @@ -7433,7 +7428,7 @@ export class ObjectBrowserClass extends Component - * - * MIT License - * - */ -import React, { Component, type JSX } from 'react'; -import Dropzone from 'react-dropzone'; - -import { - LinearProgress, - ListItemIcon, - ListItemText, - Menu, - MenuItem, - Tooltip, - CircularProgress, - Toolbar, - IconButton, - Fab, - Dialog, - DialogTitle, - DialogContent, - DialogContentText, - DialogActions, - Button, - Input, - Breadcrumbs, - Box, -} from '@mui/material'; - -// MUI Icons -import { - Refresh as RefreshIcon, - Close as CloseIcon, - Bookmark as JsonIcon, - BookmarkBorder as CssIcon, - Description as HtmlIcon, - Edit as EditIcon, - Code as JSIcon, - InsertDriveFile as FileIcon, - Publish as UploadIcon, - MusicNote as MusicIcon, - SaveAlt as DownloadIcon, - CreateNewFolder as AddFolderIcon, - FolderOpen as EmptyFilterIcon, - List as IconList, - ViewModule as IconTile, - ArrowBack as IconBack, - Delete as DeleteIcon, - Brightness6 as Brightness5Icon, - Image as TypeIconImages, - FontDownload as TypeIconTxt, - AudioFile as TypeIconAudio, - Videocam as TypeIconVideo, - KeyboardReturn as EnterIcon, - FolderSpecial as RestrictedIcon, -} from '@mui/icons-material'; - -import { - DialogError, - DialogTextInput, - IconExpert, - IconClosed, - IconOpen, - IconNoIcon, - withWidth, - Icon, - Utils, - type Connection, - type ThemeName, - type ThemeType, - type Translate, - type IobTheme, -} from '@iobroker/adapter-react-v5'; - -import { FileViewer, EXTENSIONS } from './FileViewer'; - -const ROW_HEIGHT = 32; -const BUTTON_WIDTH = 32; -const TILE_HEIGHT = 120; -const TILE_WIDTH = 64; - -const NOT_FOUND = 'Not found'; - -// Todo: replace with js-controller types -export interface MetaACL extends ioBroker.ObjectACL { - file: number; -} - -// Todo: replace with js-controller types -export interface MetaObject extends ioBroker.MetaObject { - acl: MetaACL; -} - -const FILE_TYPE_ICONS: Record> = { - all: FileIcon, - images: TypeIconImages, - code: JSIcon, - txt: TypeIconTxt, - audio: TypeIconAudio, - video: TypeIconVideo, -}; - -const styles: Record = { - root: { - width: '100%', - overflow: 'hidden', - height: '100%', - position: 'relative', - }, - filesDiv: { - width: 'calc(100% - 16px)', - overflowX: 'hidden', - overflowY: 'auto', - padding: 8, - }, - filesDivHint: { - position: 'absolute', - bottom: 0, - left: 20, - opacity: 0.7, - fontStyle: 'italic', - fontSize: 12, - }, - filesDivTable: { - height: 'calc(100% - 56px)', - }, - filesDivTile: { - height: `calc(100% - ${48 * 2 + 8}px)`, - display: 'flex', - alignContent: 'flex-start', - alignItems: 'stretch', - flexWrap: 'wrap', - flex: `0 0 ${TILE_WIDTH}px`, - }, - - itemTile: (theme: IobTheme) => ({ - position: 'relative', - userSelect: 'none', - cursor: 'pointer', - height: TILE_HEIGHT, - width: TILE_WIDTH, - display: 'inline-block', - textAlign: 'center', - opacity: 0.1, - transition: 'opacity 1s', - margin: '4px', - borderRadius: '4px', - '&:hover': { - background: theme.palette.secondary.light, - color: Utils.invertColor(theme.palette.secondary.main, true), - }, - }), - itemNameFolderTile: { - fontWeight: 'bold', - }, - itemNameTile: { - width: '100%', - height: 32, - overflow: 'hidden', - textOverflow: 'ellipsis', - fontSize: 12, - textAlign: 'center', - wordBreak: 'break-all', - }, - itemFolderIconTile: (theme: IobTheme) => ({ - width: '100%', - height: TILE_HEIGHT - 32 - 16 - 8, // name + size - display: 'block', - pl: 1, - color: theme.palette.secondary.main || '#fbff7d', - }), - itemFolderIconBack: (theme: IobTheme) => ({ - position: 'absolute', - top: 22, - left: 18, - zIndex: 1, - color: theme.palette.mode === 'dark' ? '#FFF' : '#000', - }), - itemSizeTile: { - width: '100%', - height: 16, - textAlign: 'center', - fontSize: 10, - }, - itemImageTile: { - width: 'calc(100% - 8px)', - height: TILE_HEIGHT - 32 - 16 - 8, // name + size - margin: 4, - display: 'block', - textAlign: 'center', - objectFit: 'contain', - }, - itemIconTile: { - width: '100%', - height: TILE_HEIGHT - 32 - 16 - 8, // name + size - display: 'block', - objectFit: 'contain', - }, - - itemSelected: (theme: IobTheme) => ({ - background: theme.palette.primary.main, - color: Utils.invertColor(theme.palette.primary.main, true), - }), - - itemTable: (theme: IobTheme) => ({ - userSelect: 'none', - cursor: 'pointer', - height: ROW_HEIGHT, - display: 'inline-flex', - lineHeight: `${ROW_HEIGHT}px`, - '&:hover': { - background: theme.palette.secondary.light, - color: Utils.invertColor(theme.palette.secondary.main, true), - }, - }), - itemNameTable: { - display: 'inline-block', - pl: '10px', - fontSize: '1rem', - verticalAlign: 'top', - flexGrow: 1, - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - overflow: 'hidden', - '@media screen and (max-width: 500px)': { - textAlign: 'end', - direction: 'rtl', - }, - }, - itemNameFolderTable: { - fontWeight: 'bold', - }, - itemSizeTable: { - display: 'inline-block', - width: 60, - verticalAlign: 'top', - textAlign: 'right', - whiteSpace: 'nowrap', - }, - itemAccessTable: { - // display: 'inline-block', - verticalAlign: 'top', - width: 60, - textAlign: 'right', - paddingRight: 5, - display: 'flex', - justifyContent: 'center', - }, - itemImageTable: { - display: 'inline-block', - width: 30, - marginTop: 1, - objectFit: 'contain', - maxHeight: 30, - }, - itemNoImageTable: { - marginTop: 6, - }, - itemIconTable: { - display: 'inline-block', - marginTop: 1, - width: 30, - height: 30, - }, - itemFolderTable: {}, - itemFolderTemp: { - opacity: 0.4, - }, - itemFolderIconTable: (theme: IobTheme) => ({ - marginTop: '1px', - marginLeft: '8px', - display: 'inline-block', - width: 30, - height: 30, - color: theme.palette.secondary.main || '#fbff7d', - }), - itemDownloadButtonTable: (theme: IobTheme) => ({ - display: 'inline-block', - width: BUTTON_WIDTH, - height: ROW_HEIGHT, - minWidth: BUTTON_WIDTH, - verticalAlign: 'middle', - textAlign: 'center', - padding: 0, - borderRadius: `${BUTTON_WIDTH / 2}px`, - '&:hover': { - backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.08)', - }, - '& span': { - pt: '9px', - }, - '& svg': { - width: 14, - height: 14, - fontSize: '1rem', - mt: '-3px', - verticalAlign: 'middle', - color: theme.palette.mode === 'dark' ? '#EEE' : '#111', - }, - }), - itemDownloadEmptyTable: { - display: 'inline-block', - width: BUTTON_WIDTH, - height: ROW_HEIGHT, - minWidth: BUTTON_WIDTH, - padding: 0, - }, - itemAclButtonTable: { - width: BUTTON_WIDTH, - height: ROW_HEIGHT, - minWidth: BUTTON_WIDTH, - verticalAlign: 'top', - padding: 0, - fontSize: 12, - display: 'flex', - }, - itemDeleteButtonTable: { - display: 'inline-block', - width: BUTTON_WIDTH, - height: ROW_HEIGHT, - minWidth: BUTTON_WIDTH, - verticalAlign: 'top', - padding: 0, - '& svg': { - width: 18, - height: 18, - fontSize: '1.5rem', - }, - }, - - uploadDiv: { - top: 0, - zIndex: 1, - bottom: 0, - left: 0, - right: 0, - position: 'absolute', - opacity: 0.9, - textAlign: 'center', - background: '#FFFFFF', - }, - uploadDivDragging: { - opacity: 1, - }, - - uploadCenterDiv: (theme: IobTheme) => ({ - m: '20px', - border: '3px dashed grey', - borderRadius: '30px', - width: 'calc(100% - 40px)', - height: 'calc(100% - 40px)', - position: 'relative', - color: theme.palette.mode === 'dark' ? '#222' : '#CCC', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }), - uploadCenterIcon: { - width: '25%', - height: '25%', - }, - uploadCenterText: { - fontSize: 24, - fontWeight: 'bold', - }, - uploadCloseButton: { - zIndex: 2, - position: 'absolute', - top: 30, - right: 30, - }, - uploadCenterTextAndIcon: { - position: 'absolute', - height: '30%', - width: '100%', - margin: 'auto', - opacity: 0.3, - }, - menuButtonExpertActive: { - color: '#c00000', - }, - menuButtonRestrictActive: { - color: '#c05000', - }, - pathDiv: (theme: IobTheme) => ({ - display: 'flex', - width: 'calc(100% - 16px)', - ml: 1, - mr: 1, - textOverflow: 'clip', - overflow: 'hidden', - whiteSpace: 'nowrap', - backgroundColor: theme.palette.secondary.main, - }), - pathDivInput: { - width: '100%', - }, - pathDivBreadcrumbDir: (theme: IobTheme) => ({ - pl: '2px', - pr: '2px', - cursor: 'pointer', - '&:hover': { - background: theme.palette.primary.main, - }, - }), - pathDivBreadcrumbSelected: { - // todo: add style - }, - backgroundImageLight: { - background: 'white', - }, - backgroundImageDark: { - background: 'black', - }, - backgroundImageColored: { - background: 'silver', - }, - specialFolder: (theme: IobTheme) => ({ - color: theme.palette.mode === 'dark' ? '#229b0f' : '#5dd300', - }), - tooltip: { - pointerEvents: 'none', - }, -}; - -const USER_DATA = '0_userdata.0'; - -function getParentDir(dir: string | null): string { - const parts = (dir || '').split('/'); - if (parts.length) { - parts.pop(); - } - return parts.join('/'); -} - -function isFile(path: string): boolean { - const ext = Utils.getFileExtension(path); - return !!(ext?.toLowerCase().match(/[a-z]+/) && ext.length < 5); -} - -const TABLE = 'Table'; -const TILE = 'Tile'; - -export interface FileBrowserProps { - /** The key to identify this component. */ - key?: string; - /** Additional styling for this component. */ - style?: React.CSSProperties; - /** The CSS class name. */ - className?: string; - /** Translation function. */ - t: Translate; - /** The selected language. */ - lang: ioBroker.Languages; - /** The socket connection. */ - socket: Connection; - /** Is the component data ready. */ - ready?: boolean; - /** Is expert mode enabled? (default: false) */ - expertMode?: boolean; - /** Show the toolbar? (default: false) */ - showToolbar?: boolean; - /** If defined, allow selecting only files from this folder and subfolders */ - limitPath?: string; - /** Allow upload of new files? (default: false) */ - allowUpload?: boolean; - /** Allow download of files? (default: false) */ - allowDownload?: boolean; - /** Allow creation of new folders? (default: false) */ - allowCreateFolder?: boolean; - /** Allow deleting files? (default: false) */ - allowDelete?: boolean; - /** Allow viewing files? (default: false) */ - allowView?: boolean; - /** Prefix (default: '.') */ - imagePrefix?: string; - /** Show the expert button? */ - showExpertButton?: boolean; - /** Type of view */ - viewType?: 'Table' | 'Tile'; - /** Show the buttons to switch the view from table to tile? (default: false) */ - showViewTypeButton?: boolean; - /** The ID of the selected file. */ - selected?: string | string[]; - /** The file extensions to show, like ['png', 'svg', 'bmp', 'jpg', 'jpeg', 'gif']. */ - filterFiles?: string[]; - /** The file extension categories to show. */ - filterByType?: 'images' | 'code' | 'txt'; - /** Callback for file selection. */ - onSelect?: (id: string | string[], isDoubleClick?: boolean, isFolder?: boolean) => void; - /** Theme name */ - themeName?: ThemeName; - /** Theme type. */ - themeType?: ThemeType; - /** Theme object. */ - theme: IobTheme; - - /** Padding in pixels for folder levels */ - levelPadding?: number; - - restrictToFolder?: string; - - modalEditOfAccessControl?: (obj: FileBrowserClass) => JSX.Element | null; - - allowNonRestricted?: boolean; - - showTypeSelector?: boolean; -} - -export interface FolderOrFileItem { - id: string; - level: number; - name: string; - folder: boolean; - temp?: boolean; - - size?: number | undefined; - ext?: string | null; - modified?: number; - title?: ioBroker.StringOrTranslated; - meta?: boolean; - from?: string; - ts?: number; - color?: string; - icon?: string; - acl?: ioBroker.EvaluatedFileACL | MetaACL; -} - -export type Folders = Record; - -function sortFolders(a: FolderOrFileItem, b: FolderOrFileItem): number { - if (a.folder && b.folder) { - return a.name > b.name ? 1 : a.name < b.name ? -1 : 0; - } - if (a.folder) { - return -1; - } - if (b.folder) { - return 1; - } - return a.name > b.name ? 1 : a.name < b.name ? -1 : 0; -} - -interface FileBrowserState { - viewType: string; - folders: Folders; - filterEmpty: boolean; - expanded: string[]; - currentDir: string; - expertMode: boolean; - addFolder: boolean; - uploadFile: boolean | 'dragging'; - deleteItem: string; - viewer: string; - formatEditFile: string | null; - path: string; - selected: string; - errorText: string; - modalEditOfAccess: boolean; - backgroundImage: string | null; - queueLength: number; - loadAllFolders: boolean; - fileErrors: string[]; - filterByType: string; - showTypesMenu: HTMLButtonElement | null; - restrictToFolder: string; - pathFocus: boolean; -} - -export class FileBrowserClass extends Component { - private readonly imagePrefix: string; - - private readonly levelPadding: number; - - private mounted: boolean; - - private suppressDeleteConfirm: number; - - private browseList: - | { - processing?: boolean; - resolve: null | ((files: ioBroker.ReadDirResult[]) => void); - reject: null | ((e: any) => void); - adapter: string | null; - relPath: string | null; - }[] - | null; - - private browseListRunning: boolean; - - private initialReadFinished: boolean; - - private supportSubscribes: boolean | null; - - private _tempTimeout: Record>; - - private readonly limitToObjectID: string | null = null; - - private readonly limitToPath: string | null = null; - - private lastSelect: number | null = null; - - private setOpacityTimer: ReturnType | null = null; - - private cacheFoldersTimeout: ReturnType | null = null; - - private foldersLoading: boolean | null = null; - - private cacheFolders: Folders | null = null; - - private readonly localStorage: Storage; - - constructor(props: FileBrowserProps) { - super(props); - - this.localStorage = (window as any)._localStorage || window.localStorage; - const expandedStr = this.localStorage.getItem('files.expanded') || '[]'; - - if (this.props.limitPath) { - const parts = this.props.limitPath.split('/'); - this.limitToObjectID = parts[0]; - this.limitToPath = !parts.length ? null : parts.length === 1 && parts[0] === '' ? null : parts.join('/'); - if (this.limitToPath && this.limitToPath.endsWith('/')) { - this.limitToPath.substring(0, this.limitToPath.length - 1); - } - } - - let expanded: string[]; - try { - expanded = JSON.parse(expandedStr); - if (this.limitToPath) { - expanded = expanded.filter( - id => - id.startsWith(`${this.limitToPath}/`) || - id === this.limitToPath || - this.limitToPath?.startsWith(`${id}/`), - ); - } - } catch { - expanded = []; - } - - let viewType; - if (this.props.showViewTypeButton) { - viewType = this.localStorage.getItem('files.viewType') || TABLE; - } else { - viewType = TABLE; - } - - let selected = this.props.selected || this.localStorage.getItem('files.selected') || USER_DATA; - - let currentDir: string; - - if (props.restrictToFolder) { - selected = props.restrictToFolder; - currentDir = props.restrictToFolder; - const parts = props.restrictToFolder.split('/'); - expanded = []; - let path = ''; - for (let i = 0; i < parts.length; i++) { - path += (path ? '/' : '') + parts[i]; - expanded.push(path); - } - } else { - // TODO: Now we do not support multiple selection - if (Array.isArray(selected)) { - selected = selected[0]; - } - - if (isFile(selected)) { - currentDir = getParentDir(selected); - } else { - currentDir = selected; - } - } - const backgroundImage = this.localStorage.getItem('files.backgroundImage') || null; - - this.state = { - viewType, - folders: {}, - filterEmpty: this.localStorage.getItem('files.empty') !== 'false', - expanded, - currentDir, - expertMode: !!props.expertMode, - addFolder: false, - uploadFile: false, - deleteItem: '', - // marked: [], - viewer: '', - formatEditFile: '', - path: selected, - selected, - errorText: '', - modalEditOfAccess: false, - backgroundImage, - queueLength: 0, - loadAllFolders: false, - // allFoldersLoaded: false, - fileErrors: [], - filterByType: props.filterByType || window.localStorage.getItem('files.filterByType') || '', - showTypesMenu: null, - restrictToFolder: props.restrictToFolder || '', - pathFocus: false, - }; - - this.imagePrefix = this.props.imagePrefix || './files/'; - - this.levelPadding = this.props.levelPadding || 20; - this.mounted = true; - this.suppressDeleteConfirm = 0; - - this.browseList = []; - this.browseListRunning = false; - this.initialReadFinished = false; - this.supportSubscribes = null; - this._tempTimeout = {}; - } - - static getDerivedStateFromProps( - props: FileBrowserProps, - state: FileBrowserState, - ): Partial | null { - if (props.expertMode !== undefined && props.expertMode !== state.expertMode) { - return { expertMode: props.expertMode, loadAllFolders: true }; - } - - return null; - } - - async loadFolders(): Promise { - this.initialReadFinished = false; - - let folders = (await this.browseFolder('/')) as unknown as Folders; - - if (this.state.viewType === TABLE) { - folders = (await this.browseFolders([...this.state.expanded], folders)) as unknown as Folders; - } else if ( - this.state.currentDir && - this.state.currentDir !== '/' && - (!this.limitToObjectID || this.state.currentDir.startsWith(this.limitToObjectID)) - ) { - folders = (await this.browseFolder(this.state.currentDir, folders)) as unknown as Folders; - } - - this.setState({ folders }, () => { - if (this.state.viewType === TABLE && !this.findItem(this.state.selected)) { - const parts = this.state.selected.split('/'); - while (parts.length && !this.findItem(parts.join('/'))) { - parts.pop(); - } - let selected; - if (parts.length) { - selected = parts.join('/'); - } else { - selected = USER_DATA; - } - this.setState({ selected, path: selected, pathFocus: false }, () => this.scrollToSelected()); - } else { - this.scrollToSelected(); - } - this.initialReadFinished = true; - }); - } - - scrollToSelected(): void { - if (this.mounted) { - const el = document.getElementById(this.state.selected); - el?.scrollIntoView(); - } - } - - async componentDidMount(): Promise { - this.mounted = true; - this.loadFolders().catch(error => console.error(`Cannot load folders: ${error}`)); - - this.supportSubscribes = await this.props.socket.checkFeatureSupported('BINARY_STATE_EVENT'); - if (this.supportSubscribes) { - await this.props.socket.subscribeFiles('*', '*', this.onFileChange); - } - } - - componentWillUnmount(): void { - if (this.supportSubscribes) { - this.props.socket.unsubscribeFiles('*', '*', this.onFileChange); - } - this.mounted = false; - this.browseList = null; - this.browseListRunning = false; - Object.values(this._tempTimeout).forEach(timer => timer && clearTimeout(timer)); - this._tempTimeout = {}; - } - - browseFoldersCb(foldersList: string[], newFoldersNotNull: Folders, cb: (folders: Folders) => void): void { - if (!foldersList?.length) { - cb(newFoldersNotNull); - } else { - const folder = foldersList.shift(); - if (folder) { - void this.browseFolder(folder, newFoldersNotNull) - .catch((e: Error) => console.error(`Cannot read folder ${folder}: ${e.message}`)) - .then(() => { - setTimeout(() => this.browseFoldersCb(foldersList, newFoldersNotNull, cb), 0); - }); - } else { - setTimeout(() => this.browseFoldersCb(foldersList, newFoldersNotNull, cb), 0); - } - } - } - - browseFolders(foldersList: string[], _newFolders?: Folders | null): Promise { - let newFoldersNotNull: Folders; - if (!_newFolders) { - newFoldersNotNull = {}; - Object.keys(this.state.folders).forEach(folder => (newFoldersNotNull[folder] = this.state.folders[folder])); - } else { - newFoldersNotNull = _newFolders; - } - - if (!foldersList?.length) { - return Promise.resolve(newFoldersNotNull); - } - return new Promise(resolve => { - this.browseFoldersCb(foldersList, newFoldersNotNull, resolve); - }); - } - - readDirSerial(adapter: string, relPath: string): Promise { - return new Promise((resolve, reject) => { - if (this.browseList) { - // if component still mounted - this.browseList.push({ - resolve: resolve as unknown as (files: ioBroker.ReadDirResult[]) => void, - reject, - adapter, - relPath, - }); - if (!this.browseListRunning) { - this.processBrowseList(); - } - } - }); - } - - processBrowseList(level: number = 0): void { - if (!this.browseListRunning && this.browseList && this.browseList.length) { - this.browseListRunning = true; - if (this.browseList.length > 10) { - // not too often - if (!(this.browseList.length % 10)) { - this.setState({ queueLength: this.browseList.length }); - } - } else { - this.setState({ queueLength: this.browseList.length }); - } - - this.browseList[0].processing = true; - this.props.socket - .readDir(this.browseList[0].adapter, this.browseList[0].relPath) - .then(files => { - if (this.browseList) { - // if component still mounted - const item = this.browseList.shift(); - if (item) { - const resolve = item.resolve; - item.resolve = null; - item.reject = null; - item.adapter = null; - item.relPath = null; - if (resolve) { - resolve(files); - } - this.browseListRunning = false; - if (this.browseList.length) { - if (level < 5) { - this.processBrowseList(level + 1); - } else { - setTimeout(() => this.processBrowseList(0), 0); - } - } else { - this.setState({ queueLength: 0 }); - } - } else { - this.setState({ queueLength: 0 }); - } - } - }) - .catch(e => { - if (this.browseList) { - // if component still mounted - const item = this.browseList.shift(); - if (item) { - const reject = item.reject; - item.resolve = null; - item.reject = null; - item.adapter = null; - item.relPath = null; - if (reject) { - reject(e); - } - this.browseListRunning = false; - if (this.browseList.length) { - if (level < 5) { - this.processBrowseList(level + 1); - } else { - setTimeout(() => this.processBrowseList(0), 0); - } - } else { - this.setState({ queueLength: 0 }); - } - } else { - this.setState({ queueLength: 0 }); - } - } - }); - } - } - - async browseFolder( - folderId: string, - _newFolders?: Folders | null, - _checkEmpty?: boolean, - force?: boolean, - ): Promise { - let newFoldersNotNull: Folders; - if (!_newFolders) { - newFoldersNotNull = {}; - Object.keys(this.state.folders).forEach(folder => { - newFoldersNotNull[folder] = this.state.folders[folder]; - }); - } else { - newFoldersNotNull = _newFolders; - } - - if (newFoldersNotNull[folderId] && !force) { - if (!_checkEmpty) { - return new Promise((resolve, reject) => { - Promise.all( - newFoldersNotNull[folderId] - .filter(item => item.folder) - .map(item => this.browseFolder(item.id, newFoldersNotNull, true).catch(() => undefined)), - ) - .then(() => resolve(newFoldersNotNull)) - .catch(error => reject(new Error(error))); - }); - } - - return Promise.resolve(newFoldersNotNull); - } - - // if root folder - if (!folderId || folderId === '/') { - try { - let objs = (await this.props.socket.readMetaItems()) as MetaObject[]; - const _folders: FolderOrFileItem[] = []; - let userData = null; - - if (this.state.restrictToFolder) { - const adapter = this.state.restrictToFolder.split('/')[0]; - objs = objs.filter(obj => obj._id === adapter); - } else if (!this.state.expertMode) { - // load only adapter.admin and not other meta files like hm-rpc.0.devices.blablabla - objs = objs.filter(obj => !obj._id.endsWith('.admin')); - } - - const pos = objs.findIndex(obj => obj._id === 'system.meta.uuid'); - if (pos !== -1) { - objs.splice(pos, 1); - } - - objs.forEach(obj => { - if (this.limitToObjectID && this.limitToObjectID !== obj._id) { - return; - } - - const item: FolderOrFileItem = { - id: obj._id, - name: obj._id, - title: (obj.common && obj.common.name) || obj._id, - meta: true, - from: obj.from, - ts: obj.ts, - color: obj.common && obj.common.color, - icon: obj.common && obj.common.icon, - folder: true, - acl: obj.acl, - level: 0, - }; - - if (item.id === USER_DATA) { - // user data must be first - userData = item; - } else { - _folders.push(item); - } - }); - - _folders.sort((a, b) => (a.id > b.id ? 1 : a.id < b.id ? -1 : 0)); - if (!this.limitToObjectID || this.limitToObjectID === USER_DATA) { - if (userData) { - _folders.unshift(userData); - } - } - - newFoldersNotNull[folderId || '/'] = _folders; - - if (!_checkEmpty) { - return Promise.all( - _folders - .filter(item => item.folder) - .map(item => this.browseFolder(item.id, newFoldersNotNull, true).catch(() => undefined)), - ).then(() => newFoldersNotNull); - } - } catch (e: unknown) { - const knownError = e as Error; - if (this.initialReadFinished) { - window.alert(`Cannot read meta items: ${knownError.message}`); - } - newFoldersNotNull[folderId || '/'] = []; - } - return newFoldersNotNull; - } - - const parts = folderId.split('/'); - const level = parts.length; - const adapter = parts.shift(); - const relPath = parts.join('/'); - - // make all requests here serial - let files: ioBroker.ReadDirResult[]; - try { - files = await this.readDirSerial(adapter || '', relPath); - } catch (error: unknown) { - // work around: 0_userdata.0 is a special folder, that should exist event when other folders and itself do not exit, as the browser shows it anyway. - if (error === 'Not exists' && adapter === '0_userdata.0') { - files = []; - } else { - throw error; - } - } - try { - const _folders: FolderOrFileItem[] = []; - - files.forEach(file => { - const item: FolderOrFileItem = { - id: `${folderId}/${file.file}`, - ext: Utils.getFileExtension(file.file), - folder: file.isDir, - name: file.file, - size: file.stats?.size, - modified: file.modifiedAt, - acl: file.acl, - level, - }; - - if (this.state.restrictToFolder) { - if ( - item.folder && - (item.id.startsWith(`${this.state.restrictToFolder}/`) || - item.id === this.state.restrictToFolder || - this.state.restrictToFolder.startsWith(`${item.id}/`)) - ) { - _folders.push(item); - } else if (item.id.startsWith(`${this.state.restrictToFolder}/`)) { - _folders.push(item); - } - } else if (this.limitToPath) { - if ( - item.folder && - (item.id.startsWith(`${this.limitToPath}/`) || - item.id === this.limitToPath || - this.limitToPath.startsWith(`${item.id}/`)) - ) { - _folders.push(item); - } else if (item.id.startsWith(`${this.limitToPath}/`)) { - _folders.push(item); - } - } else { - _folders.push(item); - } - }); - - _folders.sort(sortFolders); - newFoldersNotNull[folderId] = _folders; - - if (!_checkEmpty) { - return Promise.all( - _folders - .filter(item => item.folder) - .map(item => this.browseFolder(item.id, newFoldersNotNull, true)), - ).then(() => newFoldersNotNull); - } - } catch (e: unknown) { - const knownError = e as Error; - if (this.initialReadFinished) { - window.alert(`Cannot read ${adapter}${relPath ? `/${relPath}` : ''}: ${knownError?.message}`); - } - newFoldersNotNull[folderId] = []; - } - - return newFoldersNotNull; - } - - toggleFolder(item: FolderOrFileItem, e: React.MouseEvent): void { - e?.stopPropagation(); - const expanded = [...this.state.expanded]; - const pos = expanded.indexOf(item.id); - if (pos === -1) { - expanded.push(item.id); - expanded.sort(); - - this.localStorage.setItem('files.expanded', JSON.stringify(expanded)); - - if (!item.temp) { - this.browseFolder(item.id) - .then(folders => this.setState({ expanded, folders })) - .catch(err => - window.alert( - err === NOT_FOUND - ? this.props.t('ra_Cannot find "%s"', item.id) - : this.props.t('ra_Cannot read "%s"', item.id), - ), - ); - } else { - this.setState({ expanded }); - } - } else { - expanded.splice(pos, 1); - this.localStorage.setItem('files.expanded', JSON.stringify(expanded)); - this.setState({ expanded }); - } - } - - onFileChange = (id: string, fileName: string, size: number | null): void => { - const key = `${id}/${fileName}`; - const pos = key.lastIndexOf('/'); - const folder = key.substring(0, pos); - console.log(`File changed ${key}[${size}]`); - - if (this.state.folders[folder]) { - if (this._tempTimeout[folder]) { - clearTimeout(this._tempTimeout[folder]); - } - - this._tempTimeout[folder] = setTimeout(() => { - delete this._tempTimeout[folder]; - - this.browseFolder(folder, null, false, true) - .then(folders => this.setState({ folders })) - .catch(e => console.error(`Cannot read folder: ${e.message}`)); - }, 300); - } - }; - - changeFolder(e: React.MouseEvent, folder?: string): void { - e?.stopPropagation(); - - this.lastSelect = Date.now(); - - let _folder = folder || getParentDir(this.state.currentDir); - - if (_folder === '/') { - _folder = ''; - } - - this.localStorage.setItem('files.currentDir', _folder); - - if (folder && e && (e.altKey || e.shiftKey || e.ctrlKey || e.metaKey)) { - this.setState({ selected: _folder }); - return; - } - - // If desired folder is not yet loaded - if (_folder && !this.state.folders[_folder]) { - this.browseFolder(_folder) - .then(folders => - this.setState( - { - folders, - path: _folder, - currentDir: _folder, - selected: _folder, - pathFocus: false, - }, - () => this.props.onSelect && this.props.onSelect(''), - ), - ) - .catch(_e => console.error(`Cannot read folder: ${_e.message}`)); - return; - } - - this.setState( - { - currentDir: _folder, - selected: _folder, - path: _folder, - pathFocus: false, - }, - () => this.props.onSelect && this.props.onSelect(''), - ); - } - - select(id: string, e?: React.MouseEvent | null, cb?: () => void): void { - if (e) { - e.stopPropagation(); - } - this.lastSelect = Date.now(); - - this.localStorage.setItem('files.selected', id); - - this.setState({ selected: id, path: id, pathFocus: false }, () => { - if (this.props.onSelect) { - const ext = Utils.getFileExtension(id); - if ( - (!this.props.filterFiles || (ext && this.props.filterFiles.includes(ext))) && - (!this.state.filterByType || - (ext && (EXTENSIONS as Record)[this.state.filterByType].includes(ext))) - ) { - this.props.onSelect(id, false, !!this.state.folders[id]); - } else { - this.props.onSelect(''); - } - } - if (cb) { - cb(); - } - }); - } - - getText(text?: ioBroker.StringOrTranslated | null): string | undefined { - if (text) { - if (typeof text === 'object') { - return text[this.props.lang] || text.en || undefined; - } - return text; - } - return undefined; - } - - renderFolder(item: FolderOrFileItem, expanded?: boolean): JSX.Element | null { - if ( - this.state.viewType === TABLE && - this.state.filterEmpty && - (!this.state.folders[item.id] || !this.state.folders[item.id].length) && - item.id !== USER_DATA && - !item.temp - ) { - return null; - } - const IconEl = expanded ? IconOpen : IconClosed; - const padding = this.state.viewType === TABLE ? item.level * this.levelPadding : 0; - const isUserData = item.name === USER_DATA; - const isSpecialData = isUserData || item.name === 'vis.0' || item.name === 'vis-2.0'; - - const iconStyle = Utils.getStyle( - this.props.theme, - styles[`itemFolderIcon${this.state.viewType}`], - isSpecialData && styles.specialFolder, - ); - return ( - (this.state.viewType === TABLE ? this.select(item.id, e) : this.changeFolder(e, item.id))} - onDoubleClick={e => this.state.viewType === TABLE && this.toggleFolder(item, e)} - title={this.getText(item.title)} - className="browserItem" - sx={Utils.getStyle( - this.props.theme, - styles[`item${this.state.viewType}`], - styles[`itemFolder${this.state.viewType}`], - this.state.selected === item.id ? styles.itemSelected : {}, - item.temp ? styles.itemFolderTemp : {}, - )} - > - this.toggleFolder(item, e) : undefined - } - /> - - - {isUserData ? this.props.t('ra_User files') : item.name} - - - - {this.state.viewType === TABLE && this.state.folders[item.id] - ? this.state.folders[item.id].length - : ''} - - - - {this.state.viewType === TABLE && this.props.expertMode ? this.formatAcl(item.acl) : null} - - - {this.state.viewType === TABLE && this.props.expertMode ? ( - - ) : null} - - {this.state.viewType === TABLE && this.props.allowDownload ? ( -
- ) : null} - - {this.state.viewType === TABLE && - this.props.allowDelete && - this.state.folders[item.id] && - this.state.folders[item.id].length ? ( - { - e.stopPropagation(); - if (this.suppressDeleteConfirm > Date.now()) { - this.deleteItem(item.id); - } else { - this.setState({ deleteItem: item.id }); - } - }} - sx={styles[`itemDeleteButton${this.state.viewType}`]} - size="large" - > - - - ) : this.state.viewType === TABLE && this.props.allowDelete ? ( - - ) : null} - - ); - } - - renderBackFolder(): JSX.Element { - return ( - this.changeFolder(e)} - title={this.props.t('ra_Back to %s', getParentDir(this.state.currentDir))} - className="browserItem" - sx={Utils.getStyle( - this.props.theme, - styles[`item${this.state.viewType}`], - styles[`itemFolder${this.state.viewType}`], - )} - > - - - - - .. - - - ); - } - - formatSize(size: number | null | undefined): JSX.Element { - return ( -
- {size || size === 0 ? Utils.formatBytes(size) : ''} -
- ); - } - - formatAcl(acl: ioBroker.EvaluatedFileACL | MetaACL | undefined): JSX.Element { - const access: number = acl ? (acl as ioBroker.EvaluatedFileACL).permissions || (acl as MetaACL).file : 0; - let accessStr: string; - if (access) { - accessStr = access.toString(16).padStart(3, '0'); - } else { - accessStr = ''; - } - - return ( -
- {this.props.modalEditOfAccessControl ? ( - this.setState({ modalEditOfAccess: true })} - sx={styles[`itemAclButton${this.state.viewType}`]} - > - {accessStr || '---'} - - ) : ( - accessStr || '---' - )} -
- ); - } - - getFileIcon(ext: string | null): JSX.Element { - switch (ext) { - case 'json': - case 'json5': - return ; - - case 'css': - return ; - - case 'js': - case 'ts': - return ; - - case 'html': - case 'md': - return ; - - case 'mp3': - case 'ogg': - case 'wav': - case 'm4a': - case 'mp4': - case 'flac': - return ; - - default: - return ; - } - } - - static getEditFile(ext: string | null): boolean { - switch (ext) { - case 'json': - case 'json5': - case 'js': - case 'html': - case 'txt': - case 'css': - case 'log': - return true; - default: - return false; - } - } - - setStateBackgroundImage = (): void => { - const array = ['light', 'dark', 'colored', 'delete']; - this.setState(({ backgroundImage }) => { - if ( - backgroundImage && - array.indexOf(backgroundImage) !== -1 && - array.length - 1 !== array.indexOf(backgroundImage) - ) { - this.localStorage.setItem('files.backgroundImage', array[array.indexOf(backgroundImage) + 1]); - return { backgroundImage: array[array.indexOf(backgroundImage) + 1] }; - } - this.localStorage.setItem('files.backgroundImage', array[0]); - return { backgroundImage: array[0] }; - }); - }; - - getStyleBackgroundImage = (): React.CSSProperties | null => { - // ['light', 'dark', 'colored', 'delete'] - switch (this.state.backgroundImage) { - case 'light': - return styles.backgroundImageLight; - case 'dark': - return styles.backgroundImageDark; - case 'colored': - return styles.backgroundImageColored; - case 'delete': - return null; - default: - return null; - } - }; - - renderFile(item: FolderOrFileItem): JSX.Element { - const padding = this.state.viewType === TABLE ? item.level * this.levelPadding : 0; - const ext = Utils.getFileExtension(item.name); - - return ( - { - e.stopPropagation(); - if (!this.props.onSelect) { - this.setState({ viewer: this.imagePrefix + item.id, formatEditFile: ext }); - } else if ( - (!this.props.filterFiles || (item.ext && this.props.filterFiles.includes(item.ext))) && - (!this.state.filterByType || - (item.ext && - (EXTENSIONS as Record)[this.state.filterByType].includes(item.ext))) - ) { - this.props.onSelect(item.id, true, !!this.state.folders[item.id]); - } - }} - onClick={e => this.select(item.id, e)} - style={this.state.viewType === TABLE ? { marginLeft: padding, width: `calc(100% - ${padding}px)` } : {}} - className="browserItem" - sx={Utils.getStyle( - this.props.theme, - styles[`item${this.state.viewType}`], - styles[`itemFile${this.state.viewType}`], - this.state.selected === item.id ? styles.itemSelected : undefined, - )} - > - {ext && EXTENSIONS.images.includes(ext) ? ( - this.state.fileErrors.includes(item.id) ? ( - - ) : ( - { - (e.target as HTMLImageElement).onerror = null; - const fileErrors = [...this.state.fileErrors]; - if (!fileErrors.includes(item.id)) { - fileErrors.push(item.id); - this.setState({ fileErrors }); - } - }} - style={{ ...styles[`itemImage${this.state.viewType}`], ...this.getStyleBackgroundImage() }} - src={this.imagePrefix + item.id} - alt={item.name} - /> - ) - ) : ( - this.getFileIcon(ext) - )} - - {item.name} - - - {this.formatSize(item.size)} - - - {this.state.viewType === TABLE && this.props.expertMode ? this.formatAcl(item.acl) : null} - - - {this.state.viewType === TABLE && this.props.expertMode && FileBrowserClass.getEditFile(ext) ? ( - { - e.stopPropagation(); - if (!this.props.onSelect) { - this.setState({ viewer: this.imagePrefix + item.id, formatEditFile: ext }); - } else if ( - (!this.props.filterFiles || - (item.ext && this.props.filterFiles.includes(item.ext))) && - (!this.state.filterByType || - (item.ext && - (EXTENSIONS as Record)[this.state.filterByType].includes( - item.ext, - ))) - ) { - this.props.onSelect(item.id, true, !!this.state.folders[item.id]); - } - }} - sx={styles.itemDeleteButtonTable} - size="large" - > - - - ) : ( - - )} - - {this.state.viewType === TABLE && this.props.allowDownload ? ( - e.stopPropagation()} - > - - - ) : null} - - {this.state.viewType === TABLE && - this.props.allowDelete && - item.id !== 'vis.0/' && - item.id !== 'vis-2.0/' && - item.id !== USER_DATA ? ( - { - e.stopPropagation(); - if (this.suppressDeleteConfirm > Date.now()) { - this.deleteItem(item.id); - } else { - this.setState({ deleteItem: item.id }); - } - }} - sx={styles[`itemDeleteButton${this.state.viewType}`]} - size="large" - > - - - ) : this.state.viewType === TABLE && this.props.allowDelete ? ( - - ) : null} - - ); - } - - renderItems(folderId: string): JSX.Element | (JSX.Element | null)[] { - if (this.state.folders && this.state.folders[folderId]) { - // tile - if (this.state.viewType === TILE) { - const res: (JSX.Element | null)[] = []; - if (folderId && folderId !== '/') { - res.push(this.renderBackFolder()); - } - this.state.folders[folderId].forEach(item => { - if (item.folder) { - res.push(this.renderFolder(item)); - } else if ( - (!this.props.filterFiles || (item.ext && this.props.filterFiles.includes(item.ext))) && - (!this.state.filterByType || - (item.ext && - (EXTENSIONS as Record)[this.state.filterByType].includes(item.ext))) - ) { - res.push(this.renderFile(item)); - } - }); - return res; - } - - const totalResult: (JSX.Element | null)[] = []; - this.state.folders[folderId].forEach(item => { - if (item.folder) { - const expanded = this.state.expanded.includes(item.id); - - const folders = this.renderFolder(item, expanded); - if (Array.isArray(folders)) { - folders.forEach(folder => totalResult.push(folder)); - } else { - totalResult.push(folders); - } - if (this.state.folders[item.id] && expanded) { - const items = this.renderItems(item.id); - if (Array.isArray(items)) { - items.forEach(_item => totalResult.push(_item)); - } else { - totalResult.push(items); - } - } - } else if ( - (!this.props.filterFiles || (item.ext && this.props.filterFiles.includes(item.ext))) && - (!this.state.filterByType || - (item.ext && - (EXTENSIONS as Record)[this.state.filterByType].includes(item.ext))) - ) { - totalResult.push(this.renderFile(item)); - } - }); - - return totalResult; - } - - return ( -
- -
- {this.state.queueLength} -
-
- ); - } - - renderToolbar(): JSX.Element { - const IconType: React.FC<{ fontSize?: 'small' }> | null = this.props.showTypeSelector - ? FILE_TYPE_ICONS[this.state.filterByType || 'all'] || FILE_TYPE_ICONS.all - : null; - - const isInFolder = this.findFirstFolder(this.state.selected); - - return ( - - {this.props.allowNonRestricted && this.props.restrictToFolder ? ( - - this.setState({ - restrictToFolder: - (this.state.restrictToFolder ? '' : this.props.restrictToFolder) || '', - loadAllFolders: true, - }) - } - size="small" - > - - - ) : null} - {this.props.showExpertButton ? ( - this.setState({ expertMode: !this.state.expertMode })} - size="small" - > - - - ) : null} - {this.props.showViewTypeButton ? ( - { - const viewType = this.state.viewType === TABLE ? TILE : TABLE; - this.localStorage.setItem('files.viewType', viewType); - let currentDir = this.state.selected; - if (isFile(currentDir)) { - currentDir = getParentDir(currentDir); - } - this.setState({ viewType, currentDir }, () => { - if (this.state.viewType === TABLE) { - this.scrollToSelected(); - } - }); - }} - size="small" - > - {this.state.viewType !== TABLE ? : } - - ) : null} - { - this.localStorage.setItem('file.empty', this.state.filterEmpty ? 'false' : 'true'); - this.setState({ filterEmpty: !this.state.filterEmpty }); - }} - size="small" - > - - - this.setState({ folders: {} }, () => this.loadFolders())} - size="small" - > - - - {this.props.allowCreateFolder ? ( - this.setState({ addFolder: true })} - size="small" - > - - - ) : null} - {this.props.allowUpload ? ( - this.setState({ uploadFile: true })} - size="small" - > - - - ) : null} - {this.props.showTypeSelector && IconType ? ( - - this.setState({ showTypesMenu: e.target as HTMLButtonElement })} - > - - - - ) : null} - {this.state.showTypesMenu ? ( - this.setState({ showTypesMenu: null })} - > - {Object.keys(FILE_TYPE_ICONS).map(type => { - const MyIcon: React.FC<{ fontSize?: 'small' }> = FILE_TYPE_ICONS[type]; - return ( - { - if (type === 'all') { - this.localStorage.removeItem('files.filterByType'); - this.setState({ filterByType: '', showTypesMenu: null }); - } else { - this.localStorage.setItem('files.filterByType', type); - this.setState({ filterByType: type, showTypesMenu: null }); - } - }} - > - - - - {this.props.t(`ra_fileType_${type}`)} - - ); - })} - - ) : null} - - - - - - {this.state.viewType !== TABLE && this.props.allowDelete ? ( - - - { - e.stopPropagation(); - if (this.suppressDeleteConfirm > Date.now()) { - this.deleteItem(this.state.selected); - } else { - this.setState({ deleteItem: this.state.selected }); - } - }} - size="small" - > - - - - - ) : null} - - ); - } - - findItem(id: string, folders?: Folders | null): null | FolderOrFileItem { - folders = folders || this.state.folders; - if (!folders) { - return null; - } - const parts = id.split('/'); - parts.pop(); - const parentFolder = parts.join('/') || '/'; - if (!folders[parentFolder]) { - return null; - } - return folders[parentFolder].find(item => item.id === id) || null; - } - - renderInputDialog(): JSX.Element | null { - if (this.state.addFolder) { - const parentFolder = this.findFirstFolder(this.state.selected); - - if (!parentFolder) { - window.alert(this.props.t('ra_Invalid parent folder!')); - return null; - } - - return ( - - this.state.folders[parentFolder].find(item => item.name === text) - ? '' - : this.props.t('ra_Duplicate name') - } - onClose={(name: string | null) => { - if (name) { - const folders: Folders = {}; - Object.keys(this.state.folders).forEach( - folder => (folders[folder] = this.state.folders[folder]), - ); - const parent = this.findItem(parentFolder); - const id = `${parentFolder}/${name}`; - folders[parentFolder].push({ - id, - level: (parent?.level || 0) + 1, - name, - folder: true, - temp: true, - }); - - folders[parentFolder].sort(sortFolders); - - folders[id] = []; - const expanded = [...this.state.expanded]; - if (!expanded.includes(parentFolder)) { - expanded.push(parentFolder); - expanded.sort(); - } - this.localStorage.setItem('files.expanded', JSON.stringify(expanded)); - this.setState({ addFolder: false, folders, expanded }, () => this.select(id)); - } else { - this.setState({ addFolder: false }); - } - }} - replace={(text: string) => text.replace(/[^-_\w]/, '_')} - /> - ); - } - return null; - } - - componentDidUpdate(/* prevProps , prevState, snapshot */): void { - if (this.setOpacityTimer) { - clearTimeout(this.setOpacityTimer); - } - this.setOpacityTimer = setTimeout(() => { - this.setOpacityTimer = null; - const items = window.document.getElementsByClassName('browserItem'); - for (let i = 0; i < items.length; i++) { - (items[i] as HTMLElement).style.opacity = '1'; - } - }, 100); - } - - findFirstFolder(id: string): string | null { - let parentFolder = id; - const item = this.findItem(parentFolder); - // find folder - if (item && !item.folder) { - const parts = parentFolder.split('/'); - parts.pop(); - parentFolder = ''; - while (parts.length) { - const _item = this.findItem(parts.join('/')); - if (_item?.folder) { - parentFolder = parts.join('/'); - break; - } - parts.pop(); - } - if (!parts.length) { - return null; - } - } - - return parentFolder; - } - - async uploadFile(fileName: string, data: string): Promise { - const parts: string[] = fileName.split('/'); - const adapterName = parts.shift(); - try { - await this.props.socket.writeFile64(adapterName || '', parts.join('/'), data); - } catch (e: unknown) { - const knownError = e as Error; - window.alert(`Cannot write file: ${knownError?.message}`); - } - } - - renderUpload(): JSX.Element[] | null { - if (this.state.uploadFile) { - return [ - this.setState({ uploadFile: false })} - > - - , - this.setState({ uploadFile: 'dragging' })} - onDragLeave={() => this.setState({ uploadFile: true })} - onDrop={acceptedFiles => { - let count = acceptedFiles.length; - - acceptedFiles.forEach(file => { - const reader = new FileReader(); - - reader.onabort = () => console.log('file reading was aborted'); - reader.onerror = () => console.log('file reading has failed'); - reader.onload = () => { - const parentFolder = this.findFirstFolder(this.state.selected); - - if (!parentFolder) { - window.alert(this.props.t('ra_Invalid parent folder!')); - } else { - const id = `${parentFolder}/${file.name}`; - - void this.uploadFile(id, reader.result as string).then(() => { - if (!--count) { - this.setState({ uploadFile: false }, () => { - if (this.supportSubscribes) { - // open current folder - const expanded = [...this.state.expanded]; - if (!expanded.includes(parentFolder)) { - expanded.push(parentFolder); - expanded.sort(); - this.localStorage.setItem( - 'files.expanded', - JSON.stringify(expanded), - ); - } - this.setState({ expanded }, () => this.select(id)); - } else { - setTimeout( - () => - this.browseFolder(parentFolder, null, false, true).then( - folders => { - // open current folder - const expanded = [...this.state.expanded]; - if (!expanded.includes(parentFolder)) { - expanded.push(parentFolder); - expanded.sort(); - this.localStorage.setItem( - 'files.expanded', - JSON.stringify(expanded), - ); - } - this.setState({ folders, expanded }, () => - this.select(id), - ); - }, - ), - 500, - ); - } - }); - } - }); - } - }; - - reader.readAsArrayBuffer(file); - }); - }} - > - {({ getRootProps, getInputProps }) => ( -
- - -
- -
- {this.state.uploadFile === 'dragging' - ? this.props.t('ra_Drop file here') - : this.props.t( - 'ra_Place your files here or click here to open the browse dialog', - )} -
-
-
-
- )} -
, - ]; - } - return null; - } - - deleteRecursive(id: string): Promise { - const item = this.findItem(id); - if (item?.folder) { - return ( - this.state.folders[id] - ? Promise.all(this.state.folders[id].map(_item => this.deleteRecursive(_item.id))) - : Promise.resolve() - ).then(() => { - // If it is a folder of second level - if (item.level >= 1) { - const parts = id.split('/'); - const adapter = parts.shift(); - void this.props.socket.deleteFolder(adapter || '', parts.join('/')).then(() => { - // remove this folder - const folders = JSON.parse(JSON.stringify(this.state.folders)); - delete folders[item.id]; - // delete folder from parent item - const parentId = getParentDir(item.id); - const parentFolder = folders[parentId]; - if (parentFolder) { - const pos = parentFolder.findIndex((f: FolderOrFileItem) => f.id === item.id); - if (pos !== -1) { - parentFolder.splice(pos, 1); - } - - this.select(parentId, null, () => this.setState({ folders })); - } - }); - } - }); - } - - const parts = id.split('/'); - const adapter = parts.shift(); - if (parts.length) { - return this.props.socket - .deleteFile(adapter || '', parts.join('/')) - .catch(e => window.alert(`Cannot delete file: ${e}`)); - } - return Promise.resolve(); - } - - deleteItem(deleteItem: string): void { - deleteItem = deleteItem || this.state.deleteItem; - - this.setState({ deleteItem: '' }, () => - this.deleteRecursive(deleteItem).then(() => { - const newState: Partial = {}; - const pos = this.state.expanded.indexOf(deleteItem); - if (pos !== -1) { - const expanded = [...this.state.expanded]; - expanded.splice(pos, 1); - this.localStorage.setItem('files.expanded', JSON.stringify(expanded)); - newState.expanded = expanded; - } - - if (this.state.selected === deleteItem) { - const parts = this.state.selected.split('/'); - parts.pop(); - newState.selected = parts.join('/'); - } - - if (!this.supportSubscribes) { - const parentFolder = this.findFirstFolder(deleteItem); - const folders: Folders = {}; - - Object.keys(this.state.folders).forEach(name => { - if (name !== parentFolder && !name.startsWith(`${parentFolder}/`)) { - folders[name] = this.state.folders[name]; - } - }); - - newState.folders = folders; - - this.setState(newState as FileBrowserState, () => - setTimeout(() => { - this.browseFolders([...this.state.expanded], folders) - .then(_folders => this.setState({ folders: _folders })) - .catch(e => console.error(e)); - }, 200), - ); - } else { - this.setState(newState as FileBrowserState); - } - }), - ); - } - - renderDeleteDialog(): JSX.Element | null { - if (this.state.deleteItem) { - return ( - this.setState({ deleteItem: '' })} - aria-labelledby="ar_dialog_file_delete_title" - > - - {this.props.t('ra_Confirm deletion of %s', this.state.deleteItem.split('/').pop())} - - - {this.props.t('ra_Are you sure?')} - - - - - - - - ); - } - return null; - } - - renderViewDialog(): JSX.Element | null { - return this.state.viewer ? ( - this.setState({ viewer: '', formatEditFile: '' })} - /> - ) : null; - } - - renderError(): JSX.Element | null { - if (this.state.errorText) { - return ( - this.setState({ errorText: '' })} - /> - ); - } - return null; - } - - // used in tabs/Files - // eslint-disable-next-line react/no-unused-class-component-methods - updateItemsAcl(info: FolderOrFileItem[]): void { - this.cacheFolders = this.cacheFolders || JSON.parse(JSON.stringify(this.state.folders)); - let changed; - - info.forEach(it => { - const item = this.findItem(it.id, this.cacheFolders); - if (item && JSON.stringify(item.acl) !== JSON.stringify(it.acl)) { - item.acl = it.acl; - changed = true; - } - }); - if (changed) { - if (this.cacheFoldersTimeout) { - clearTimeout(this.cacheFoldersTimeout); - } - this.cacheFoldersTimeout = setTimeout(() => { - this.cacheFoldersTimeout = null; - const folders = this.cacheFolders || {}; - this.cacheFolders = null; - this.setState({ folders }); - }, 200); - } - } - - changeToPath(): void { - setTimeout(() => { - if (this.state.path !== this.state.selected && (!this.lastSelect || Date.now() - this.lastSelect > 100)) { - let folder = this.state.path; - if (isFile(this.state.path)) { - folder = getParentDir(this.state.path); - } - new Promise(resolve => { - if (!this.state.folders[folder]) { - this.browseFolder(folder) - .then(folders => this.setState({ folders }, () => resolve(true))) - .catch(err => - this.setState({ - errorText: - err === NOT_FOUND - ? this.props.t('ra_Cannot find "%s"', folder) - : this.props.t('ra_Cannot read "%s"', folder), - }), - ); - } else { - resolve(true); - } - }) - .then( - result => - result && - this.setState({ selected: this.state.path, currentDir: folder, pathFocus: false }), - ) - .catch(e => console.error(e)); - } else if (!this.lastSelect || Date.now() - this.lastSelect > 100) { - this.setState({ pathFocus: false }); - } - }, 100); - } - - renderBreadcrumb(): JSX.Element { - const parts = this.state.currentDir.startsWith('/') - ? this.state.currentDir.split('/') - : `/${this.state.currentDir}`.split('/'); - const p: string[] = []; - return ( - - {parts.map((part, i) => { - if (part) { - p.push(part); - } - const path = p.join('/'); - if (i < parts.length - 1) { - return ( - this.changeFolder(e, path || '/')} - > - {part || this.props.t('ra_Root')} - - ); - } - - return ( -
this.setState({ pathFocus: true })} - > - {part} -
- ); - })} -
- ); - } - - renderPath(): JSX.Element { - return ( - - {this.state.pathFocus ? ( - { - if (e.key === 'Enter') { - this.changeToPath(); - } else if (e.key === 'Escape') { - this.setState({ pathFocus: false }); - } - }} - endAdornment={ - this.changeToPath()} - > - - - } - onBlur={() => this.changeToPath()} - onChange={e => this.setState({ path: e.target.value })} - style={styles.pathDivInput} - /> - ) : ( - this.renderBreadcrumb() - )} - - ); - } - - render(): JSX.Element { - if (!this.props.ready) { - return ; - } - - if (this.state.loadAllFolders && !this.foldersLoading) { - this.foldersLoading = true; - setTimeout(() => { - this.setState({ loadAllFolders: false, folders: {} }, () => { - this.foldersLoading = false; - this.loadFolders().catch(error => console.error(`Cannot load folders: ${error}`)); - }); - }, 300); - } - - return ( -
- {this.props.showToolbar ? this.renderToolbar() : null} - {this.state.viewType === TILE ? this.renderPath() : null} -
{ - if (this.state.viewType !== TABLE) { - if (this.state.selected !== (this.state.currentDir || '/')) { - this.changeFolder(e, this.state.currentDir || '/'); - } else { - e.stopPropagation(); - } - } - }} - > - {this.state.viewType === TABLE - ? this.renderItems('/') - : this.renderItems(this.state.currentDir || '/')} - {this.state.viewType !== TABLE ? ( -
{this.props.t('ra_select_folder_hint')}
- ) : null} -
- {this.props.allowUpload ? this.renderInputDialog() : null} - {this.props.allowUpload ? this.renderUpload() : null} - {this.props.allowDelete ? this.renderDeleteDialog() : null} - {this.props.allowView ? this.renderViewDialog() : null} - {this.state.modalEditOfAccess && this.props.modalEditOfAccessControl - ? this.props.modalEditOfAccessControl(this) - : null} - {this.renderError()} -
- ); - } -} - -export default withWidth()(FileBrowserClass); diff --git a/packages/admin/src-admin/src/components/FileEditor.tsx b/packages/admin/src-admin/src/components/FileEditor.tsx new file mode 100644 index 000000000..c4ca63926 --- /dev/null +++ b/packages/admin/src-admin/src/components/FileEditor.tsx @@ -0,0 +1,78 @@ +// File viewer in adapter-react does not support write +import { Buffer } from 'buffer'; +import React, { type JSX } from 'react'; + +// File viewer in adapter-react does not use ace editor +import * as ace from 'ace-builds'; +import 'ace-builds/src-noconflict/ext-modelist'; + +import { withWidth, FileViewerClass } from '@iobroker/adapter-react-v5'; + +import Editor from './Editor'; +import type { FileViewerProps } from '@iobroker/adapter-react-v5'; + +const modelist = ace.require('ace/ext/modelist'); + +class FileEditorClass extends FileViewerClass { + constructor(props: FileViewerProps) { + super(props); + + Object.assign(this.state, { + // File viewer in adapter-react does not support write + editing: !!this.props.formatEditFile || false, + }); + } + + static getEditFile(ext: string | null): 'json' | 'json5' | 'javascript' | 'html' | 'text' { + switch (ext) { + case 'json': + return 'json'; + case 'json5': + return 'json5'; + case 'js': + return 'javascript'; + case 'html': + return 'html'; + case 'txt': + return 'html'; + default: + // e.g. ace/mode/text + return modelist.getModeForPath(`testFile.${ext}`).mode.split('/').pop(); + } + } + + writeFile64 = (): void => { + // File viewer in adapter-react does not support write + const parts = this.props.href.split('/'); + const data = this.state.editingValue; + parts.splice(0, 2); + const adapter = parts[0]; + const name = parts.splice(1).join('/'); + this.props.socket + .writeFile64(adapter, name, Buffer.from(data).toString('base64')) + .then(() => this.props.onClose()) + .catch(e => window.alert(`Cannot write file: ${e}`)); + }; + + getEditorOrViewer(): JSX.Element { + // File viewer in adapter-react does not support write + return ( + this.setState({ editingValue: newValue, changed: true }) + : undefined + } + /> + ); + } + + onSave(): void { + this.writeFile64(); + } +} + +export const FileEditor = withWidth()(FileEditorClass); diff --git a/packages/admin/src-admin/src/components/FileViewer.tsx b/packages/admin/src-admin/src/components/FileViewer.tsx deleted file mode 100644 index cfefb45be..000000000 --- a/packages/admin/src-admin/src/components/FileViewer.tsx +++ /dev/null @@ -1,400 +0,0 @@ -// File viewer in adapter-react does not support write -import { Buffer } from 'buffer'; -import React, { Component, type JSX } from 'react'; - -import { Button, Dialog, DialogActions, DialogContent, DialogTitle, IconButton } from '@mui/material'; - -// Icons -import { FaCopy as CopyIcon } from 'react-icons/fa'; -import { Close as CloseIcon, Save as SaveIcon, Brightness6 as Brightness5Icon } from '@mui/icons-material'; - -import type { Connection } from '@iobroker/socket-client'; - -// File viewer in adapter-react does not use ace editor -import * as ace from 'ace-builds'; -import 'ace-builds/src-noconflict/ext-modelist'; - -import { Utils, withWidth, IconNoIcon, Icon, type ThemeType, type Translate } from '@iobroker/adapter-react-v5'; - -import Editor from './Editor'; - -const modelist = ace.require('ace/ext/modelist'); - -const styles: Record = { - dialog: { - height: '100%', - }, - paper: { - height: 'calc(100% - 64px)', - }, - content: { - textAlign: 'center', - }, - textarea: { - width: '100%', - height: '100%', - }, - img: { - width: 'auto', - height: 'calc(100% - 5px)', - objectFit: 'contain', - }, - dialogTitle: { - justifyContent: 'space-between', - display: 'flex', - }, -}; - -export const EXTENSIONS = { - images: ['png', 'jpg', 'svg', 'jpeg', 'bmp', 'gif', 'apng', 'avif', 'webp'], - code: ['js', 'json', 'json5', 'md'], - txt: ['log', 'txt', 'html', 'css', 'xml', 'ics'], - audio: ['mp3', 'wav', 'ogg', 'acc'], - video: ['mp4', 'mov', 'avi'], -}; - -function bufferToBase64(buffer: Buffer, isFull?: boolean): string { - let binary = ''; - const bytes = new Uint8Array(buffer); - const len = bytes.byteLength; - for (let i = 0; i < len && (isFull || i < 50); i++) { - binary += String.fromCharCode(bytes[i]); - } - return window.btoa(binary); -} - -interface FileViewerProps { - /** Translation function */ - t: Translate; - /** Callback when the viewer is closed. */ - onClose: () => void; - /** The URL (file path) to the file to be displayed. */ - href: string; - formatEditFile?: string; - socket: Connection; - setStateBackgroundImage: () => void; - themeType: ThemeType; - getStyleBackgroundImage: () => React.CSSProperties | null; - /** Flag is the js-controller support subscribe on file */ - supportSubscribes?: boolean; -} - -interface FileViewerState { - text: string | null; - code: string | null; - ext: string | null; - editing: boolean; - editingValue: string | null; - copyPossible: boolean; - forceUpdate: number; - changed: boolean; - imgError: boolean; -} - -class FileViewerClass extends Component { - private timeout: ReturnType | null = null; - - constructor(props: FileViewerProps) { - super(props); - const ext = Utils.getFileExtension(props.href); - - this.state = { - text: null, - code: null, - ext, - // File viewer in adapter-react does not support write - editing: !!this.props.formatEditFile || false, - editingValue: null, - copyPossible: !!ext && (EXTENSIONS.code.includes(ext) || EXTENSIONS.txt.includes(ext)), - forceUpdate: Date.now(), - changed: false, - imgError: false, - }; - } - - readFile(): void { - if (this.props.href) { - const parts = this.props.href.split('/'); - parts.splice(0, 2); - const adapter = parts[0]; - const name = parts.splice(1).join('/'); - - this.props.socket - .readFile(adapter, name) - .then((data: { data: Buffer; type: string } | { file: string; mimeType: string }) => { - let fileData = ''; - if ((data as { file: string; mimeType: string }).file !== undefined) { - fileData = (data as { file: string; mimeType: string }).file; - } - - const newState: Partial = { - copyPossible: this.state.copyPossible, - ext: this.state.ext, - }; - // try to detect valid extension - if ((data as { data: Buffer; type: string }).type === 'Buffer') { - if (name.toLowerCase().endsWith('.json5')) { - newState.ext = 'json5'; - newState.copyPossible = true; - try { - fileData = atob(bufferToBase64((data as { data: Buffer; type: string }).data, true)); - } catch { - console.error('Cannot convert base64 to string'); - fileData = ''; - } - } else { - const ext = Utils.detectMimeType( - bufferToBase64((data as { data: Buffer; type: string }).data), - ); - if (ext) { - newState.ext = ext; - newState.copyPossible = EXTENSIONS.code.includes(ext) || EXTENSIONS.txt.includes(ext); - } - } - } - - if (newState.copyPossible) { - if (newState.ext && EXTENSIONS.txt.includes(newState.ext)) { - newState.text = fileData; - newState.editingValue = fileData; - } else if (newState.ext && EXTENSIONS.code.includes(newState.ext)) { - newState.code = fileData; - newState.editingValue = fileData; - } - } - - this.setState(newState as FileViewerState); - }) - .catch(e => window.alert(`Cannot read file: ${e}`)); - } - } - - componentDidMount(): void { - this.readFile(); - - const parts = this.props.href.split('/'); - parts.splice(0, 2); - const adapter = parts[0]; - const name = parts.splice(1).join('/'); - - if (this.props.supportSubscribes) { - this.props.socket - .subscribeFiles(adapter, name, this.onFileChanged) - .catch(e => window.alert(`Cannot subscribe on file: ${e}`)); - } - } - - componentWillUnmount(): void { - if (this.timeout) { - clearTimeout(this.timeout); - this.timeout = null; - } - const parts = this.props.href.split('/'); - parts.splice(0, 2); - const adapter = parts[0]; - const name = parts.splice(1).join('/'); - if (this.props.supportSubscribes) { - this.props.socket - .subscribeFiles(adapter, name, this.onFileChanged) - .catch(e => window.alert(`Cannot subscribe on file: ${e}`)); - } - } - - onFileChanged = (_id: string, _fileName: string, size: number | null): void => { - if (!this.state.changed) { - if (this.timeout) { - clearTimeout(this.timeout); - } - this.timeout = setTimeout(() => { - this.timeout = null; - if (size === null) { - window.alert('Show file was deleted!'); - } else if (this.state.text !== null || this.state.code !== null) { - this.readFile(); - } else { - this.setState({ forceUpdate: Date.now() }); - } - }, 300); - } - }; - - writeFile64 = (): void => { - // File viewer in adapter-react does not support write - const parts = this.props.href.split('/'); - const data = this.state.editingValue; - parts.splice(0, 2); - const adapter = parts[0]; - const name = parts.splice(1).join('/'); - this.props.socket - .writeFile64(adapter, name, Buffer.from(data).toString('base64')) - .then(() => this.props.onClose()) - .catch(e => window.alert(`Cannot write file: ${e}`)); - }; - - static getEditFile(ext: string | null): 'json' | 'json5' | 'javascript' | 'html' | 'text' { - switch (ext) { - case 'json': - return 'json'; - case 'json5': - return 'json5'; - case 'js': - return 'javascript'; - case 'html': - return 'html'; - case 'txt': - return 'html'; - default: - // e.g. ace/mode/text - return modelist.getModeForPath(`testFile.${ext}`).mode.split('/').pop(); - } - } - - getContent(): JSX.Element | null { - if (this.state.ext && EXTENSIONS.images.includes(this.state.ext)) { - if (this.state.imgError) { - return ; - } - return ( - { - (e.target as HTMLImageElement).onerror = null; - this.setState({ imgError: true }); - }} - style={{ ...styles.img, ...this.props.getStyleBackgroundImage() }} - src={`${this.props.href}?ts=${this.state.forceUpdate}`} - alt={this.props.href} - /> - ); - } - if (this.state.ext && EXTENSIONS.audio.includes(this.state.ext)) { - return ( -
- -
- ); - } - if (this.state.ext && EXTENSIONS.video.includes(this.state.ext)) { - return ( -
- -
- ); - } - if (this.state.code !== null || this.state.text !== null || this.state.editing) { - // File viewer in adapter-react does not support write - return ( - this.setState({ editingValue: newValue, changed: true }) - : undefined - } - /> - ); - } - return null; - } - - render(): JSX.Element { - return ( - this.props.onClose()} - fullWidth - maxWidth="xl" - aria-labelledby="ar_dialog_file_view_title" - > -
- {`${this.props.t(this.state.editing ? 'Edit' : 'View')}: ${this.props.href}`} - {this.state.ext && EXTENSIONS.images.includes(this.state.ext) && ( -
- - - -
- )} -
- {this.getContent()} - - {this.state.copyPossible ? ( - - ) : null} - {this.state.editing ? ( - - ) : null} - - -
- ); - } -} - -export const FileViewer = withWidth()(FileViewerClass); diff --git a/packages/admin/src-admin/src/components/ObjectBrowser.tsx b/packages/admin/src-admin/src/components/ObjectBrowser.tsx deleted file mode 100644 index 7ceb99ca8..000000000 --- a/packages/admin/src-admin/src/components/ObjectBrowser.tsx +++ /dev/null @@ -1,8950 +0,0 @@ -/** - * Copyright 2020-2024, Denis Haev - * - * MIT License - * - * To all editors: please merge asap the changes to https://github.com/ioBroker/adapter-react/blob/master/src/Components/ObjectBrowser.js - * This file is here only temporary for better debugging - */ -import React, { Component, createRef, type JSX } from 'react'; -import SVG from 'react-inlinesvg'; - -import { - Badge, - Box, - Button, - Checkbox, - CircularProgress, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - Fab, - FormControl, - FormControlLabel, - Grid2, - IconButton, - Input, - List, - ListItem, - ListItemButton, - ListItemIcon, - ListItemText, - Menu, - MenuItem, - Paper, - Select, - Snackbar, - Switch, - TextField, - type Theme, - Tooltip, -} from '@mui/material'; - -// Icons -import { - Add as AddIcon, - ArrowRight as ArrowRightIcon, - BedroomParent, - BorderColor, - Build as BuildIcon, - CalendarToday as IconSchedule, - Check as IconCheck, - Close as IconClose, - Code as IconScript, - Construction, - CreateNewFolder as IconFolder, - Delete as IconDelete, - Description as IconMeta, - Edit as IconEdit, - Error as IconError, - FindInPage, - FormatItalic as IconValueEdit, - Info as IconInfo, - Link as IconLink, - ListAlt as IconEnum, - LooksOne as LooksOneIcon, - PersonOutlined as IconUser, - Publish as PublishIcon, - Refresh as RefreshIcon, - Router as IconHost, - Settings as IconConfig, - SettingsApplications as IconSystem, - DataObject as IconData, - ShowChart as IconChart, - SupervisedUserCircle as IconGroup, - TextFields as TextFieldsIcon, - ViewColumn as IconColumns, - Wifi as IconConnection, - WifiOff as IconDisconnected, -} from '@mui/icons-material'; - -import { - Icon, - IconAdapter, - IconChannel, - IconClearFilter, - IconClosed, - IconCopy, - IconDevice, - IconDocument, - IconDocumentReadOnly, - IconExpert, - IconInstance, - IconOpen, - IconState, - withWidth, - Connection, - Utils, - TabHeader, - TabContent, - TabContainer, - type Router, - type IobTheme, - type ThemeType, - type ThemeName, - type Translate, -} from '@iobroker/adapter-react-v5'; -// own - -declare global { - interface Window { - sparkline: { - sparkline: (el: HTMLDivElement, data: number[]) => JSX.Element; - }; - } -} -declare module '@mui/material/Button' { - interface ButtonPropsColorOverrides { - grey: true; - } -} - -const ICON_SIZE = 24; -const ROW_HEIGHT = 32; -const ITEM_LEVEL = 16; -const SMALL_BUTTON_SIZE = 20; -const COLOR_NAME_USERDATA = (themeType: ThemeType): string => (themeType === 'dark' ? '#62ff25' : '#37c400'); -const COLOR_NAME_ALIAS = (themeType: ThemeType): string => (themeType === 'dark' ? '#ee56ff' : '#a204b4'); -const COLOR_NAME_JAVASCRIPT = (themeType: ThemeType): string => (themeType === 'dark' ? '#fff46e' : '#b89101'); -const COLOR_NAME_SYSTEM = (themeType: ThemeType): string => (themeType === 'dark' ? '#ff6d69' : '#ff6d69'); -const COLOR_NAME_SYSTEM_ADAPTER = (themeType: ThemeType): string => (themeType === 'dark' ? '#5773ff' : '#5773ff'); -const COLOR_NAME_ERROR_DARK = '#ff413c'; -const COLOR_NAME_ERROR_LIGHT = '#86211f'; -const COLOR_NAME_CONNECTED_DARK = '#57ff45'; -const COLOR_NAME_CONNECTED_LIGHT = '#098c04'; -const COLOR_NAME_DISCONNECTED_DARK = '#f3ad11'; -const COLOR_NAME_DISCONNECTED_LIGHT = '#6c5008'; - -type ObjectEventType = 'new' | 'changed' | 'deleted'; - -interface ObjectEvent { - id: string; - obj?: ioBroker.Object; - type: ObjectEventType; - oldObj?: ioBroker.Object; -} - -interface ObjectsWorker { - getObjects(update?: boolean): Promise>; - registerHandler(cb: (events: ObjectEvent[]) => void): void; - unregisterHandler(cb: (events: ObjectEvent[]) => void, doNotUnsubscribe?: boolean): void; -} - -interface CustomAdminColumnStored { - path: string; - name: string; - objTypes?: ioBroker.ObjectType[]; - width?: number; - edit?: boolean; - type?: ioBroker.CommonType; -} - -interface ContextMenuItem { - /** hotkey */ - key?: string; - visibility: boolean; - icon: JSX.Element | string; - label: string; - onClick?: () => void; - listItemIconStyle?: React.CSSProperties; - style?: React.CSSProperties; - subMenu?: { - label: string; - visibility: boolean; - icon: JSX.Element; - onClick: () => void; - iconStyle?: React.CSSProperties; - style?: React.CSSProperties; - listItemIconStyle?: React.CSSProperties; - }[]; - iconStyle?: React.CSSProperties; -} - -export interface TreeItemData { - id: string; - name: string; - obj?: ioBroker.Object; - /** Object ID in lower case for filtering */ - fID?: string; - /** translated common.name in lower case for filtering */ - fName?: string; - /** Link to parent item */ - parent?: TreeItem; - level?: number; - icon?: string | JSX.Element | null; - /** If the item existing object or generated folder */ - generated?: boolean; - title?: string; - /** if the item has "write" button (value=true, ack=false) */ - button?: boolean; - /** If the item has read and write and is boolean */ - switch?: boolean; - /** if the item has custom settings in `common.custom` */ - hasCustoms?: boolean; - /** If this item is visible */ - visible?: boolean; - /** Is any of the children visible (not only directly children) */ - hasVisibleChildren?: boolean; - /** Is any of the parents visible (not only directly parent) */ - hasVisibleParent?: boolean; - /** Combination of `visible || hasVisibleChildren` */ - sumVisibility?: boolean; - /** translated names of enumerations (functions) where this object is the member (or the parent), divided by comma */ - funcs?: string; - /** is if the enums are from parent */ - pef?: boolean; - /** translated names of enumerations (rooms) where this object is the member (or the parent), divided by comma */ - rooms?: string; - /** is if the enums are from parent */ - per?: boolean; - // language in what the rooms and functions where translated - lang?: ioBroker.Languages; - state?: { - valTextRx?: JSX.Element[] | null; - style?: React.CSSProperties; - }; - aclTooltip?: null | JSX.Element; -} - -interface InputSelectItem { - value: string; - name: string; - icon?: null | JSX.Element; -} - -type ioBrokerObjectForExport = ioBroker.Object & Partial; - -interface ObjectBrowserCustomFilter { - type?: ioBroker.ObjectType | ioBroker.ObjectType[]; - common?: { - type?: ioBroker.CommonType | ioBroker.CommonType[]; - role?: string | string[]; - // If "_" - no custom set - // If "_dataSources" - only data sources (history, sql, influxdb, ...) - // Else "telegram." or something like this - // `true` - If common.custom not empty - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents - custom?: '_' | '_dataSources' | true | string | string[]; - }; -} - -interface FormatValueOptions { - state: ioBroker.State; - obj: ioBroker.StateObject; - texts: Record; - dateFormat: string; - isFloatComma: boolean; - full?: boolean; -} - -export interface TreeItem { - id?: string; - data: TreeItemData; - children?: TreeItem[]; -} - -interface TreeInfo { - funcEnums: string[]; - roomEnums: string[]; - roles: string[]; - ids: string[]; - types: string[]; - objects: Record; - customs: string[]; - enums: string[]; - hasSomeCustoms: boolean; - // List of all aliases that shows to this state - aliasesMap: { [stateId: string]: string[] }; -} - -interface GetValueStyleOptions { - state: ioBroker.State; - isExpertMode?: boolean; - isButton?: boolean; -} - -const styles: Record = { - toolbar: { - minHeight: 38, // Theme.toolbar.height, - // boxShadow: '0px 2px 4px -1px rgba(0, 0, 0, 0.2), 0px 4px 5px 0px rgba(0, 0, 0, 0.14), 0px 1px 10px 0px rgba(0, 0, 0, 0.12)' - }, - toolbarButtons: { - padding: 4, - marginLeft: 4, - }, - switchColumnAuto: { - marginLeft: 16, - }, - dialogColumns: { - transition: 'opacity 1s', - }, - dialogColumnsLabel: { - fontSize: 12, - paddingTop: 8, - }, - columnCustom: { - width: '100%', - display: 'inline-block', - }, - columnCustomEditable: { - cursor: 'text', - }, - columnCustom_center: { - textAlign: 'center', - }, - columnCustom_left: { - textAlign: 'left', - }, - columnCustom_right: { - textAlign: 'right', - }, - width100: { - width: '100%', - }, - transparent_10: { - opacity: 0.1, - }, - transparent_20: { - opacity: 0.2, - }, - transparent_30: { - opacity: 0.3, - }, - transparent_40: { - opacity: 0.4, - }, - transparent_50: { - opacity: 0.5, - }, - transparent_60: { - opacity: 0.6, - }, - transparent_70: { - opacity: 0.7, - }, - transparent_80: { - opacity: 0.8, - }, - transparent_90: { - opacity: 0.9, - }, - transparent_100: { - opacity: 1, - }, - headerRow: { - paddingLeft: 8, - height: 38, - whiteSpace: 'nowrap', - userSelect: 'none', - }, - buttonClearFilter: { - position: 'relative', - float: 'right', - padding: 0, - }, - buttonClearFilterIcon: { - zIndex: 2, - position: 'absolute', - top: 0, - left: 0, - color: '#FF0000', - opacity: 0.7, - }, - - tableDiv: { - paddingTop: 0, - paddingLeft: 0, - width: 'calc(100% - 8px)', - height: 'calc(100% - 38px)', - overflow: 'auto', - }, - tableRow: (theme: IobTheme) => ({ - pl: 1, - height: ROW_HEIGHT, - lineHeight: `${ROW_HEIGHT}px`, - verticalAlign: 'top', - userSelect: 'none', - position: 'relative', - width: '100%', - '&:hover': { - background: `${ - theme.palette.mode === 'dark' ? theme.palette.primary.dark : theme.palette.primary.light - } !important`, - color: Utils.invertColor(theme.palette.primary.main, true), - }, - whiteSpace: 'nowrap', - flexWrap: 'nowrap', - }), - tableRowLines: (theme: IobTheme) => ({ - borderBottom: `1px solid ${theme.palette.mode === 'dark' ? '#8888882e' : '#8888882e'}`, - '& > div': { - borderRight: `1px solid ${theme.palette.mode === 'dark' ? '#8888882e' : '#8888882e'}`, - }, - }), - tableRowNoDragging: { - cursor: 'pointer', - }, - tableRowAlias: { - height: ROW_HEIGHT + 10, - }, - tableRowAliasReadWrite: { - height: ROW_HEIGHT + 22, - }, - tableRowFocused: (theme: IobTheme) => ({ - '&:after': { - content: '""', - position: 'absolute', - top: 1, - left: 1, - right: 1, - bottom: 1, - border: theme.palette.mode ? '1px dotted #000' : '1px dotted #FFF', - }, - }), - checkBox: { - padding: 0, - }, - cellId: { - position: 'relative', - fontSize: '1rem', - overflow: 'hidden', - textOverflow: 'ellipsis', - // verticalAlign: 'top', - // position: 'relative', - '& .copyButton': { - display: 'none', - }, - '&:hover .copyButton': { - display: 'block', - }, - '& .iconOwn': { - display: 'block', - width: ROW_HEIGHT - 4, - height: ROW_HEIGHT - 4, - mt: '2px', - float: 'right', - }, - '&:hover .iconOwn': { - display: 'none', - }, - '& *': { - width: 'initial', - }, - }, - cellIdSpan: { - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - // display: 'inline-block', - // verticalAlign: 'top', - }, - // This style is used for simple div. Do not migrate it to "secondary.main" - cellIdIconFolder: (theme: IobTheme) => ({ - marginRight: 8, - width: ROW_HEIGHT - 4, - height: ROW_HEIGHT - 4, - cursor: 'pointer', - color: theme.palette.secondary.main || '#fbff7d', - verticalAlign: 'top', - }), - cellIdIconDocument: { - verticalAlign: 'middle', - marginLeft: (ROW_HEIGHT - SMALL_BUTTON_SIZE) / 2, - marginRight: 8, - width: SMALL_BUTTON_SIZE, - height: SMALL_BUTTON_SIZE, - }, - cellIdIconOwn: {}, - cellIdTooltip: { - fontSize: 14, - }, - cellIdTooltipLink: { - color: '#7ec2fd', - '&:hover': { - color: '#7ec2fd', - }, - '&:visited': { - color: '#7ec2fd', - }, - }, - cellCopyButton: { - width: SMALL_BUTTON_SIZE, - height: SMALL_BUTTON_SIZE, - top: (ROW_HEIGHT - SMALL_BUTTON_SIZE) / 2, - opacity: 0.8, - position: 'absolute', - right: 3, - }, - cellCopyButtonInDetails: { - width: SMALL_BUTTON_SIZE, - height: SMALL_BUTTON_SIZE, - top: (ROW_HEIGHT - SMALL_BUTTON_SIZE) / 2, - opacity: 0.8, - }, - cellEditButton: { - width: SMALL_BUTTON_SIZE, - height: SMALL_BUTTON_SIZE, - color: 'white', - position: 'absolute', - top: (ROW_HEIGHT - SMALL_BUTTON_SIZE) / 2, - right: SMALL_BUTTON_SIZE + 3, - opacity: 0.7, - '&:hover': { - opacity: 1, - }, - }, - cellName: { - display: 'inline-block', - verticalAlign: 'top', - fontSize: 14, - ml: '5px', - overflow: 'hidden', - textOverflow: 'ellipsis', - position: 'relative', - '& .copyButton': { - display: 'none', - }, - '&:hover .copyButton': { - display: 'block', - }, - }, - cellNameWithDesc: { - lineHeight: 'normal', - }, - cellNameDivDiv: {}, - cellDescription: { - fontSize: 10, - opacity: 0.5, - fontStyle: 'italic', - }, - cellIdAlias: (theme: IobTheme) => ({ - fontStyle: 'italic', - fontSize: 12, - opacity: 0.7, - '&:hover': { - color: theme.palette.mode === 'dark' ? '#009900' : '#007700', - }, - }), - cellIdAliasReadWriteDiv: { - height: 24, - marginTop: -5, - }, - cellIdAliasAlone: { - lineHeight: 0, - }, - cellIdAliasReadWrite: { - lineHeight: '12px', - }, - cellType: { - display: 'inline-block', - verticalAlign: 'top', - '& .itemIcon': { - verticalAlign: 'middle', - width: ICON_SIZE, - height: ICON_SIZE, - display: 'inline-block', - }, - '& .itemIconFolder': { - marginLeft: 3, - }, - }, - cellRole: { - display: 'inline-block', - verticalAlign: 'top', - textOverflow: 'ellipsis', - overflow: 'hidden', - }, - cellRoom: { - display: 'inline-block', - verticalAlign: 'top', - textOverflow: 'ellipsis', - overflow: 'hidden', - }, - cellEnumParent: { - opacity: 0.4, - }, - cellFunc: { - display: 'inline-block', - verticalAlign: 'top', - textOverflow: 'ellipsis', - overflow: 'hidden', - }, - cellValue: { - display: 'inline-block', - verticalAlign: 'top', - textOverflow: 'ellipsis', - overflow: 'hidden', - }, - cellValueButton: { - marginTop: 5, - }, - cellValueButtonFalse: { - opacity: 0.3, - }, - cellAdapter: { - display: 'inline-block', - verticalAlign: 'top', - }, - cellValueTooltip: { - fontSize: 12, - }, - cellValueText: { - width: '100%', - height: ROW_HEIGHT, - fontSize: 16, - display: 'flex', - overflow: 'hidden', - textOverflow: 'ellipsis', - position: 'relative', - verticalAlign: 'top', - '& .copyButton': { - display: 'none', - }, - '&:hover .copyButton': { - display: 'block', - }, - }, - cellValueFile: { - color: '#2837b9', - }, - cellValueTooltipTitle: { - fontStyle: 'italic', - width: 100, - display: 'inline-block', - }, - cellValueTooltipValue: { - width: 120, - display: 'inline-block', - // overflow: 'hidden', - whiteSpace: 'nowrap', - textOverflow: 'ellipsis', - }, - cellValueTooltipImage: { - width: 100, - height: 'auto', - }, - cellValueTooltipBoth: { - width: 220, - display: 'inline-block', - whiteSpace: 'nowrap', - }, - cellValueTooltipBox: { - width: 250, - overflow: 'hidden', - pointerEvents: 'none', - }, - tooltip: { - pointerEvents: 'none', - }, - cellValueTextUnit: { - marginLeft: 4, - opacity: 0.8, - display: 'inline-block', - }, - cellValueTextState: { - opacity: 0.7, - }, - cellValueTooltipCopy: { - position: 'absolute', - bottom: 3, - right: 3, - }, - cellValueTooltipEdit: { - position: 'absolute', - bottom: 3, - right: 15, - }, - cellButtons: { - display: 'inline-block', - verticalAlign: 'top', - }, - cellButtonsButton: { - display: 'inline-block', - opacity: 0.5, - width: SMALL_BUTTON_SIZE + 4, - height: SMALL_BUTTON_SIZE + 4, - '&:hover': { - opacity: 1, - }, - p: 0, - mt: '-2px', - }, - cellButtonsEmptyButton: { - fontSize: 12, - }, - cellButtonMinWidth: { - minWidth: 40, - }, - cellButtonsButtonAlone: { - ml: `${SMALL_BUTTON_SIZE + 6}px`, - pt: 0, - mt: '-2px', - }, - cellButtonsButtonWithCustoms: (theme: IobTheme) => ({ - color: theme.palette.mode === 'dark' ? theme.palette.primary.main : theme.palette.secondary.main, - }), - cellButtonsButtonWithoutCustoms: { - opacity: 0.2, - }, - cellButtonsValueButton: (theme: IobTheme) => ({ - position: 'absolute', - top: SMALL_BUTTON_SIZE / 2 - 2, - opacity: 0.7, - width: SMALL_BUTTON_SIZE - 2, - height: SMALL_BUTTON_SIZE - 2, - color: theme.palette.action.active, - '&:hover': { - opacity: 1, - }, - }), - cellButtonsValueButtonCopy: { - right: 8, - cursor: 'pointer', - }, - cellButtonsValueButtonEdit: { - right: SMALL_BUTTON_SIZE / 2 + 16, - }, - cellDetailsLine: { - display: 'flex', - alignItems: 'center', - width: '100%', - height: 32, - fontSize: 16, - }, - cellDetailsName: { - fontWeight: 'bold', - marginRight: 8, - minWidth: 80, - }, - - filteredOut: { - opacity: 0.5, - }, - filteredParentOut: { - opacity: 0.3, - }, - filterInput: { - mt: 0, - mb: 0, - }, - selectIcon: { - width: 24, - height: 24, - marginRight: 4, - }, - selectNone: { - opacity: 0.5, - }, - itemSelected: (theme: IobTheme) => ({ - background: `${theme.palette.primary.main} !important`, - color: `${Utils.invertColor(theme.palette.primary.main, true)} !important`, - }), - header: { - width: '100%', - }, - headerCell: { - display: 'inline-block', - verticalAlign: 'top', - }, - headerCellValue: { - paddingTop: 4, - // paddingLeft: 5, - fontSize: 16, - }, - headerCellInput: { - width: 'calc(100% - 5px)', - height: ROW_HEIGHT, - pt: 0, - '& .itemIcon': { - verticalAlign: 'middle', - width: ICON_SIZE, - height: ICON_SIZE, - display: 'inline-block', - }, - }, - headerCellSelectItem: { - '& .itemIcon': { - width: ICON_SIZE, - height: ICON_SIZE, - mr: '5px', - display: 'inline-block', - }, - }, - visibleButtons: { - color: '#2196f3', - opacity: 0.7, - }, - grow: { - flexGrow: 1, - }, - enumIconDiv: { - marginRight: 8, - width: 32, - height: 32, - borderRadius: 8, - background: '#FFFFFF', - }, - enumIcon: { - marginTop: 4, - marginLeft: 4, - width: 24, - height: 24, - }, - enumDialog: { - overflow: 'hidden', - }, - enumList: { - minWidth: 250, - height: 'calc(100% - 50px)', - overflow: 'auto', - }, - enumButton: { - float: 'right', - }, - enumCheckbox: { - minWidth: 0, - }, - buttonDiv: { - display: 'flex', - height: '100%', - alignItems: 'center', - }, - aclText: { - fontSize: 13, - marginTop: 6, - }, - rightsObject: { - color: '#55ff55', - paddingLeft: 3, - }, - rightsState: { - color: '#86b6ff', - paddingLeft: 3, - }, - textCenter: { - padding: 12, - textAlign: 'center', - }, - tooltipAccessControl: { - display: 'flex', - flexDirection: 'column', - }, - fontSizeTitle: { - '@media screen and (max-width: 465px)': { - '& *': { - fontSize: 12, - }, - }, - }, - draggable: { - cursor: 'copy', - }, - nonDraggable: { - cursor: 'no-drop', - }, - selectClearButton: { - position: 'absolute', - top: 0, - right: 0, - borderRadius: 5, - backgroundColor: 'background.default', - }, - iconDeviceConnected: (theme: IobTheme) => ({ - color: theme.palette.mode === 'dark' ? COLOR_NAME_CONNECTED_DARK : COLOR_NAME_CONNECTED_LIGHT, - opacity: 0.8, - position: 'absolute', - top: 4, - right: 32, - width: 20, - }), - iconDeviceDisconnected: (theme: IobTheme) => ({ - color: theme.palette.mode === 'dark' ? COLOR_NAME_DISCONNECTED_DARK : COLOR_NAME_DISCONNECTED_LIGHT, - opacity: 0.8, - position: 'absolute', - top: 4, - right: 32, - width: 20, - }), - iconDeviceError: (theme: IobTheme) => ({ - color: theme.palette.mode === 'dark' ? COLOR_NAME_ERROR_DARK : COLOR_NAME_ERROR_LIGHT, - opacity: 0.8, - position: 'absolute', - top: 4, - right: 50, - width: 20, - }), - resizeHandle: { - display: 'block', - position: 'absolute', - cursor: 'col-resize', - width: 7, - top: 2, - bottom: 2, - zIndex: 1, - }, - resizeHandleRight: { - right: 3, - borderRight: '2px dotted #888', - '&:hover': { - borderColor: '#ccc', - borderRightStyle: 'solid', - }, - '&.active': { - borderColor: '#517ea5', - borderRightStyle: 'solid', - }, - }, - invertedBackground: (theme: IobTheme) => ({ - backgroundColor: theme.palette.mode === 'dark' ? '#9a9a9a' : '#565656', - padding: '0 3px', - borderRadius: '2px 0 0 2px', - }), - invertedBackgroundFlex: (theme: IobTheme) => ({ - backgroundColor: theme.palette.mode === 'dark' ? '#9a9a9a' : '#565656', - borderRadius: '0 2px 2px 0', - }), - contextMenuEdit: (theme: IobTheme) => ({ - color: theme.palette.mode === 'dark' ? '#ffee48' : '#cbb801', - }), - contextMenuEditValue: (theme: IobTheme) => ({ - color: theme.palette.mode === 'dark' ? '#5dff45' : '#1cd301', - }), - contextMenuView: (theme: IobTheme) => ({ - color: theme.palette.mode === 'dark' ? '#FFF' : '#000', - }), - contextMenuCustom: (theme: IobTheme) => ({ - color: theme.palette.mode === 'dark' ? '#42eaff' : '#01bbc2', - }), - contextMenuACL: (theme: IobTheme) => ({ - color: theme.palette.mode === 'dark' ? '#e079ff' : '#500070', - }), - contextMenuRoom: (theme: IobTheme) => ({ - color: theme.palette.mode === 'dark' ? '#ff9a33' : '#642a00', - }), - contextMenuRole: (theme: IobTheme) => ({ - color: theme.palette.mode === 'dark' ? '#ffdb43' : '#562d00', - }), - contextMenuDelete: (theme: IobTheme) => ({ - color: theme.palette.mode === 'dark' ? '#ff4f4f' : '#cf0000', - }), - contextMenuKeys: { - marginLeft: 8, - opacity: 0.7, - fontSize: 'smaller', - }, - contextMenuWithSubMenu: { - display: 'flex', - }, -}; - -function ButtonIcon(props?: { style?: React.CSSProperties }): JSX.Element { - return ( - - - - - - - ); -} - -/** - * Function that walks through all keys of an object or array and applies a function to each key. - */ -function walkThroughArray(object: any[], iteratee: (result: any[], value: any, key: number) => void): any[] { - const copiedObject: any[] = []; - for (let index = 0; index < object.length; index++) { - iteratee(copiedObject, object[index], index); - } - return copiedObject; -} - -/** - * Function that walks through all keys of an object or array and applies a function to each key. - */ -function walkThroughObject( - object: Record, - iteratee: (result: Record, value: any, key: string) => void, -): Record { - const copiedObject: Record = {}; - for (const key in object) { - if (Object.prototype.hasOwnProperty.call(object, key)) { - iteratee(copiedObject, object[key], key); - } - } - return copiedObject; -} - -/** - * Function to reduce an object primarily by a given list of keys - */ -function filterObject( - /** The objects which should be filtered */ - obj: Record | any[], - /** The keys which should be excluded */ - filterKeys: string[], - /** Whether translations should be reduced to only the english value */ - excludeTranslations?: boolean, -): Record | any[] { - if (Array.isArray(obj)) { - return walkThroughArray(obj, (result: any[], value: any, key: number) => { - if (value === undefined || value === null) { - return; - } - // if the key is an object, run it through the inner function - omitFromObject - const isObject = typeof value === 'object'; - if (excludeTranslations && isObject) { - if (typeof value.en === 'string' && typeof value.de === 'string') { - result[key] = value.en; - return; - } - } - result[key] = isObject ? filterObject(value, filterKeys, excludeTranslations) : value; - }); - } - - return walkThroughObject(obj, (result: Record, value: any, key: string) => { - if (value === undefined || value === null) { - return; - } - if (filterKeys.includes(key)) { - return; - } - // if the key is an object, run it through the inner function - omitFromObject - const isObject = typeof value === 'object'; - if (excludeTranslations && isObject) { - if (typeof value.en === 'string' && typeof value.de === 'string') { - result[key] = value.en; - return; - } - } - result[key] = isObject ? filterObject(value, filterKeys, excludeTranslations) : value; - }); -} - -/** - * Function to generate a json-file for an object and trigger download it - */ -function generateFile( - /** The desired filename */ - fileName: string, - /** The objects which should be downloaded */ - obj: Record, - /** Options to filter/reduce the output */ - options: { - /** Whether the output should be beautified */ - beautify?: boolean; - /** Whether "system.repositories" should be excluded */ - excludeSystemRepositories?: boolean; - /** Whether translations should be reduced to only the english value */ - excludeTranslations?: boolean; - }, -): void { - const el = document.createElement('a'); - const filterKeys = []; - if (options.excludeSystemRepositories) { - filterKeys.push('system.repositories'); - } - const filteredObject = - filterKeys.length > 0 || options.excludeTranslations - ? filterObject(obj, filterKeys, options.excludeTranslations) - : obj; - const data = options.beautify ? JSON.stringify(filteredObject, null, 2) : JSON.stringify(filteredObject); - el.setAttribute('href', `data:application/json;charset=utf-8,${encodeURIComponent(data)}`); - el.setAttribute('download', fileName); - - el.style.display = 'none'; - document.body.appendChild(el); - - el.click(); - - document.body.removeChild(el); -} - -// d=data, t=target, s=start, e=end, m=middle -function binarySearch(list: string[], find: string, _start?: number, _end?: number): boolean { - _start = _start || 0; - if (_end === undefined) { - _end = list.length - 1; - if (!_end) { - return list[0] === find; - } - } - const middle = Math.floor((_start + _end) / 2); - if (find === list[middle]) { - return true; - } - if (_end - 1 === _start) { - return list[_start] === find || list[_end] === find; - } - if (find > list[middle]) { - return binarySearch(list, find, middle, _end); - } - if (find < list[middle]) { - return binarySearch(list, find, _start, middle); - } - return false; -} - -function getName(name: ioBroker.StringOrTranslated, lang: ioBroker.Languages): string { - if (typeof name === 'object') { - if (!name) { - return ''; - } - return (name[lang] || name.en || '').toString(); - } - - return name ? name.toString() : ''; -} - -export function getSelectIdIconFromObjects( - objects: Record, - id: string, - lang: ioBroker.Languages, - imagePrefix?: string, -): string | React.JSX.Element | null { - // `admin` has prefix '.' and `web` has '../..' - imagePrefix = imagePrefix || '.'; // http://localhost:8081'; - let src: string | React.JSX.Element | number | React.JSX.Element[] = ''; - const _id_ = `system.adapter.${id}`; - const aIcon = id && objects[_id_] && objects[_id_].common && objects[_id_].common.icon; - if (aIcon) { - // if not BASE64 - if (!aIcon.startsWith('data:image/')) { - if (aIcon.includes('.')) { - const name = objects[_id_].common.name; - if (typeof name === 'object') { - src = `${imagePrefix}/adapter/${name[lang] || name.en}/${aIcon}`; - } else { - src = `${imagePrefix}/adapter/${name}/${aIcon}`; - } - } else if (aIcon && aIcon.length < 3) { - return aIcon; // utf-8 - } else { - return null; // '' + objects[_id_].common.icon + ''; - } - } else if (aIcon.startsWith('data:image/svg')) { - const svgEl: any = ( - - ); - src = svgEl as React.JSX.Element; - } else { - src = aIcon; - } - } else { - const common = objects[id] && objects[id].common; - - if (common) { - const cIcon = common.icon; - if (cIcon) { - if (!cIcon.startsWith('data:image/')) { - if (cIcon.includes('.')) { - let instance; - if (objects[id].type === 'instance' || objects[id].type === 'adapter') { - if (typeof common.name === 'object') { - src = `${imagePrefix}/adapter/${common.name[lang] || common.name.en}/${cIcon}`; - } else { - src = `${imagePrefix}/adapter/${common.name}/${cIcon}`; - } - } else if (id && id.startsWith('system.adapter.')) { - instance = id.split('.', 3); - if (cIcon[0] === '/') { - instance[2] += cIcon; - } else { - instance[2] += `/${cIcon}`; - } - src = `${imagePrefix}/adapter/${instance[2]}`; - } else { - instance = id.split('.', 2); - if (cIcon[0] === '/') { - instance[0] += cIcon; - } else { - instance[0] += `/${cIcon}`; - } - src = `${imagePrefix}/adapter/${instance[0]}`; - } - } else if (aIcon && aIcon.length < 3) { - return aIcon; // utf-8 - } else { - return null; - } - } else if (cIcon.startsWith('data:image/svg')) { - // if base 64 image - const svgEl: any = ( - - ); - src = svgEl as React.JSX.Element; - } else { - src = cIcon; - } - } - } - } - - return src || null; -} - -function applyFilter( - item: TreeItem, - filters: { - id?: string; - name?: string; - type?: string; - custom?: string; - role?: string; - room?: string; - func?: string; - expertMode?: boolean; - }, - lang: ioBroker.Languages, - objects: Record, - context?: { - id?: string; - name?: string; - type?: string; - custom?: string; - role?: string; - room?: string[]; - func?: string[]; - }, - counter?: { count: number }, - customFilter?: ObjectBrowserCustomFilter, - selectedTypes?: string[], - _depth?: number, -): boolean { - _depth = _depth || 0; - let filteredOut = false; - if (!context) { - context = {}; - if (filters.id) { - context.id = filters.id.toLowerCase(); - } - if (filters.name) { - context.name = filters.name.toLowerCase(); - } - if (filters.type) { - context.type = filters.type.toLowerCase(); - } - if (filters.custom) { - context.custom = filters.custom.toLowerCase(); - } - if (filters.role) { - context.role = filters.role.toLowerCase(); - } - if (filters.room) { - context.room = (objects[filters.room] as ioBroker.EnumObject)?.common?.members || []; - } - if (filters.func) { - context.func = (objects[filters.func] as ioBroker.EnumObject)?.common?.members || []; - } - } - - const data = item.data; - - if (data && data.id) { - const common: ioBroker.StateCommon = data.obj?.common as ioBroker.StateCommon; - - if (customFilter) { - if (customFilter.type) { - if (typeof customFilter.type === 'string') { - if (!data.obj || customFilter.type !== data.obj.type) { - filteredOut = true; - } - } else if (Array.isArray(customFilter.type)) { - if (!data.obj || !customFilter.type.includes(data.obj.type)) { - filteredOut = true; - } - } - } - if (!filteredOut && customFilter.common?.type) { - if (!common?.type) { - filteredOut = true; - } else if (typeof customFilter.common.type === 'string') { - if (customFilter.common.type !== common.type) { - filteredOut = true; - } - } else if (Array.isArray(customFilter.common.type)) { - if (!customFilter.common.type.includes(common.type)) { - filteredOut = true; - } - } - } - if (!filteredOut && customFilter.common?.role) { - if (!common?.role) { - filteredOut = true; - } else if (typeof customFilter.common.role === 'string') { - if (common.role.startsWith(customFilter.common.role)) { - filteredOut = true; - } - } else if (Array.isArray(customFilter.common.role)) { - if (!customFilter.common.role.find(role => common.role.startsWith(role))) { - filteredOut = true; - } - } - } - - if (!filteredOut && customFilter.common?.custom === '_' && common?.custom) { - filteredOut = true; - } else if (!filteredOut && customFilter.common?.custom && customFilter.common?.custom !== '_') { - const filterOfCustom = customFilter.common.custom as string | string[] | boolean; - if (!common?.custom) { - filteredOut = true; - } else if (filterOfCustom === '_dataSources') { - // TODO: make it configurable - if ( - !Object.keys(common.custom).find( - id => id.startsWith('history.') || id.startsWith('sql.') || id.startsWith('influxdb.'), - ) - ) { - filteredOut = true; - } - } else if (Array.isArray(filterOfCustom)) { - // here are ['influxdb.', 'telegram.'] - const customs = Object.keys(common.custom); // here are ['influxdb.0', 'telegram.2'] - if (filterOfCustom.find(cst => customs.find(id => id.startsWith(cst)))) { - filteredOut = true; - } - } else if ( - filterOfCustom !== true && - !Object.keys(common.custom).find(id => id.startsWith(filterOfCustom as string)) - ) { - filteredOut = true; - } - } - } - - if (!filteredOut && !filters.expertMode) { - filteredOut = - data.id === 'system' || - data.id === 'enum' || - // (data.obj && data.obj.type === 'meta') || - data.id.startsWith('system.') || - data.id.startsWith('enum.') || - data.id.startsWith('_design/') || - data.id.endsWith('.admin') || - !!common?.expert; - } - if (!filteredOut && context.id) { - if (data.fID === undefined) { - data.fID = data.id.toLowerCase(); - } - filteredOut = !data.fID.includes(context.id); - } - if (!filteredOut && context.name) { - if (common) { - if (data.fName === undefined) { - data.fName = (common && getName(common.name, lang)) || ''; - data.fName = data.fName.toLowerCase(); - } - filteredOut = !data.fName.includes(context.name); - } else { - filteredOut = true; - } - } - if (!filteredOut && filters.role && common) { - if (common) { - filteredOut = !(common.role && common.role.startsWith(context.role)); - } else { - filteredOut = true; - } - } - if (!filteredOut && context.room) { - filteredOut = !context.room.find(id => id === data.id || data.id.startsWith(`${id}.`)); - } - if (!filteredOut && context.func) { - filteredOut = !context.func.find(id => id === data.id || data.id.startsWith(`${id}.`)); - } - if (!filteredOut && context.type) { - filteredOut = !(data.obj && data.obj.type && data.obj.type === context.type); - } - if (!filteredOut && selectedTypes) { - filteredOut = !(data.obj && data.obj.type && selectedTypes.includes(data.obj.type)); - } - if (!filteredOut && context.custom) { - if (common) { - if (context.custom === '_') { - filteredOut = !!common.custom; - } else { - filteredOut = !common.custom || !common.custom[context.custom]; - } - } else { - filteredOut = true; - } - } - } - - data.visible = !filteredOut; - - data.hasVisibleChildren = false; - if (item.children && _depth < 20) { - item.children.forEach(_item => { - const visible = applyFilter( - _item, - filters, - lang, - objects, - context, - counter, - customFilter, - selectedTypes, - _depth + 1, - ); - if (visible) { - data.hasVisibleChildren = true; - } - }); - } - - // const visible = data.visible || data.hasVisibleChildren; - data.sumVisibility = data.visible || data.hasVisibleChildren; // || data.hasVisibleParent; - if (counter && data.sumVisibility) { - counter.count++; - } - - // show all children of visible object with opacity 0.5 - if (data.id && data.sumVisibility && item.children) { - item.children.forEach(_item => (_item.data.hasVisibleParent = true)); - } - - return data.visible || data.hasVisibleChildren; -} - -function getVisibleItems( - item: TreeItem, - type: ioBroker.ObjectType, - objects: Record, - _result?: string[], -): string[] { - _result = _result || []; - const data = item.data; - if (data.sumVisibility) { - if (data.id && objects[data.id] && (!type || objects[data.id].type === type)) { - _result.push(data.id); - } - item.children?.forEach(_item => getVisibleItems(_item, type, objects, _result)); - } - - return _result; -} - -function getSystemIcon( - objects: Record, - id: string, - level: number, - themeType: ThemeType, - lang: ioBroker.Languages, - imagePrefix?: string, -): string | JSX.Element | null { - let icon: string | JSX.Element | null | undefined; - - // system or design has special icons - if (id === 'alias' || id === 'alias.0') { - icon = ( - - ); - } else if (id === '0_userdata' || id === '0_userdata.0') { - icon = ( - - ); - } else if (id.startsWith('_design/') || id === 'system') { - icon = ( - - ); - } else if (id === 'system.adapter') { - icon = ( - - ); - } else if (id === 'system.group') { - icon = ; - } else if (id === 'system.user') { - icon = ; - } else if (id === 'system.host') { - icon = ; - } else if (id.endsWith('.connection') || id.endsWith('.connected')) { - icon = ; - } else if (id.endsWith('.info')) { - icon = ; - } else if (objects[id] && objects[id].type === 'meta') { - icon = ; - } else if (level < 2) { - // detect "cloud.0" - if (objects[`system.adapter.${id}`]) { - icon = getSelectIdIconFromObjects(objects, `system.adapter.${id}`, lang, imagePrefix); - } - } - - return icon || null; -} - -function getObjectTooltip(data: TreeItemData, lang: ioBroker.Languages): string | null { - if (data?.obj?.common?.desc) { - return getName(data.obj.common.desc, lang) || null; - } - - return null; -} - -function getIdFieldTooltip(data: TreeItemData, lang: ioBroker.Languages): JSX.Element { - const tooltip = getObjectTooltip(data, lang); - if (tooltip?.startsWith('http')) { - return ( - - {tooltip} - - ); - } - return {tooltip || data.id || ''}; -} - -function buildTree( - objects: Record, - options: { - imagePrefix?: string; - root?: string; - lang: ioBroker.Languages; - themeType: ThemeType; - }, -): { root: TreeItem; info: TreeInfo } { - const imagePrefix = options.imagePrefix || '.'; - - let ids = Object.keys(objects); - - ids.sort((a, b) => { - if (a === b) { - return 0; - } - a = a.replace(/\./g, '!!!'); - b = b.replace(/\./g, '!!!'); - if (a > b) { - return 1; - } - return -1; - }); - - if (options.root) { - ids = ids.filter(id => id === options.root || id.startsWith(`${options.root}.`)); - } - - // find empty nodes and create names for it - let currentPathArr: string[] = []; - let currentPath = ''; - let currentPathLen = 0; - const root: TreeItem = { - data: { - name: '', - id: '', - }, - children: [], - }; - - const info: TreeInfo = { - funcEnums: [], - roomEnums: [], - roles: [], - ids: [], - types: [], - objects, - customs: ['_'], - enums: [], - hasSomeCustoms: false, - aliasesMap: {}, - }; - - let cRoot: TreeItem = root; - - for (let i = 0; i < ids.length; i++) { - const id = ids[i]; - if (!id) { - continue; - } - const obj = objects[id]; - const parts = id.split('.'); - - if (obj.type && !info.types.includes(obj.type)) { - info.types.push(obj.type); - } - - if (obj) { - const common = obj.common; - const role = common && common.role; - if (role && !info.roles.includes(role)) { - info.roles.push(role); - } else if (id.startsWith('enum.rooms.')) { - info.roomEnums.push(id); - info.enums.push(id); - } else if (id.startsWith('enum.functions.')) { - info.funcEnums.push(id); - info.enums.push(id); - } else if (obj.type === 'enum') { - info.enums.push(id); - } else if (obj.type === 'instance' && common && (common.supportCustoms || common.adminUI?.custom)) { - info.hasSomeCustoms = true; - info.customs.push(id.substring('system.adapter.'.length)); - } - - // Build a map of aliases - if (id.startsWith('alias.') && obj.common.alias?.id) { - if (typeof obj.common.alias.id === 'string') { - const usedId = obj.common.alias.id; - if (!info.aliasesMap[usedId]) { - info.aliasesMap[usedId] = [id]; - } else if (!info.aliasesMap[usedId].includes(id)) { - info.aliasesMap[usedId].push(id); - } - } else { - const readId = obj.common.alias.id.read; - if (readId) { - if (!info.aliasesMap[readId]) { - info.aliasesMap[readId] = [id]; - } else if (!info.aliasesMap[readId].includes(id)) { - info.aliasesMap[readId].push(id); - } - } - const writeId = obj.common.alias.id.write; - if (writeId) { - if (!info.aliasesMap[writeId]) { - info.aliasesMap[writeId] = [id]; - } else if (!info.aliasesMap[writeId].includes(id)) { - info.aliasesMap[writeId].push(id); - } - } - } - } - } - - info.ids.push(id); - - let repeat; - - // if next level - do { - repeat = false; - - // If the current level is still OK, and we can add ID to children - if (!currentPath || id.startsWith(`${currentPath}.`)) { - // if more than one level added - if (parts.length - currentPathLen > 1) { - let curPath = currentPath; - // generate missing levels - for (let k = currentPathLen; k < parts.length - 1; k++) { - curPath += (curPath ? '.' : '') + parts[k]; - // level does not exist - if (!binarySearch(info.ids, curPath)) { - const _cRoot: TreeItem = { - data: { - name: parts[k], - parent: cRoot, - id: curPath, - obj: objects[curPath], - level: k, - icon: getSystemIcon( - objects, - curPath, - k, - options.themeType, - options.lang, - imagePrefix, - ), - generated: true, - }, - }; - - cRoot.children = cRoot.children || []; - cRoot.children.push(_cRoot); - cRoot = _cRoot; - info.ids.push(curPath); // IDs will be added by alphabet - } else if (cRoot.children) { - cRoot = cRoot.children.find(item => item.data.name === parts[k]); - } - } - } - - const _cRoot: TreeItem = { - data: { - name: parts[parts.length - 1], - title: getName(obj?.common?.name, options.lang), - obj, - parent: cRoot, - icon: - getSelectIdIconFromObjects(objects, id, options.lang, imagePrefix) || - getSystemIcon(objects, id, 0, options.themeType, options.lang, imagePrefix), - id, - hasCustoms: !!(obj.common?.custom && Object.keys(obj.common.custom).length), - level: parts.length - 1, - generated: false, - button: - obj.type === 'state' && - !!obj.common?.role && - typeof obj.common.role === 'string' && - obj.common.role.startsWith('button') && - obj.common?.write !== false, - switch: - obj.type === 'state' && - obj.common?.type === 'boolean' && - obj.common?.write !== false && - obj.common?.read !== false, - }, - }; - - cRoot.children = cRoot.children || []; - cRoot.children.push(_cRoot); - cRoot = _cRoot; - - currentPathLen = parts.length; - currentPathArr = parts; - currentPath = id; - } else { - let u = 0; - - while (currentPathArr[u] === parts[u]) { - u++; - } - - if (u > 0) { - let move = currentPathArr.length; - currentPathArr = currentPathArr.splice(0, u); - currentPathLen = u; - currentPath = currentPathArr.join('.'); - while (move > u) { - if (cRoot.data.parent) { - cRoot = cRoot.data.parent; - } else { - console.error(`Parent is null for ${id} ${currentPath} ${currentPathArr.join('.')}`); - } - move--; - } - } else { - cRoot = root; - currentPathArr = []; - currentPath = ''; - currentPathLen = 0; - } - repeat = true; - } - } while (repeat); - } - - info.roomEnums.sort((a, b) => { - const aName: string = getName(objects[a]?.common?.name, options.lang) || a.split('.').pop(); - const bName: string = getName(objects[b]?.common?.name, options.lang) || b.split('.').pop(); - if (aName > bName) { - return 1; - } - if (aName < bName) { - return -1; - } - return 0; - }); - info.funcEnums.sort((a, b) => { - const aName: string = getName(objects[a]?.common?.name, options.lang) || a.split('.').pop(); - const bName: string = getName(objects[b]?.common?.name, options.lang) || b.split('.').pop(); - if (aName > bName) { - return 1; - } - if (aName < bName) { - return -1; - } - return 0; - }); - info.roles.sort(); - info.types.sort(); - - return { info, root }; -} - -function findNode(root: TreeItem, id: string, _parts?: string[], _path?: string, _level?: number): TreeItem | null { - if (root.data.id === id) { - return root; - } - if (!_parts) { - _parts = id.split('.'); - _level = 0; - _path = _parts[_level]; - } - if (!root.children && root.data.id !== id) { - return null; - } - let found; - if (root.children) { - for (let i = 0; i < root.children.length; i++) { - const _id = root.children[i].data.id; - if (_id === _path) { - found = root.children[i]; - break; - } else if (_id > _path) { - break; - } - } - } - if (found) { - _level = _level || 0; - return findNode(found, id, _parts, `${_path}.${_parts[_level + 1]}`, _level + 1); - } - - return null; -} - -function findRoomsForObject( - info: TreeInfo, - id: string, - lang: ioBroker.Languages, - rooms?: string[], -): { rooms: string[]; per: boolean } { - if (!id) { - return { rooms: [], per: false }; - } - rooms = rooms || []; - for (const room of info.roomEnums) { - const common = info.objects[room]?.common; - - if (!common) { - continue; - } - - const name = getName(common.name, lang); - - if (common.members?.includes(id) && !rooms.includes(name)) { - rooms.push(name); - } - } - - let ownEnums; - - // Check parent - const parts = id.split('.'); - parts.pop(); - id = parts.join('.'); - if (info.objects[id]) { - ownEnums = rooms.length; - findRoomsForObject(info, id, lang, rooms); - } - - return { rooms, per: !ownEnums }; // per is if the enums are from parent -} - -function findEnumsForObjectAsIds( - info: TreeInfo, - id: string, - enumName: 'roomEnums' | 'funcEnums', - funcs?: string[], -): string[] { - if (!id) { - return []; - } - funcs = funcs || []; - for (let i = 0; i < info[enumName].length; i++) { - const common = info.objects[info[enumName][i]]?.common; - if (common?.members?.includes(id) && !funcs.includes(info[enumName][i])) { - funcs.push(info[enumName][i]); - } - } - funcs.sort(); - - return funcs; -} - -function findFunctionsForObject( - info: TreeInfo, - id: string, - lang: ioBroker.Languages, - funcs?: string[], -): { funcs: string[]; pef: boolean } { - if (!id) { - return { funcs: [], pef: false }; - } - funcs = funcs || []; - for (let i = 0; i < info.funcEnums.length; i++) { - const common = info.objects[info.funcEnums[i]]?.common; - - if (!common) { - continue; - } - - const name = getName(common.name, lang); - if (common.members?.includes(id) && !funcs.includes(name)) { - funcs.push(name); - } - } - - let ownEnums; - - // Check parent - const parts = id.split('.'); - parts.pop(); - id = parts.join('.'); - if (info.objects[id]) { - ownEnums = funcs.length; - findFunctionsForObject(info, id, lang, funcs); - } - - return { funcs, pef: !ownEnums }; -} - -/* -function quality2text(q) { - if (!q) { - return 'ok'; - } - const custom = q & 0xFFFF0000; - let text = ''; - if (q & 0x40) text += 'device'; - if (q & 0x80) text += 'sensor'; - if (q & 0x01) text += ' bad'; - if (q & 0x02) text += ' not connected'; - if (q & 0x04) text += ' error'; - - return text + (custom ? '|0x' + (custom >> 16).toString(16).toUpperCase() : '') + ' [0x' + q.toString(16).toUpperCase() + ']'; -} -*/ - -/** - * Format a state value for visualization - */ -function formatValue(options: FormatValueOptions): { - valText: { - /** value as string */ - v: string; - /** value unit */ - u?: string; - /** value not replaced by `common.states` */ - s?: string; - }; - valFull: - | { - /** label */ - t: string; - /** value */ - v: string; - /** no break */ - nbr?: boolean; - }[] - | undefined; - fileViewer: 'image' | 'text' | 'json' | 'html' | 'pdf' | 'audio' | 'video' | undefined; -} { - const { dateFormat, state, isFloatComma, texts, obj } = options; - const states = Utils.getStates(obj); - const isCommon = obj.common; - let fileViewer: 'image' | 'text' | 'json' | 'html' | 'pdf' | 'audio' | 'video' | undefined; - - let v: any = - // @ts-expect-error deprecated from js-controller 6 - isCommon?.type === 'file' - ? '[file]' - : !state || state.val === null - ? '(null)' - : state.val === undefined - ? '[undef]' - : state.val; - - const type = typeof v; - - if (isCommon?.role && typeof isCommon.role === 'string' && isCommon.role.match(/^value\.time|^date/)) { - if (v && typeof v === 'string') { - if (Utils.isStringInteger(v)) { - // we assume a unix ts - v = new Date(parseInt(v, 10)).toString(); - } else { - // check if parsable by new date - try { - const parsedDate = new Date(v); - - if (Utils.isValidDate(parsedDate)) { - v = parsedDate.toString(); - } - } catch { - // ignore - } - } - } else { - if (v > 946681200 && v < 946681200000) { - // '2000-01-01T00:00:00' => 946681200000 - v *= 1_000; // maybe the time is in seconds (UNIX time) - } - // "null" and undefined could not be here. See `let v = (isCommon && isCommon.type === 'file') ....` above - v = v ? new Date(v).toString() : v; - } - } else { - if (type === 'number') { - if (!Number.isInteger(v)) { - v = Math.round(v * 100_000_000) / 100_000_000; // remove 4.00000000000000001 - if (isFloatComma) { - v = v.toString().replace('.', ','); - } - } - } else if (type === 'object') { - v = JSON.stringify(v); - } else if (type !== 'string') { - v = v.toString(); - } else if (v.startsWith('data:image/')) { - fileViewer = 'image'; - } - - if (typeof v !== 'string') { - v = v.toString(); - } - } - - const valText: { - /** value as string */ - v: string; - /** value unit */ - u?: string; - /** value not replaced by `common.states` */ - s?: string; - } = { v: v as string }; - - // try to replace number with "common.states" - if (states && states[v] !== undefined) { - if (v !== states[v]) { - valText.s = v; - v = states[v]; - valText.v = v; - } - } - - if (isCommon?.unit) { - valText.u = isCommon.unit; - } - let valFull: - | { - /** label */ - t: string; - /** value */ - v: string; - nbr?: boolean; - }[] - | undefined; - if (options.full) { - valFull = [{ t: texts.value, v }]; - - if (state) { - if (state.ack !== undefined && state.ack !== null) { - valFull.push({ t: texts.ack, v: state.ack.toString() }); - } - if (state.ts) { - valFull.push({ t: texts.ts, v: state.ts ? Utils.formatDate(new Date(state.ts), dateFormat) : '' }); - } - if (state.lc) { - valFull.push({ t: texts.lc, v: state.lc ? Utils.formatDate(new Date(state.lc), dateFormat) : '' }); - } - if (state.from) { - let from = state.from.toString(); - if (from.startsWith('system.adapter.')) { - from = from.substring(15); - } - valFull.push({ t: texts.from, v: from }); - } - if (state.user) { - let user = state.user.toString(); - if (user.startsWith('system.user.')) { - user = user.substring(12); - } - valFull.push({ t: texts.user, v: user }); - } - if (state.c) { - valFull.push({ t: texts.c, v: state.c }); - } - valFull.push({ t: texts.quality, v: Utils.quality2text(state.q || 0).join(', '), nbr: true }); - } - } - - return { - valText, - valFull, - fileViewer, - }; -} - -/** - * Get CSS style for given state value - */ -function getValueStyle(options: GetValueStyleOptions): { color: string } { - const { state /* , isExpertMode, isButton */ } = options; - const color = state?.ack ? (state.q ? '#ffa500' : '') : '#ff2222c9'; - - // do not show the color of the button in non-expert mode - // if (!isExpertMode && isButton) { - // color = ''; - // } - - return { color }; -} - -function prepareSparkData(values: ioBroker.GetHistoryResult, from: number): number[] { - // set one point every hour - let time = from; - let i = 1; - const v = []; - - while (i < values.length && time < from + 25 * 3600000) { - // find the interval - while (values[i - 1].ts < time && time <= values[i].ts && i < values.length) { - i++; - } - if (i === 1 && values[i - 1].ts >= time) { - // assume the value was always null - v.push(0); - } else if (i < values.length) { - if (typeof values[i].val === 'boolean' || typeof values[i - 1].val === 'boolean') { - v.push(values[i].val ? 1 : 0); - } else { - // remove nulls - values[i - 1].val = values[i - 1].val || 0; - values[i].val = values[i].val || 0; - // interpolate - const nm1: number = values[i - 1].val as number; - const n: number = values[i].val as number; - const val = nm1 + ((n - nm1) * (time - values[i - 1].ts)) / (values[i].ts - values[i - 1].ts); - - v.push(val); - } - } - - time += 3600000; - } - - return v; -} - -export const ITEM_IMAGES: Record = { - state: ( - - ), - channel: ( - - ), - device: ( - - ), - adapter: ( - - ), - meta: ( - - ), - instance: ( - - ), - enum: ( - - ), - chart: ( - - ), - config: ( - - ), - group: ( - - ), - user: ( - - ), - host: ( - - ), - schedule: ( - - ), - script: ( - - ), - folder: ( - - ), -}; - -interface ScreenWidthOne { - idWidth: string | number; - widths: { - room?: number; - val?: number; - name?: number; - func?: number; - buttons?: number; - type?: number; - role?: number; - changedFrom?: number; - qualityCode?: number; - timestamp?: number; - lastChange?: number; - }; - fields: string[]; -} - -interface ScreenWidth { - xs: ScreenWidthOne; - sm: ScreenWidthOne; - md: ScreenWidthOne; - lg: ScreenWidthOne; - xl: ScreenWidthOne; -} - -const SCREEN_WIDTHS: ScreenWidth = { - // extra-small: 0px - xs: { idWidth: '100%', fields: [], widths: {} }, - // small: 600px - sm: { idWidth: 300, fields: ['room', 'val'], widths: { room: 100, val: 200 } }, - // medium: 960px - md: { - idWidth: 300, - fields: ['room', 'func', 'val', 'buttons'], - widths: { - name: 200, - room: 150, - func: 150, - val: 120, - buttons: 120, - }, - }, - // large: 1280px - lg: { - idWidth: 300, - fields: [ - 'name', - 'type', - 'role', - 'room', - 'func', - 'val', - 'buttons', - 'changedFrom', - 'qualityCode', - 'timestamp', - 'lastChange', - ], - widths: { - name: 300, - type: 80, - role: 120, - room: 180, - func: 180, - val: 140, - buttons: 120, - changedFrom: 120, - qualityCode: 100, - timestamp: 165, - lastChange: 165, - }, - }, - // ///////////// - // extra-large: 1920px - xl: { - idWidth: 550, - fields: [ - 'name', - 'type', - 'role', - 'room', - 'func', - 'val', - 'buttons', - 'changedFrom', - 'qualityCode', - 'timestamp', - 'lastChange', - ], - widths: { - name: 400, - type: 80, - role: 120, - room: 180, - func: 180, - val: 140, - buttons: 120, - changedFrom: 120, - qualityCode: 100, - timestamp: 170, - lastChange: 170, - }, - }, -}; - -let objectsAlreadyLoaded = false; - -export interface ObjectBrowserFilter { - id?: string; - name?: string; - room?: string; - func?: string; - role?: string; - type?: string; - custom?: string; - expertMode?: boolean; -} - -const DEFAULT_FILTER: ObjectBrowserFilter = { - id: '', - name: '', - room: '', - func: '', - role: '', - type: '', - custom: '', - expertMode: false, -}; - -interface AdapterColumn { - adapter: string; - id: string; - name: string; - path: string[]; - pathText: string; - edit?: boolean; - type?: 'boolean' | 'string' | 'number'; - objTypes?: ioBroker.ObjectType[]; - align?: 'center' | 'left' | 'right'; -} - -interface ObjectBrowserEditRoleProps { - roles: string[]; - id: string; - socket: Connection; - onClose: (obj?: ioBroker.Object | null) => void; - t: Translate; -} - -interface ObjectViewFileDialogProps { - t: Translate; - socket: Connection; - obj: ioBroker.AnyObject; - onClose: () => void; -} - -interface DragWrapperProps { - item: TreeItem; - className?: string; - style?: React.CSSProperties; - children: JSX.Element | null; -} - -interface ObjectCustomDialogProps { - t: Translate; - lang: ioBroker.Languages; - expertMode?: boolean; - objects: Record; - socket: Connection; - theme: IobTheme; - themeName: ThemeName; - themeType: ThemeType; - customsInstances: string[]; - objectIDs: string[]; - onClose: () => void; - reportChangedIds: (ids: string[]) => void; - isFloatComma: boolean; - allVisibleObjects: boolean; - systemConfig: ioBroker.SystemConfigObject; -} - -interface ObjectBrowserValueProps { - /** State type */ - type: 'states' | 'string' | 'number' | 'boolean' | 'json'; - /** State role */ - role: string; - /** common.states */ - states: Record | null; - /** The state value */ - value: string | number | boolean | null; - /** If expert mode is enabled */ - expertMode: boolean; - onClose: (newValue?: { - val: ioBroker.StateValue; - ack: boolean; - q: ioBroker.STATE_QUALITY[keyof ioBroker.STATE_QUALITY]; - expire: number | undefined; - }) => void; - /** Configured theme */ - themeType: ThemeType; - theme: IobTheme; - socket: Connection; - defaultHistory: string; - dateFormat: string; - object: ioBroker.StateObject; - isFloatComma: boolean; - t: Translate; - lang: ioBroker.Languages; - width?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; -} - -interface ObjectBrowserEditObjectProps { - socket: Connection; - obj: ioBroker.AnyObject; - roleArray: string[]; - expertMode: boolean; - themeType: ThemeType; - theme: IobTheme; - aliasTab: boolean; - onClose: (obj?: ioBroker.AnyObject) => void; - dialogName?: string; - objects: Record; - dateFormat: string; - isFloatComma: boolean; - onNewObject: (obj: ioBroker.AnyObject) => void; - t: Translate; - width?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; -} - -interface ObjectAliasEditorProps { - t: Translate; - socket: Connection; - objects: Record; - onRedirect: (id: string, delay?: number) => void; - obj: ioBroker.AnyObject; - onClose: () => void; -} - -interface ObjectBrowserProps { - /** where to store settings in localStorage */ - dialogName?: string; - defaultFilters?: ObjectBrowserFilter; - selected?: string | string[]; - onSelect?: (selected: string | string[], name: string, isDouble?: boolean) => void; - onFilterChanged?: (newFilter: ObjectBrowserFilter) => void; - socket: Connection; - showExpertButton?: boolean; - expertMode?: boolean; - imagePrefix?: string; - themeName: ThemeName; - themeType: ThemeType; - /** will be filled by withWidth */ - width?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; - theme: IobTheme; - t: Translate; - lang: ioBroker.Languages; - multiSelect?: boolean; - notEditable?: boolean; - foldersFirst?: boolean; - disableColumnSelector?: boolean; - isFloatComma?: boolean; - dateFormat?: string; - levelPadding?: number; - - // components - objectCustomDialog?: React.FC; - objectAddBoolean?: boolean; // optional toolbar button - objectEditBoolean?: boolean; // optional toolbar button - objectStatesView?: boolean; // optional toolbar button - objectImportExport?: boolean; // optional toolbar button - objectEditOfAccessControl?: boolean; // Access Control - /** modal add object */ - - modalNewObject?: (oBrowser: ObjectBrowserClass) => JSX.Element; - /** modal Edit Of Access Control */ - - modalEditOfAccessControl: (oBrowser: ObjectBrowserClass, data: TreeItemData) => JSX.Element; - onObjectDelete?: (id: string, hasChildren: boolean, objectExists: boolean, childrenCount: number) => void; - /** - * Optional filter - * `{common: {custom: true}}` - show only objects with some custom settings - * `{common: {custom: 'sql.0'}}` - show only objects with sql.0 custom settings (only of the specific instance) - * `{common: {custom: '_dataSources'}}` - show only objects of adapters `influxdb' or 'sql' or 'history' - * `{common: {custom: 'adapterName.'}}` - show only objects of custom settings of specific adapter (all instances) - * `{type: 'channel'}` - show only channels - * `{type: ['channel', 'device']}` - show only channels and devices - * `{common: {type: 'number'}` - show only states of type 'number - * `{common: {type: ['number', 'string']}` - show only states of type 'number and string - * `{common: {role: ['switch']}` - show only states with roles starting from switch - * `{common: {role: ['switch', 'button']}` - show only states with roles starting from `switch` and `button` - */ - customFilter: ObjectBrowserCustomFilter; - objectBrowserValue?: React.FC; - objectBrowserEditObject?: React.FC; - /** on edit alias */ - objectBrowserAliasEditor?: React.FC; - /** on Edit role */ - objectBrowserEditRole?: React.FC; - /** on view file state */ - objectBrowserViewFile?: React.FC; - router?: typeof Router; - types?: ioBroker.ObjectType[]; - /** Possible columns: ['name', 'type', 'role', 'room', 'func', 'val', 'buttons'] */ - columns?: string[]; - /** Shows only elements of this root */ - root?: string; - - /** cache of objects */ - objectsWorker?: ObjectsWorker; - /** - * function to filter out all unnecessary objects. It cannot be used together with "types" - * Example for function: `obj => obj.common?.type === 'boolean'` to show only boolean states - */ - filterFunc?: (obj: ioBroker.Object) => boolean; - /** Used for enums dragging */ - DragWrapper?: React.FC; - /** let DragWrapper know about objects to get the icons */ - setObjectsReference?: (objects: Record) => void; - dragEnabled?: boolean; -} - -interface ObjectBrowserState { - loaded: boolean; - foldersFirst: boolean; - selected: string[]; - focused: string; - selectedNonObject: string; - filter: ObjectBrowserFilter; - filterKey: number; - depth: number; - expandAllVisible: boolean; - expanded: string[]; - toast: string; - scrollBarWidth: number; - customDialog: null | string[]; - customDialogAll?: boolean; - editObjectDialog: string; - editObjectAlias: boolean; // open the edit object dialog on alias tab - viewFileDialog: string; - showAliasEditor: string; - enumDialog: null | { - item: TreeItem; - type: 'room' | 'func'; - enumsOriginal: string; - }; - enumDialogEnums?: null | string[]; - roleDialog: null | string; - statesView: boolean; - /** ['name', 'type', 'role', 'room', 'func', 'val', 'buttons'] */ - columns: string[] | null; - columnsForAdmin: Record | null; - columnsSelectorShow: boolean; - columnsAuto: boolean; - columnsWidths: Record; - columnsDialogTransparent: number; - columnsEditCustomDialog: null | { - obj: ioBroker.Object; - item: TreeItem; - it: AdapterColumn; - }; - customColumnDialogValueChanged: boolean; - showExportDialog: false | number; - showAllExportOptions: boolean; - linesEnabled: boolean; - showDescription: boolean; - showContextMenu: { - item: TreeItem; - position: { left: number; top: number }; - subItem?: string; - subAnchor?: HTMLLIElement; - } | null; - noStatesByExportImport: boolean; - beautifyJsonExport: boolean; - excludeSystemRepositoriesFromExport: boolean; - excludeTranslations: boolean; - updating?: boolean; - modalNewObj?: null | { id: string; initialType?: ioBroker.ObjectType; initialStateType?: ioBroker.CommonType }; - error?: any; - modalEditOfAccess?: boolean; - modalEditOfAccessObjData?: TreeItemData; - updateOpened?: boolean; - tooltipInfo: null | { el: JSX.Element[]; id: string }; - /** Show the menu with aliases for state */ - aliasMenu: string; -} - -export class ObjectBrowserClass extends Component { - // do not define the type as null to save the performance, so we must check it every time - private info: TreeInfo = { - funcEnums: [], - roomEnums: [], - roles: [], - ids: [], - types: [], - objects: {}, - customs: [], - enums: [], - hasSomeCustoms: false, - aliasesMap: {}, - }; - - private localStorage: Storage = ((window as any)._localStorage as Storage) || window.localStorage; - - private lastAppliedFilter: string | null = null; - - private readonly tableRef: React.RefObject; - - private readonly filterRefs: Record>; - - private pausedSubscribes: boolean = false; - - private selectFirst: string; - - private root: TreeItem | null = null; - - private readonly states: Record = {}; - - private subscribes: string[] = []; - - private unsubscribeTimer: ReturnType | null = null; - - private statesUpdateTimer: ReturnType | null = null; - - private objectsUpdateTimer: ReturnType | null = null; - - private filterTimer: ReturnType | null = null; - - private readonly visibleCols: string[]; - - private readonly texts: Record; - - private readonly possibleCols: string[]; - - private readonly imagePrefix: string; - - private adapterColumns: AdapterColumn[] = []; - - private styleTheme: string = ''; - - private edit: { - val: string | number | boolean | null; - q: number; - ack: boolean; - id: string; - } = { - id: '', - val: '', - q: 0, - ack: false, - }; - - private readonly levelPadding: number; - - private customWidth: boolean = false; - - private resizeTimeout: ReturnType | null = null; - - private resizerNextName: string | null = null; - - private resizerActiveName: string | null = null; - - private resizerCurrentWidths: Record = {}; - - private resizeLeft: boolean = false; - - private resizerOldWidth: number = 0; - - private resizerMin: number = 0; - - private resizerNextMin: number = 0; - - private resizerOldWidthNext: number = 0; - - private resizerPosition: number = 0; - - private resizerActiveDiv: HTMLDivElement | null = null; - - private resizerNextDiv: HTMLDivElement | null = null; - - private storedWidths: ScreenWidthOne | null = null; - - private systemConfig: ioBroker.SystemConfigObject; - - public objects: Record; - - private defaultHistory: string = ''; - - private columnsVisibility: { - id?: number | string; - name?: number | string; - nameHeader?: number | string; - type?: number; - role?: number; - room?: number; - func?: number; - changedFrom?: number; - qualityCode?: number; - timestamp?: number; - lastChange?: number; - val?: number; - buttons?: number; - } = {}; - - private changedIds: null | string[] = null; - - private contextMenu: null | { item: any; ts: number } = null; - - private recordStates: string[] = []; - - private styles: { - cellIdIconFolder?: React.CSSProperties; - cellIdIconDocument?: React.CSSProperties; - iconDeviceError?: React.CSSProperties; - iconDeviceConnected?: React.CSSProperties; - iconDeviceDisconnected?: React.CSSProperties; - cellButtonsButtonWithCustoms?: React.CSSProperties; - invertedBackground?: React.CSSProperties; - invertedBackgroundFlex?: React.CSSProperties; - contextMenuEdit?: React.CSSProperties; - contextMenuEditValue?: React.CSSProperties; - contextMenuView?: React.CSSProperties; - contextMenuCustom?: React.CSSProperties; - contextMenuACL?: React.CSSProperties; - contextMenuRoom?: React.CSSProperties; - contextMenuRole?: React.CSSProperties; - contextMenuDelete?: React.CSSProperties; - filterInput?: React.CSSProperties; - iconCopy?: React.CSSProperties; - aliasReadWrite?: React.CSSProperties; - aliasAlone?: React.CSSProperties; - } = {}; - - private customColumnDialog: null | { - value: boolean | number | string; - type: 'boolean' | 'number' | 'string'; - initValue: boolean | number | string; - } = null; - - /** Namespaces which are allowed to be edited by non-expert users */ - static #NON_EXPERT_NAMESPACES = ['0_userdata.0.', 'alias.0.']; - - constructor(props: ObjectBrowserProps) { - super(props); - - const lastSelectedItemStr: string = - this.localStorage.getItem(`${props.dialogName || 'App'}.objectSelected`) || ''; - - this.selectFirst = ''; - - if (lastSelectedItemStr.startsWith('[')) { - try { - const lastSelectedItems = JSON.parse(lastSelectedItemStr) as string[]; - this.selectFirst = lastSelectedItems[0] || ''; - } catch { - // ignore - } - } else { - this.selectFirst = lastSelectedItemStr; - } - - let expanded: string[]; - const expandedStr = this.localStorage.getItem(`${props.dialogName || 'App'}.objectExpanded`) || '[]'; - try { - expanded = JSON.parse(expandedStr); - } catch { - expanded = []; - } - - let filter: ObjectBrowserFilter; - const filterStr: string = props.defaultFilters - ? '' - : this.localStorage.getItem(`${props.dialogName || 'App'}.objectFilter`) || ''; - if (filterStr) { - try { - filter = JSON.parse(filterStr); - } catch { - filter = { ...DEFAULT_FILTER }; - } - } else if (props.defaultFilters && typeof props.defaultFilters === 'object') { - filter = { ...props.defaultFilters }; - } else { - filter = { ...DEFAULT_FILTER }; - } - - filter.expertMode = - props.expertMode !== undefined - ? props.expertMode - : (((window as any)._sessionStorage as Storage) || window.sessionStorage).getItem('App.expertMode') === - 'true'; - this.tableRef = createRef(); - this.filterRefs = {}; - - Object.keys(DEFAULT_FILTER).forEach(name => (this.filterRefs[name] = createRef())); - - this.visibleCols = props.columns || SCREEN_WIDTHS[props.width || 'lg'].fields; - // remove type column if only one type must be selected - if (props.types && props.types.length === 1) { - const pos = this.visibleCols.indexOf('type'); - if (pos !== -1) { - this.visibleCols.splice(pos, 1); - } - } - - this.possibleCols = SCREEN_WIDTHS.xl.fields; - - let customDialog = null; - - if (props.router) { - const location = props.router.getLocation(); - if (location.id && location.dialog === 'customs') { - customDialog = [location.id]; - this.pauseSubscribe(true); - } - } - - let selected: string[]; - if (!Array.isArray(props.selected)) { - selected = [props.selected || '']; - } else { - selected = props.selected; - } - selected = selected.map(id => id.replace(/["']/g, '')).filter(id => id); - - this.selectFirst = selected.length && selected[0] ? selected[0] : this.selectFirst; - - const columnsStr = this.localStorage.getItem(`${props.dialogName || 'App'}.columns`); - let columns: string[] | null; - try { - columns = columnsStr ? JSON.parse(columnsStr) : null; - } catch { - columns = null; - } - - let columnsWidths = null; // this.localStorage.getItem(`${props.dialogName || 'App'}.columnsWidths`); - try { - columnsWidths = columnsWidths ? JSON.parse(columnsWidths) : {}; - } catch { - columnsWidths = {}; - } - - this.imagePrefix = props.imagePrefix || '.'; - let foldersFirst: boolean; - const foldersFirstStr = this.localStorage.getItem(`${props.dialogName || 'App'}.foldersFirst`); - - if (foldersFirstStr === 'false') { - foldersFirst = false; - } else if (foldersFirstStr === 'true') { - foldersFirst = true; - } else { - foldersFirst = props.foldersFirst === undefined ? true : props.foldersFirst; - } - - let statesView = false; - try { - statesView = this.props.objectStatesView - ? JSON.parse(this.localStorage.getItem(`${props.dialogName || 'App'}.objectStatesView`) || '') || false - : false; - } catch { - // ignore - } - - this.state = { - loaded: false, - foldersFirst, - selected, - selectedNonObject: this.localStorage.getItem(`${props.dialogName || 'App'}.selectedNonObject`) || '', - filter, - filterKey: 0, - focused: this.localStorage.getItem(`${props.dialogName || 'App'}.focused`) || '', - depth: 0, - expandAllVisible: false, - expanded, - toast: '', - scrollBarWidth: 16, - customDialog, - editObjectDialog: '', - editObjectAlias: false, // open the edit object dialog on alias tab - viewFileDialog: '', - showAliasEditor: '', - enumDialog: null, - roleDialog: null, - statesView, - columns, - columnsForAdmin: null, - columnsSelectorShow: false, - columnsAuto: this.localStorage.getItem(`${props.dialogName || 'App'}.columnsAuto`) !== 'false', - columnsWidths, - columnsDialogTransparent: 100, - columnsEditCustomDialog: null, - customColumnDialogValueChanged: false, - showExportDialog: false, - showAllExportOptions: false, - linesEnabled: this.localStorage.getItem(`${props.dialogName || 'App'}.lines`) === 'true', - showDescription: this.localStorage.getItem(`${props.dialogName || 'App'}.desc`) !== 'false', - showContextMenu: null, - noStatesByExportImport: false, - beautifyJsonExport: true, - excludeSystemRepositoriesFromExport: true, - excludeTranslations: false, - tooltipInfo: null, - aliasMenu: '', - }; - - this.texts = { - name: props.t('ra_Name'), - categories: props.t('ra_Categories'), - value: props.t('ra_tooltip_value'), - ack: props.t('ra_tooltip_ack'), - ts: props.t('ra_tooltip_ts'), - lc: props.t('ra_tooltip_lc'), - from: props.t('ra_tooltip_from'), - user: props.t('ra_tooltip_user'), - c: props.t('ra_tooltip_comment'), - quality: props.t('ra_tooltip_quality'), - editObject: props.t('ra_tooltip_editObject'), - deleteObject: props.t('ra_tooltip_deleteObject'), - customConfig: props.t('ra_tooltip_customConfig'), - copyState: props.t('ra_tooltip_copyState'), - editState: props.t('ra_tooltip_editState'), - close: props.t('ra_Close'), - filter_id: props.t('ra_filter_id'), - filter_name: props.t('ra_filter_name'), - filter_type: props.t('ra_filter_type'), - filter_role: props.t('ra_filter_role'), - filter_room: props.t('ra_filter_room'), - filter_func: props.t('ra_filter_func'), - filter_custom: props.t('ra_filter_customs'), // - filterCustomsWithout: props.t('ra_filter_customs_without'), // - objectChangedByUser: props.t('ra_object_changed_by_user'), // Object last changed at - objectChangedBy: props.t('ra_object_changed_by'), // Object changed by - objectChangedFrom: props.t('ra_state_changed_from'), // Object changed from - stateChangedBy: props.t('ra_state_changed_by'), // State changed by - stateChangedFrom: props.t('ra_state_changed_from'), // State changed from - ownerGroup: props.t('ra_Owner group'), - ownerUser: props.t('ra_Owner user'), - deviceError: props.t('ra_Error'), - deviceDisconnected: props.t('ra_Disconnected'), - deviceConnected: props.t('ra_Connected'), - - aclOwner_read_object: props.t('ra_aclOwner_read_object'), - aclOwner_read_state: props.t('ra_aclOwner_read_state'), - aclOwner_write_object: props.t('ra_aclOwner_write_object'), - aclOwner_write_state: props.t('ra_aclOwner_write_state'), - aclGroup_read_object: props.t('ra_aclGroup_read_object'), - aclGroup_read_state: props.t('ra_aclGroup_read_state'), - aclGroup_write_object: props.t('ra_aclGroup_write_object'), - aclGroup_write_state: props.t('ra_aclGroup_write_state'), - aclEveryone_read_object: props.t('ra_aclEveryone_read_object'), - aclEveryone_read_state: props.t('ra_aclEveryone_read_state'), - aclEveryone_write_object: props.t('ra_aclEveryone_write_object'), - aclEveryone_write_state: props.t('ra_aclEveryone_write_state'), - - create: props.t('ra_Create'), - createBooleanState: props.t('ra_create_boolean_state'), - createNumberState: props.t('ra_create_number_state'), - createStringState: props.t('ra_create_string_state'), - createState: props.t('ra_create_state'), - createChannel: props.t('ra_create_channel'), - createDevice: props.t('ra_create_device'), - createFolder: props.t('ra_Create folder'), - }; - - this.levelPadding = props.levelPadding || ITEM_LEVEL; - - const resizerCurrentWidthsStr = this.localStorage.getItem(`${this.props.dialogName || 'App'}.table`); - if (resizerCurrentWidthsStr) { - try { - const resizerCurrentWidths = JSON.parse(resizerCurrentWidthsStr); - const width = this.props.width || 'lg'; - this.storedWidths = JSON.parse(JSON.stringify(SCREEN_WIDTHS[width])); - Object.keys(resizerCurrentWidths).forEach(id => { - if (id === 'id') { - SCREEN_WIDTHS[width].idWidth = resizerCurrentWidths.id; - } else if (id === 'nameHeader') { - SCREEN_WIDTHS[width].widths.name = resizerCurrentWidths[id]; - } else if ((SCREEN_WIDTHS[width].widths as Record)[id] !== undefined) { - (SCREEN_WIDTHS[width].widths as Record)[id] = resizerCurrentWidths[id]; - } - }); - - this.customWidth = true; - } catch { - // ignore - } - } - - this.calculateColumnsVisibility(); - } - - async loadAllObjects(update?: boolean): Promise { - const props = this.props; - - try { - await new Promise(resolve => { - this.setState({ updating: true }, () => resolve()); - }); - - const objects = - (this.props.objectsWorker - ? await this.props.objectsWorker.getObjects(update) - : await props.socket.getObjects(update, true)) || {}; - if (props.types && Connection.isWeb()) { - for (let i = 0; i < props.types.length; i++) { - // admin has ALL objects - // web has only state, channel, device, enum, and system.config - if ( - props.types[i] === 'state' || - props.types[i] === 'channel' || - props.types[i] === 'device' || - props.types[i] === 'enum' - ) { - continue; - } - const moreObjects = await props.socket.getObjectViewSystem(props.types[i]); - Object.assign(objects || {}, moreObjects as Record); - } - } - - this.systemConfig = - this.systemConfig || - (objects?.['system.config'] as ioBroker.SystemConfigObject) || - (await props.socket.getObject('system.config')); - - this.systemConfig.common = this.systemConfig.common || ({} as ioBroker.SystemConfigCommon); - this.systemConfig.common.defaultNewAcl = this.systemConfig.common.defaultNewAcl || { - object: 0, - state: 0, - file: 0, - owner: 'system.user.admin', - ownerGroup: 'system.group.administrator', - }; - this.systemConfig.common.defaultNewAcl.owner = - this.systemConfig.common.defaultNewAcl.owner || 'system.user.admin'; - this.systemConfig.common.defaultNewAcl.ownerGroup = - this.systemConfig.common.defaultNewAcl.ownerGroup || 'system.group.administrator'; - if (typeof this.systemConfig.common.defaultNewAcl.state !== 'number') { - // TODO: may be convert here from string - this.systemConfig.common.defaultNewAcl.state = 0x664; - } - if (typeof this.systemConfig.common.defaultNewAcl.object !== 'number') { - // TODO: may be convert here from string - this.systemConfig.common.defaultNewAcl.state = 0x664; - } - - if (typeof props.filterFunc === 'function') { - this.objects = {}; - const filterFunc: (obj: ioBroker.Object) => boolean = props.filterFunc; - - Object.keys(objects).forEach(id => { - try { - if (filterFunc(objects[id])) { - this.objects[id] = objects[id]; - } else { - const type = objects[id] && objects[id].type; - // include "folder" types too for icons and names of nodes - if ( - type && - (type === 'channel' || - type === 'device' || - type === 'folder' || - type === 'adapter' || - type === 'instance') - ) { - this.objects[id] = objects[id]; - } - } - } catch (e) { - console.log(`Error by filtering of "${id}": ${e}`); - } - }); - } else if (props.types) { - this.objects = {}; - const propsTypes = props.types; - - Object.keys(objects).forEach(id => { - const type = objects[id] && objects[id].type; - // include "folder" types too - if ( - type && - (type === 'channel' || - type === 'device' || - type === 'enum' || - type === 'folder' || - type === 'adapter' || - type === 'instance' || - propsTypes.includes(type)) - ) { - this.objects[id] = objects[id]; - } - }); - } else { - this.objects = objects; - } - - if (props.setObjectsReference) { - props.setObjectsReference(this.objects); - } - - // read default history - this.defaultHistory = this.systemConfig.common.defaultHistory; - if (this.defaultHistory) { - props.socket - .getState(`system.adapter.${this.defaultHistory}.alive`) - .then(state => { - if (!state || !state.val) { - this.defaultHistory = ''; - } - }) - .catch(e => window.alert(`Cannot get state: ${e}`)); - } - - const columnsForAdmin = await this.getAdditionalColumns(); - this.calculateColumnsVisibility(null, null, columnsForAdmin); - - const { info, root } = buildTree(this.objects, { - imagePrefix: this.props.imagePrefix, - root: this.props.root, - lang: this.props.lang, - themeType: this.props.themeType, - }); - this.root = root; - this.info = info; - - // Show first selected item - const node = - this.state.selected && this.state.selected.length && findNode(this.root, this.state.selected[0]); - - this.lastAppliedFilter = null; - - // If the selected ID is not visible, reset filter - if ( - node && - !applyFilter( - node, - this.state.filter, - this.props.lang, - this.objects, - undefined, - undefined, - props.customFilter, - props.types, - ) - ) { - // reset filter - this.setState({ filter: { ...DEFAULT_FILTER }, columnsForAdmin }, () => { - this.setState({ loaded: true, updating: false }, () => - this.expandAllSelected(() => this.onAfterSelect()), - ); - }); - } else { - this.setState({ loaded: true, updating: false, columnsForAdmin }, () => - this.expandAllSelected(() => this.onAfterSelect()), - ); - } - } catch (e1) { - this.showError(e1); - } - } - - /** - * Check if it is a non-expert id - */ - static isNonExpertId( - /** id to test */ - id: string, - ): boolean { - return !!ObjectBrowserClass.#NON_EXPERT_NAMESPACES.find(saveNamespace => id.startsWith(saveNamespace)); - } - - private expandAllSelected(cb?: () => void): void { - const expanded = [...this.state.expanded]; - let changed = false; - this.state.selected.forEach(id => { - const parts = id.split('.'); - const path = []; - for (let i = 0; i < parts.length - 1; i++) { - path.push(parts[i]); - if (!expanded.includes(path.join('.'))) { - expanded.push(path.join('.')); - changed = true; - } - } - }); - if (changed) { - expanded.sort(); - this.localStorage.setItem(`${this.props.dialogName || 'App'}.objectExpanded`, JSON.stringify(expanded)); - this.setState({ expanded }, cb); - } else if (cb) { - cb(); - } - } - - /** - * @param isDouble is double click - */ - private onAfterSelect(isDouble?: boolean): void { - if (this.state.selected?.length && this.state.selected[0]) { - this.localStorage.setItem(`${this.props.dialogName || 'App'}.objectSelected`, this.state.selected[0]); - - // remove a task to select the pre-selected item if now we want to see another object - if (this.selectFirst && this.selectFirst !== this.state.selected[0]) { - this.selectFirst = ''; - } - - if (this.state.selected.length === 1 && this.objects[this.state.selected[0]]) { - const name = Utils.getObjectName(this.objects, this.state.selected[0], null, { - language: this.props.lang, - }); - if (this.props.onSelect) { - this.props.onSelect(this.state.selected, name, isDouble); - } - } - } else { - this.localStorage.removeItem(`${this.props.dialogName || 'App'}.objectSelected`); - - if (this.state.selected.length) { - this.setState({ selected: [] }, () => this.props.onSelect && this.props.onSelect([], '')); - } else if (this.props.onSelect) { - this.props.onSelect([], ''); - } - } - } - - private static getDerivedStateFromProps( - props: ObjectBrowserProps, - state: ObjectBrowserState, - ): Partial | null { - const newState: Partial = {}; - let changed = false; - if (props.expertMode !== undefined && props.expertMode !== state.filter.expertMode) { - changed = true; - newState.filter = { ...state.filter }; - newState.filter.expertMode = props.expertMode; - } - return changed ? newState : null; - } - - /** - * Called when component is mounted. - */ - async componentDidMount(): Promise { - await this.loadAllObjects(!objectsAlreadyLoaded); - if (this.props.objectsWorker) { - this.props.objectsWorker.registerHandler(this.onObjectChangeFromWorker); - } else { - await this.props.socket.subscribeObject('*', this.onObjectChange); - } - - objectsAlreadyLoaded = true; - - window.addEventListener('contextmenu', this.onContextMenu, true); - } - - /** - * Called when component is unmounted. - */ - componentWillUnmount(): void { - if (this.filterTimer) { - clearTimeout(this.filterTimer); - this.filterTimer = null; - } - window.removeEventListener('contextmenu', this.onContextMenu, true); - - if (this.props.objectsWorker) { - this.props.objectsWorker.unregisterHandler(this.onObjectChangeFromWorker, true); - } else { - void this.props.socket - .unsubscribeObject('*', this.onObjectChange) - .catch(e => console.error(`Cannot unsubscribe *: ${e}`)); - } - - // remove all subscribes - this.subscribes.forEach(pattern => { - console.log(`- unsubscribe ${pattern}`); - this.props.socket.unsubscribeState(pattern, this.onStateChange); - }); - - this.subscribes = []; - this.objects = {}; - } - - /** - * Show the deletion dialog for a given object - */ - showDeleteDialog(options: { id: string; obj: ioBroker.Object; item: TreeItem }): void { - const { id, obj, item } = options; - - // calculate the number of children - const keys = Object.keys(this.objects); - keys.sort(); - let count = 0; - const start = `${id}.`; - for (let i = 0; i < keys.length; i++) { - if (keys[i].startsWith(start)) { - count++; - } else if (keys[i] > start) { - break; - } - } - - if (this.props.onObjectDelete) { - this.props.onObjectDelete(id, !!item.children?.length, !obj.common?.dontDelete, count + 1); - } - } - - /** - * Context menu handler. - */ - onContextMenu = (e: MouseEvent): void => { - // console.log(`CONTEXT MENU: ${this.contextMenu ? Date.now() - this.contextMenu.ts : 'false'}`); - if (this.contextMenu && Date.now() - this.contextMenu.ts < 2000) { - e.preventDefault(); - this.setState({ - showContextMenu: { - item: this.contextMenu.item, - position: { left: e.clientX + 2, top: e.clientY - 6 }, - }, - }); - } else if (this.state.showContextMenu) { - e.preventDefault(); - this.setState({ showContextMenu: null }); - } - this.contextMenu = null; - }; - - /** - * Called when component is mounted. - */ - refreshComponent(): void { - // remove all subscribes - this.subscribes.forEach(pattern => { - console.log(`- unsubscribe ${pattern}`); - this.props.socket.unsubscribeState(pattern, this.onStateChange); - }); - - this.subscribes = []; - - this.loadAllObjects(true) - .then(() => console.log('updated!')) - .catch(e => this.showError(e)); - } - - /** - * Renders the error dialog. - */ - renderErrorDialog(): JSX.Element | null { - return this.state.error ? ( - this.setState({ error: '' })} - aria-labelledby="error-dialog-title" - aria-describedby="error-dialog-description" - > - {this.props.t('ra_Error')} - - {this.state.error} - - - - - - ) : null; - } - - /** - * Show the error dialog. - */ - showError(error: any): void { - this.setState({ - error: - typeof error === 'object' - ? error && typeof error.toString === 'function' - ? error.toString() - : JSON.stringify(error) - : error, - }); - } - - /** - * Called when an item is selected/deselected. - */ - onSelect(toggleItem: string, isDouble?: boolean, cb?: () => void): void { - this.localStorage.setItem(`${this.props.dialogName || 'App'}.focused`, toggleItem); - - if (!this.props.multiSelect) { - if ( - this.objects[toggleItem] && - (!this.props.types || this.props.types.includes(this.objects[toggleItem].type)) - ) { - this.localStorage.removeItem(`${this.props.dialogName || 'App'}.selectedNonObject`); - if (this.state.selected[0] !== toggleItem) { - this.setState({ selected: [toggleItem], selectedNonObject: '', focused: toggleItem }, () => { - this.onAfterSelect(isDouble); - if (cb) { - cb(); - } - }); - } else if (isDouble && this.props.onSelect) { - this.onAfterSelect(isDouble); - } - } else { - this.localStorage.setItem(`${this.props.dialogName || 'App'}.selectedNonObject`, toggleItem); - this.setState({ selected: [], selectedNonObject: toggleItem, focused: toggleItem }, () => { - this.onAfterSelect(); - if (cb) { - cb(); - } - }); - } - } else if ( - this.objects[toggleItem] && - (!this.props.types || this.props.types.includes(this.objects[toggleItem].type)) - ) { - this.localStorage.removeItem(`${this.props.dialogName || 'App'}.selectedNonObject`); - - const selected = [...this.state.selected]; - const pos = selected.indexOf(toggleItem); - if (pos === -1) { - selected.push(toggleItem); - selected.sort(); - } else if (!isDouble) { - selected.splice(pos, 1); - } - - this.setState({ selected, selectedNonObject: '', focused: toggleItem }, () => { - this.onAfterSelect(isDouble); - if (cb) { - cb(); - } - }); - } - } - - private _renderDefinedList(isLast: boolean): JSX.Element[] { - const cols = [...this.possibleCols]; - cols.unshift('id'); - if (this.props.columns && !this.props.columns.includes('buttons')) { - const pos = cols.indexOf('buttons'); - if (pos !== -1) { - cols.splice(pos, 1); - } - } - return cols - .filter( - id => (isLast && (id === 'val' || id === 'buttons')) || (!isLast && id !== 'val' && id !== 'buttons'), - ) - .map(id => ( - { - if (!this.state.columnsAuto && id !== 'id') { - const columns = [...(this.state.columns || [])]; - const pos = columns.indexOf(id); - if (pos === -1) { - columns.push(id); - columns.sort(); - } else { - columns.splice(pos, 1); - } - this.localStorage.setItem( - `${this.props.dialogName || 'App'}.columns`, - JSON.stringify(columns), - ); - this.calculateColumnsVisibility(null, columns); - this.setState({ columns }); - } - }} - key={id} - > - - - {/* - - - { - const columnsWidths = JSON.parse(JSON.stringify(this.state.columnsWidths)); - columnsWidths[id] = e.target.value; - this.localStorage.setItem((this.props.dialogName || 'App') + '.columnsWidths', JSON.stringify(columnsWidths)); - this.calculateColumnsVisibility(null, null, null, columnsWidths); - this.setState({ columnsWidths }); - }} - autoComplete="off" - /> - - - */} - - )); - } - - /** - * Renders the columns' selector. - */ - renderColumnsSelectorDialog(): JSX.Element | null { - if (!this.state.columnsSelectorShow) { - return null; - } - return ( - this.setState({ columnsSelectorShow: false })} - open={!0} - sx={{ - '& .MuiPaper-root': Utils.getStyle( - this.props.theme, - styles.dialogColumns, - styles[`transparent_${this.state.columnsDialogTransparent}`], - ), - }} - > - {this.props.t('ra_Configure')} - - { - this.localStorage.setItem( - `${this.props.dialogName || 'App'}.foldersFirst`, - this.state.foldersFirst ? 'false' : 'true', - ); - this.setState({ foldersFirst: !this.state.foldersFirst }); - }} - /> - } - label={this.props.t('ra_Folders always first')} - /> - { - this.localStorage.setItem( - `${this.props.dialogName || 'App'}.lines`, - this.state.linesEnabled ? 'false' : 'true', - ); - this.setState({ linesEnabled: !this.state.linesEnabled }); - }} - /> - } - label={this.props.t('ra_Show lines between rows')} - /> - { - this.localStorage.setItem( - `${this.props.dialogName || 'App'}.columnsAuto`, - this.state.columnsAuto ? 'false' : 'true', - ); - if (!this.state.columnsAuto) { - this.calculateColumnsVisibility(true); - this.setState({ columnsAuto: true }); - } else if (!this.state.columns) { - this.calculateColumnsVisibility(false, [...this.visibleCols]); - this.setState({ columnsAuto: false, columns: [...this.visibleCols] }); - } else { - this.calculateColumnsVisibility(false); - this.setState({ columnsAuto: false }); - } - }} - /> - } - label={this.props.t('ra_Auto (no custom columns)')} - /> - {/* - {this.props.t('ra_Transparent dialog')} - - this.setState({ columnsDialogTransparent: newValue }) - } /> - */} - - {this._renderDefinedList(false)} - - {this.state.columnsForAdmin && - Object.keys(this.state.columnsForAdmin) - .sort() - .map( - adapter => - this.state.columnsForAdmin && - this.state.columnsForAdmin[adapter].map(column => ( - { - if (!this.state.columnsAuto) { - const columns = [...(this.state.columns || [])]; - const id = `_${adapter}_${column.path}`; - const pos = columns.indexOf(id); - if (pos === -1) { - columns.push(id); - columns.sort(); - } else { - columns.splice(pos, 1); - } - this.calculateColumnsVisibility(null, columns); - this.localStorage.setItem( - `${this.props.dialogName || 'App'}.columns`, - JSON.stringify(columns), - ); - this.setState({ columns }); - } - }} - key={`${adapter}_${column.name}`} - > - - - - - {/* - - - { - const columnsWidths = JSON.parse(JSON.stringify(this.state.columnsWidths)); - columnsWidths['_' + adapter + '_' + column.path] = e.target.value; - this.localStorage.setItem((this.props.dialogName || 'App') + '.columnsWidths', JSON.stringify(columnsWidths)); - this.calculateColumnsVisibility(null, null, null, columnsWidths); - this.setState({ columnsWidths }); - }} - autoComplete="off" - /> - - - */} - - )), - )} - {this._renderDefinedList(true)} - - - - - - - ); - } - - private async getAdditionalColumns(): Promise | null> { - try { - const instances = await this.props.socket.getAdapters(); - - let columnsForAdmin: Record | null = null; - // find all additional columns - instances.forEach(obj => (columnsForAdmin = this.parseObjectForAdmins(columnsForAdmin, obj))); - - return columnsForAdmin; - } catch (err) { - // window.alert('Cannot get adapters: ' + e); - // Object browser in Web has no additional columns - console.error(`Cannot get adapters: ${err}`); - return null; - } - } - - private checkUnsubscribes(): void { - // Remove unused subscriptions - for (let i = this.subscribes.length - 1; i >= 0; i--) { - if (!this.recordStates.includes(this.subscribes[i])) { - this.unsubscribe(this.subscribes[i]); - } - } - this.recordStates = []; - } - - /** - * Find an item. - */ - findItem(id: string, _parts?: string[], _root?: TreeItem | null, _partyId?: string): TreeItem | null { - _parts = _parts || id.split('.'); - _root = _root || this.root; - if (!_root || !_parts.length) { - return null; - } - - _partyId = (_partyId ? `${_partyId}.` : '') + _parts.shift(); - - if (_root.children) { - const item = _root.children.find(i => i.data.id === _partyId); - if (item) { - if (item.data.id === id) { - return item; - } - if (_parts.length) { - return this.findItem(id, _parts, item, _partyId); - } - } else { - return null; - } - } - - return null; - } - - /** - * Called when a state changes. - */ - onStateChange = (id: string, state?: ioBroker.State | null): void => { - console.log(`> stateChange ${id}`); - if (this.states[id]) { - const item = this.findItem(id); - if (item?.data.state) { - item.data.state = undefined; - } - } - if (state) { - this.states[id] = state; - } else { - delete this.states[id]; - } - - if (!this.pausedSubscribes) { - if (!this.statesUpdateTimer) { - this.statesUpdateTimer = setTimeout(() => { - this.statesUpdateTimer = null; - this.forceUpdate(); - }, 300); - } - } else if (this.statesUpdateTimer) { - clearTimeout(this.statesUpdateTimer); - this.statesUpdateTimer = null; - } - }; - - private parseObjectForAdmins( - columnsForAdmin: Record | null, - obj: ioBroker.AdapterObject, - ): Record | null { - if (obj.common && obj.common.adminColumns && obj.common.name) { - const columns: string | (string | ioBroker.CustomAdminColumn)[] = obj.common.adminColumns; - let aColumns: (string | ioBroker.CustomAdminColumn)[] | undefined; - if (columns && typeof columns !== 'object') { - aColumns = [columns]; - } else if (columns) { - aColumns = columns as (string | ioBroker.CustomAdminColumn)[]; - } - let cColumns: CustomAdminColumnStored[] | null; - if (columns) { - cColumns = aColumns - .map((_item: string | ioBroker.CustomAdminColumn) => { - if (typeof _item !== 'object') { - return { path: _item, name: _item.split('.').pop() }; - } - const item: ioBroker.CustomAdminColumn = _item; - // string => array - if (item.objTypes && typeof item.objTypes !== 'object') { - item.objTypes = [item.objTypes]; - } else if (!item.objTypes) { - item.objTypes = undefined; - } - - if (!item.name && item.path) { - return { - path: item.path, - name: item.path.split('.').pop(), - width: item.width, - edit: !!item.edit, - type: item.type, - objTypes: item.objTypes, - } as CustomAdminColumnStored; - } - if (!item.path) { - console.warn(`Admin columns for ${obj._id} ignored, because path not found`); - return null; - } - return { - path: item.path, - name: getName(item.name || '', this.props.lang), - width: item.width, - edit: !!item.edit, - type: item.type, - objTypes: item.objTypes, - } as CustomAdminColumnStored; - }) - .filter((item: CustomAdminColumnStored) => item); - } else { - cColumns = null; - } - - if (cColumns && cColumns.length) { - columnsForAdmin = columnsForAdmin || {}; - columnsForAdmin[obj.common.name] = cColumns.sort((a, b) => - a.path > b.path ? -1 : a.path < b.path ? 1 : 0, - ); - } - } else if (obj.common && obj.common.name && columnsForAdmin && columnsForAdmin[obj.common.name]) { - delete columnsForAdmin[obj.common.name]; - } - return columnsForAdmin; - } - - onObjectChangeFromWorker = (events: ObjectEvent[]): void => { - if (Array.isArray(events)) { - let newState: { columnsForAdmin: Record | null } | null = null; - events.forEach(event => { - const { newInnerState, filtered } = this.processOnObjectChangeElement(event.id, event.obj); - if (filtered) { - return; - } - if (newInnerState && newState) { - Object.assign(newState, newInnerState); - } else { - newState = newInnerState; - } - }); - - if (newState) { - this.setState(newState); - } - this.afterObjectUpdated(); - } - }; - - onObjectChange = (id: string, obj?: ioBroker.Object | null): void => { - const { newInnerState, filtered } = this.processOnObjectChangeElement(id, obj); - if (filtered) { - return; - } - - if (newInnerState) { - this.setState(newInnerState); - } - this.afterObjectUpdated(); - }; - - afterObjectUpdated(): void { - if (!this.objectsUpdateTimer && this.objects) { - this.objectsUpdateTimer = setTimeout(() => { - this.objectsUpdateTimer = null; - const { info, root } = buildTree(this.objects, { - imagePrefix: this.props.imagePrefix, - root: this.props.root, - lang: this.props.lang, - themeType: this.props.themeType, - }); - this.root = root; - this.info = info; - this.lastAppliedFilter = null; // apply filter anew - - if (!this.pausedSubscribes) { - this.forceUpdate(); - } - // else it will be re-rendered when the dialog will be closed - }, 500); - } - } - - // This function is called when the user changes the alias of an object. - // It updates the aliasMap and returns true if the aliasMap has changed. - updateAliases(aliasId: string): void { - if (!this.objects || !this.info?.aliasesMap || !aliasId?.startsWith('alias.')) { - return; - } - // Rebuild aliases map - const aliasesIds = Object.keys(this.objects).filter(id => id.startsWith('alias.0')); - - this.info.aliasesMap = {}; - - for (const id of aliasesIds) { - const obj = this.objects[id]; - if (obj?.common?.alias?.id) { - if (typeof obj.common.alias.id === 'string') { - const usedId = obj.common.alias.id; - if (!this.info.aliasesMap[usedId]) { - this.info.aliasesMap[usedId] = [id]; - } else if (!this.info.aliasesMap[usedId].includes(id)) { - this.info.aliasesMap[usedId].push(id); - } - } else { - const readId = obj.common.alias.id.read; - if (readId) { - if (!this.info.aliasesMap[readId]) { - this.info.aliasesMap[readId] = [id]; - } else if (!this.info.aliasesMap[readId].includes(id)) { - this.info.aliasesMap[readId].push(id); - } - } - const writeId = obj.common.alias.id.write; - if (writeId) { - if (!this.info.aliasesMap[writeId]) { - this.info.aliasesMap[writeId] = [id]; - } else if (!this.info.aliasesMap[writeId].includes(id)) { - this.info.aliasesMap[writeId].push(id); - } - } - } - } - } - } - - /** - * Processes a single element in regard to certain filters, columns for admin and updates object dict - * - * @param id The id of the object - * @param obj The object itself - * @returns Returns an object containing the new state (if any) and whether the object was filtered. - */ - processOnObjectChangeElement( - id: string, - obj?: ioBroker.Object | null, - ): { - filtered: boolean; - newInnerState: null | { columnsForAdmin: Record | null }; - } { - console.log(`> objectChange ${id}`); - const type = obj?.type; - - // If the object is filtered out, we don't need to update the React state - if ( - obj && - typeof this.props.filterFunc === 'function' && - !this.props.filterFunc(obj) && - type !== 'channel' && - type !== 'device' && - type !== 'folder' && - type !== 'adapter' && - type !== 'instance' - ) { - return { newInnerState: null, filtered: true }; - } - - let newInnerState = null; - if (id.startsWith('system.adapter.') && obj?.type === 'adapter') { - const columnsForAdmin: Record | null = JSON.parse( - JSON.stringify(this.state.columnsForAdmin), - ); - - this.parseObjectForAdmins(columnsForAdmin, obj as ioBroker.AdapterObject); - - if (JSON.stringify(this.state.columnsForAdmin) !== JSON.stringify(columnsForAdmin)) { - newInnerState = { columnsForAdmin }; - } - } - - this.objects = this.objects || {}; - - if (obj) { - this.objects[id] = obj; - } else if (this.objects[id]) { - delete this.objects[id]; - } - - this.updateAliases(id); - - return { newInnerState, filtered: false }; - } - - private subscribe(id: string): void { - if (!this.subscribes.includes(id)) { - this.subscribes.push(id); - console.log(`+ subscribe ${id}`); - if (!this.pausedSubscribes) { - this.props.socket - .subscribeState(id, this.onStateChange) - .catch(e => console.error(`Cannot subscribe on state ${id}: ${e}`)); - } - } - } - - private unsubscribe(id: string): void { - const pos = this.subscribes.indexOf(id); - if (pos !== -1) { - this.subscribes.splice(pos, 1); - if (this.states[id]) { - delete this.states[id]; - } - console.log(`- unsubscribe ${id}`); - this.props.socket.unsubscribeState(id, this.onStateChange); - - if (this.pausedSubscribes) { - console.warn('Unsubscribe during pause?'); - } - } - } - - private pauseSubscribe(isPause: boolean): void { - if (!this.pausedSubscribes && isPause) { - this.pausedSubscribes = true; - this.subscribes.forEach(id => this.props.socket.unsubscribeState(id, this.onStateChange)); - } else if (this.pausedSubscribes && !isPause) { - this.pausedSubscribes = false; - this.subscribes.forEach(id => this.props.socket.subscribeState(id, this.onStateChange)); - } - } - - private onFilter(name?: string, value?: string | boolean): void { - this.filterTimer = null; - const filter: ObjectBrowserFilter = { ...this.state.filter }; - - Object.keys(this.filterRefs).forEach(_name => { - if (this.filterRefs[_name] && this.filterRefs[_name].current) { - const filterRef: HTMLSelectElement = this.filterRefs[_name].current; - for (let i = 0; i < filterRef.children.length; i++) { - if (filterRef.children[i].tagName === 'INPUT') { - (filter as Record)[_name] = (filterRef.children[i] as HTMLInputElement).value; - break; - } - } - } - }); - - if (name) { - (filter as Record)[name] = value; - if (name === 'expertMode') { - (((window as any)._sessionStorage as Storage) || window.sessionStorage).setItem( - 'App.expertMode', - value ? 'true' : 'false', - ); - } - } - - if (JSON.stringify(this.state.filter) !== JSON.stringify(filter)) { - this.localStorage.setItem(`${this.props.dialogName || 'App'}.objectFilter`, JSON.stringify(filter)); - this.setState({ filter }, () => this.props.onFilterChanged && this.props.onFilterChanged(filter)); - } - } - - clearFilter(): void { - const filter: ObjectBrowserFilter = { ...this.state.filter }; - - Object.keys(this.filterRefs).forEach(name => { - if (this.filterRefs[name] && this.filterRefs[name].current) { - const filterRef: HTMLSelectElement = this.filterRefs[name].current; - for (let i = 0; i < filterRef.childNodes.length; i++) { - const item = filterRef.childNodes[i]; - if ((item as HTMLInputElement).tagName === 'INPUT') { - (filter as Record)[name] = ''; - (item as HTMLInputElement).value = ''; - break; - } - } - } - }); - - if (JSON.stringify(this.state.filter) !== JSON.stringify(filter)) { - this.localStorage.setItem(`${this.props.dialogName || 'App'}.objectFilter`, JSON.stringify(filter)); - this.setState( - { filter, filterKey: this.state.filterKey + 1 }, - () => this.props.onFilterChanged && this.props.onFilterChanged(filter), - ); - } - } - - isFilterEmpty(): boolean { - const someNotEmpty = Object.keys(this.state.filter).find( - attr => attr !== 'expertMode' && (this.state.filter as Record)[attr], - ); - return !someNotEmpty; - } - - private getFilterInput(filterName: string): JSX.Element { - return ( - - )[filterName] || ''} - onChange={() => { - if (this.filterTimer) { - clearTimeout(this.filterTimer); - } - this.filterTimer = setTimeout(() => this.onFilter(), 400); - }} - autoComplete="off" - /> - {(this.filterRefs[filterName]?.current?.firstChild as HTMLInputElement)?.value ? ( -
- { - (this.filterRefs[filterName].current?.firstChild as HTMLInputElement).value = ''; - this.onFilter(filterName, ''); - }} - > - - -
- ) : null} -
- ); - } - - private getFilterSelect(name: string, values?: (string | InputSelectItem)[]): JSX.Element { - const hasIcons = !!values?.find(item => (item as InputSelectItem).icon); - - return ( -
- - {(this.filterRefs[name]?.current?.childNodes[1] as HTMLInputElement)?.value ? ( - - { - const newFilter: ObjectBrowserFilter = { ...this.state.filter }; - (newFilter as Record)[name] = ''; - (this.filterRefs[name].current?.childNodes[1] as HTMLInputElement).value = ''; - this.localStorage.setItem( - `${this.props.dialogName || 'App'}.objectFilter`, - JSON.stringify(newFilter), - ); - this.setState( - { filter: newFilter, filterKey: this.state.filterKey + 1 }, - () => this.props.onFilterChanged && this.props.onFilterChanged(newFilter), - ); - }} - > - - - - ) : null} -
- ); - } - - private getFilterSelectRole(): JSX.Element { - return this.getFilterSelect('role', this.info.roles); - } - - private getFilterSelectRoom(): JSX.Element { - const rooms: InputSelectItem[] = this.info.roomEnums.map( - id => - ({ - name: getName(this.objects[id]?.common?.name, this.props.lang) || id.split('.').pop(), - value: id, - icon: ( - - ), - }) as InputSelectItem, - ); - - return this.getFilterSelect('room', rooms); - } - - private getFilterSelectFunction(): JSX.Element { - const func: InputSelectItem[] = this.info.funcEnums.map( - id => - ({ - name: getName(this.objects[id]?.common?.name, this.props.lang) || id.split('.').pop(), - value: id, - icon: ( - - ), - }) as InputSelectItem, - ); - - return this.getFilterSelect('func', func); - } - - private getFilterSelectType(): JSX.Element { - const types = this.info.types.map(type => ({ - name: type, - value: type, - icon: ITEM_IMAGES[type] || null, - })); - - return this.getFilterSelect('type', types); - } - - private getFilterSelectCustoms(): JSX.Element | null { - if (this.info.customs.length > 1) { - const customs = this.info.customs.map(id => ({ - name: id === '_' ? this.texts.filterCustomsWithout : id, - value: id, - icon: - id === '_' ? null : ( - - ), - })); - return this.getFilterSelect('custom', customs); - } - return null; - } - - private onExpandAll(root?: TreeItem, expanded?: string[]): void { - const _root: TreeItem | null = root || this.root; - expanded = expanded || []; - - _root?.children?.forEach((item: TreeItem) => { - if (item.data.sumVisibility) { - expanded.push(item.data.id); - this.onExpandAll(item, expanded); - } - }); - - if (_root === this.root) { - expanded.sort(); - this.localStorage.setItem(`${this.props.dialogName || 'App'}.objectExpanded`, JSON.stringify(expanded)); - - this.setState({ expanded }); - } - } - - private onCollapseAll(): void { - this.localStorage.setItem(`${this.props.dialogName || 'App'}.objectExpanded`, JSON.stringify([])); - this.localStorage.setItem(`${this.props.dialogName || 'App'}.objectSelected`, '[]'); - this.setState({ expanded: [], depth: 0, selected: [] }, () => this.onAfterSelect()); - } - - private expandDepth(root: TreeItem, depth: number, expanded: string[]): void { - root = root || this.root; - if (depth > 0) { - root.children?.forEach(item => { - if (item.data.sumVisibility) { - if (!binarySearch(expanded, item.data.id)) { - expanded.push(item.data.id); - expanded.sort(); - } - if (depth - 1 > 0) { - this.expandDepth(item, depth - 1, expanded); - } - } - }); - } - } - - private static collapseDepth(depth: number, expanded: string[]): string[] { - return expanded.filter(id => id.split('.').length <= depth); - } - - private onExpandVisible(): void { - if (this.state.depth < 9) { - const depth = this.state.depth + 1; - const expanded = [...this.state.expanded]; - if (this.root) { - this.expandDepth(this.root, depth, expanded); - } - this.localStorage.setItem(`${this.props.dialogName || 'App'}.objectExpanded`, JSON.stringify(expanded)); - this.setState({ depth, expanded }); - } - } - - private onStatesViewVisible(): void { - const statesView = !this.state.statesView; - this.localStorage.setItem(`${this.props.dialogName || 'App'}.objectStatesView`, JSON.stringify(statesView)); - this.setState({ statesView }); - } - - private onCollapseVisible(): void { - if (this.state.depth > 0) { - const depth = this.state.depth - 1; - const expanded = ObjectBrowserClass.collapseDepth(depth, this.state.expanded); - this.localStorage.setItem(`${this.props.dialogName || 'App'}.objectExpanded`, JSON.stringify(expanded)); - this.setState({ depth, expanded }); - } - } - - private getEnumsForId = (id: string): ioBroker.EnumObject[] | undefined => { - const result: ioBroker.EnumObject[] = []; - this.info.enums.forEach(_id => { - if (this.objects[_id]?.common?.members?.includes(id)) { - const enumItem: ioBroker.EnumObject = { - _id: this.objects[_id]._id, - common: JSON.parse(JSON.stringify(this.objects[_id].common)) as ioBroker.EnumCommon, - native: this.objects[_id].native, - type: 'enum', - } as ioBroker.EnumObject; - if (enumItem.common) { - delete enumItem.common.members; - delete enumItem.common.custom; - // @ts-expect-error deprecated attribute - delete enumItem.common.mobile; - } - result.push(enumItem); - } - }); - - return result.length ? result : undefined; - }; - - private _createAllEnums = async (enums: (string | ioBroker.EnumObject)[], objId: string): Promise => { - for (let e = 0; e < enums.length; e++) { - const item: string | ioBroker.EnumObject = enums[e]; - let id: string; - let newObj: ioBroker.EnumObject | undefined; - - // some admin version delivered enums as string - if (typeof item === 'object') { - newObj = item; - id = newObj._id; - } else { - id = item; - } - - let oldObj: ioBroker.EnumObject | undefined = this.objects[id] as ioBroker.EnumObject | undefined; - // if enum does not exist - if (!oldObj) { - // create a new one - oldObj = - newObj || - ({ - _id: id, - common: { - name: id.split('.').pop(), - members: [], - }, - native: {}, - type: 'enum', - } as ioBroker.EnumObject); - - oldObj.common = oldObj.common || ({} as ioBroker.EnumCommon); - oldObj.common.members = [objId]; - oldObj.type = 'enum'; - - await this.props.socket.setObject(id, oldObj); - } else if (!oldObj.common?.members?.includes(objId)) { - oldObj.common = oldObj.common || ({} as ioBroker.EnumCommon); - oldObj.type = 'enum'; - oldObj.common.members = oldObj.common.members || []; - // add the missing object - oldObj.common.members.push(objId); - oldObj.common.members.sort(); - await this.props.socket.setObject(id, oldObj); - } - } - }; - - private async loadObjects(objs: Record): Promise { - if (objs) { - for (const id in objs) { - if (!Object.prototype.hasOwnProperty.call(objs, id) || !objs[id]) { - continue; - } - const obj = objs[id]; - let enums = null; - let val; - let ack; - if (obj && obj.common && obj.common.enums) { - enums = obj.common.enums; - delete obj.common.enums; - } else { - enums = null; - } - - if (obj.val || obj.val === 0) { - val = obj.val; - delete obj.val; - } - if (obj.ack !== undefined) { - ack = obj.ack; - delete obj.ack; - } - try { - await this.props.socket.setObject(id, obj); - if (enums) { - await this._createAllEnums(enums, obj._id); - } - if (obj.type === 'state') { - if (val !== undefined && val !== null) { - try { - await this.props.socket.setState(obj._id, val, ack !== undefined ? ack : true); - } catch (e) { - window.alert(`Cannot set state "${obj._id} with ${val}": ${e}`); - } - } else { - try { - const state = await this.props.socket.getState(obj._id); - if (!state || state.val === null) { - try { - await this.props.socket.setState( - obj._id, - !obj.common || obj.common.def === undefined ? null : obj.common.def, - true, - ); - } catch (e) { - window.alert(`Cannot set state "${obj._id}": ${e}`); - } - } - } catch (e) { - window.alert(`Cannot read state "${obj._id}": ${e}`); - } - } - } - } catch (error) { - window.alert(error); - } - } - } - } - - _getSelectedIdsForExport(): string[] { - if (this.state.selected.length || this.state.selectedNonObject) { - const result = []; - const keys = Object.keys(this.objects); - keys.sort(); - const id = this.state.selected[0] || this.state.selectedNonObject; - const idDot = `${id}.`; - const idLen = idDot.length; - for (let k = 0; k < keys.length; k++) { - const key = keys[k]; - if (id === key || key.startsWith(idDot)) { - result.push(key); - } - if (key.substring(0, idLen) > idDot) { - break; - } - } - - return result; - } - return []; - } - - /** - * Exports the selected objects based on the given options and triggers file generation - */ - private async _exportObjects( - /** Options to filter/reduce the output */ - options: { - /** Whether all objects should be exported or only the selected ones */ - isAll?: boolean; - /** Whether the output should be beautified */ - beautify?: boolean; - /** Whether "system.repositories" should be excluded */ - excludeSystemRepositories?: boolean; - /** Whether translations should be reduced to only the english value */ - excludeTranslations?: boolean; - /** Whether the values of the states should be not included */ - noStatesByExportImport?: boolean; - }, - ): Promise { - if (options.isAll) { - generateFile('allObjects.json', this.objects, options); - return; - } - if (!(this.state.selected.length || this.state.selectedNonObject)) { - window.alert(this.props.t('ra_Save of objects-tree is not possible')); - return; - } - const result: Record = {}; - const id = this.state.selected[0] || this.state.selectedNonObject; - const ids = this._getSelectedIdsForExport(); - - for (const key of ids) { - result[key] = JSON.parse(JSON.stringify(this.objects[key])) as ioBrokerObjectForExport; - // read states values - if (result[key]?.type === 'state' && !options.noStatesByExportImport) { - const state = await this.props.socket.getState(key); - if (state) { - result[key].val = state.val; - result[key].ack = state.ack; - } - } - // add enum information - if (result[key].common) { - const enums = this.getEnumsForId(key); - if (enums) { - result[key].common.enums = enums; - } - } - } - - generateFile(`${id}.json`, result, options); - } - - renderExportDialog(): JSX.Element | null { - if (this.state.showExportDialog === false) { - return null; - } - return ( - - {this.props.t('ra_Select type of export')} - - - {this.state.filter.expertMode || this.state.showAllExportOptions ? ( - <> - {this.props.t('ra_You can export all objects or just the selected branch.')} -
- {this.props.t('ra_Selected %s object(s)', this.state.showExportDialog)} -
- this.setState({ noStatesByExportImport: e.target.checked })} - /> - } - label={this.props.t('ra_Do not export values of states')} - /> -
- {this.props.t('These options can reduce the size of the export file:')} - this.setState({ beautifyJsonExport: e.target.checked })} - /> - } - label={this.props.t('Beautify JSON output')} - /> -
- - this.setState({ excludeSystemRepositoriesFromExport: e.target.checked }) - } - /> - } - label={this.props.t('Exclude system repositories from export JSON')} - /> - this.setState({ excludeTranslations: e.target.checked })} - /> - } - label={this.props.t('Exclude translations (except english) from export JSON')} - /> - - ) : null} -
-
- - {this.state.filter.expertMode || this.state.showAllExportOptions ? ( - - ) : ( - - )} - - - -
- ); - } - - private handleJsonUpload(evt: Event): void { - const target = evt.target as HTMLInputElement; - const f = target.files?.length && target.files[0]; - if (f) { - const r = new FileReader(); - r.onload = async e => { - const contents = e.target?.result; - try { - const json = JSON.parse(contents as string); - const len = Object.keys(json).length; - const id = json._id; - // it could be a single object or many objects - if (id === undefined && len) { - // many objects - await this.loadObjects(json as Record); - window.alert(this.props.t('ra_%s object(s) processed', len)); - } else { - // it is only one object in form - // { - // "_id": "xxx", - // "common": "yyy", - // "native": "zzz" - // "val": JSON.stringify(value) - // "ack": true - // } - if (!id) { - return window.alert(this.props.t('ra_Invalid structure')); - } - try { - let enums; - let val; - let ack; - if (json.common.enums) { - enums = json.common.enums; - delete json.common.enums; - } - if (json.val) { - val = json.val; - delete json.val; - } - if (json.ack !== undefined) { - ack = json.ack; - delete json.ack; - } - await this.props.socket.setObject(json._id, json); - - if (json.type === 'state') { - if (val !== undefined && val !== null) { - await this.props.socket.setState(json._id, val, ack === undefined ? true : ack); - } else { - const state = await this.props.socket.getState(json._id); - if (!state || state.val === null || state.val === undefined) { - await this.props.socket.setState( - json._id, - json.common.def === undefined ? null : json.common.def, - true, - ); - } - } - } - if (enums) { - await this._createAllEnums(enums, json._id); - } - - window.alert(this.props.t('ra_%s was imported', json._id)); - } catch (err) { - window.alert(err); - } - } - } catch (err) { - window.alert(err); - } - return null; - }; - r.readAsText(f); - } else { - window.alert(this.props.t('ra_Failed to open JSON File')); - } - } - - toolTipObjectCreating = (): JSX.Element[] | string => { - const { t } = this.props; - - let value = [ -
{t('ra_Only following structures of objects are available:')}
, -
{t('ra_Folder → State')}
, -
{t('ra_Folder → Channel → State')}
, -
{t('ra_Folder → Device → Channel → State')}
, -
{t('ra_Device → Channel → State')}
, -
{t('ra_Channel → State')}
, -
, -
{t('ra_Non-experts may create new objects only in "0_userdata.0" or "alias.0".')}
, -
- {t( - 'ra_The experts may create objects everywhere but from second level (e.g. "vis.0" or "javascript.0").', - )} -
, - ]; - - if (this.state.selected.length || this.state.selectedNonObject) { - const id = this.state.selected[0] || this.state.selectedNonObject; - if (id.split('.').length < 2 || (this.objects[id] && this.objects[id]?.type === 'state')) { - // show default tooltip - } else if (this.state.filter.expertMode) { - switch (this.objects[id]?.type) { - case 'device': - value = [ -
{t('ra_Only following structures of objects are available:')}
, -
{t('ra_Device → Channel → State')}
, -
, -
- {t('ra_Non-experts may create new objects only in "0_userdata.0" or "alias.0".')} -
, -
- {t( - 'ra_The experts may create objects everywhere but from second level (e.g. "vis.0" or "javascript.0").', - )} -
, - ]; - break; - case 'folder': - value = [ -
{t('ra_Only following structures of objects are available:')}
, -
{t('ra_Folder → State')}
, -
{t('ra_Folder → Channel → State')}
, -
{t('ra_Folder → Device → Channel → State')}
, -
, -
- {t('ra_Non-experts may create new objects only in "0_userdata.0" or "alias.0".')} -
, -
- {t( - 'ra_The experts may create objects everywhere but from second level (e.g. "vis.0" or "javascript.0").', - )} -
, - ]; - break; - case 'channel': - value = [ -
{t('ra_Only following structures of objects are available:')}
, -
{t('ra_Channel → State')}
, -
, -
- {t('ra_Non-experts may create new objects only in "0_userdata.0" or "alias.0".')} -
, -
- {t( - 'ra_The experts may create objects everywhere but from second level (e.g. "vis.0" or "javascript.0").', - )} -
, - ]; - break; - default: - break; - } - } else if (id.startsWith('alias.0') || id.startsWith('0_userdata')) { - value = [ -
{t('ra_Only following structures of objects are available:')}
, -
{t('ra_Folder → State')}
, -
{t('ra_Folder → Channel → State')}
, -
{t('ra_Folder → Device → Channel → State')}
, -
{t('ra_Device → Channel → State')}
, -
{t('ra_Channel → State')}
, -
, -
- {t('ra_Non-experts may create new objects only in "0_userdata.0" or "alias.0".')} -
, -
- {t( - 'ra_The experts may create objects everywhere but from second level (e.g. "vis.0" or "javascript.0").', - )} -
, - ]; - } - } - - return value.length ? value : t('ra_Add new child object to selected parent'); - }; - - /** - * Renders the toolbar. - */ - getToolbar(): JSX.Element { - let allowObjectCreation = false; - if (this.state.selected.length || this.state.selectedNonObject) { - const id = this.state.selected[0] || this.state.selectedNonObject; - - if (id.split('.').length < 2 || (this.objects[id] && this.objects[id].type === 'state')) { - allowObjectCreation = false; - } else if (this.state.filter.expertMode) { - allowObjectCreation = true; - } else if (id.startsWith('alias.0') || id.startsWith('0_userdata')) { - allowObjectCreation = true; - } - } - - return ( -
-
- -
- this.refreshComponent()} - disabled={this.state.updating} - size="large" - > - - -
-
- {this.props.showExpertButton && !this.props.expertMode && ( - - this.onFilter('expertMode', !this.state.filter.expertMode)} - size="large" - > - - - - )} - {!this.props.disableColumnSelector && this.props.width !== 'xs' && ( - - this.setState({ columnsSelectorShow: true })} - size="large" - > - - - - )} - {this.props.width !== 'xs' && this.state.expandAllVisible && ( - - this.onExpandAll()} - size="large" - > - - - - )} - - this.onCollapseAll()} - size="large" - > - - - - {this.props.width !== 'xs' && ( - - this.onExpandVisible()} - size="large" - > - ({ - badge: { - right: 3, - top: 3, - border: `2px solid ${theme.palette.background.paper}`, - padding: '0 4px', - }, - })} - > - - - - - )} - {this.props.width !== 'xs' && ( - - this.onCollapseVisible()} - size="large" - > - ({ - badge: { - right: 3, - top: 3, - border: `2px solid ${theme.palette.background.paper}`, - padding: '0 4px', - }, - })} - badgeContent={this.state.depth} - color="secondary" - > - - - - - )} - {this.props.objectStatesView && ( - - this.onStatesViewVisible()} - size="large" - > - - - - )} - - - { - this.localStorage.setItem( - `${this.props.dialogName || 'App'}.desc`, - this.state.showDescription ? 'false' : 'true', - ); - this.setState({ showDescription: !this.state.showDescription }); - }} - size="large" - > - - - - - {this.props.objectAddBoolean ? ( - -
- - this.setState({ - modalNewObj: { - id: this.state.selected[0] || this.state.selectedNonObject, - }, - }) - } - size="large" - > - - -
-
- ) : null} - - {this.props.objectImportExport && ( - - { - const input = document.createElement('input'); - input.setAttribute('type', 'file'); - input.setAttribute('id', 'files'); - input.setAttribute('opacity', '0'); - input.addEventListener('change', (e: Event) => this.handleJsonUpload(e), false); - input.click(); - }} - size="large" - > - - - - )} - {this.props.objectImportExport && - (!!this.state.selected.length || this.state.selectedNonObject) && ( - - - this.setState({ showExportDialog: this._getSelectedIdsForExport().length }) - } - size="large" - > - - - - )} -
- {!!this.props.objectBrowserEditObject && this.props.width !== 'xs' && ( -
- {`${this.props.t('ra_Objects')}: ${Object.keys(this.info.objects).length}, ${this.props.t( - 'ra_States', - )}: ${ - Object.keys(this.info.objects).filter(el => this.info.objects[el].type === 'state').length - }`} -
- )} - {this.props.objectEditBoolean && ( - - { - // get all visible states - const ids = this.root ? getVisibleItems(this.root, 'state', this.objects) : []; - - if (ids.length) { - this.pauseSubscribe(true); - - if (ids.length === 1) { - this.localStorage.setItem( - `${this.props.dialogName || 'App'}.objectSelected`, - this.state.selected[0], - ); - this.props.router?.doNavigate(null, 'custom', this.state.selected[0]); - } - this.setState({ customDialog: ids, customDialogAll: true }); - } else { - this.setState({ toast: this.props.t('ra_please select object') }); - } - }} - size="large" - > - - - - )} -
- ); - } - - private toggleExpanded(id: string): void { - const expanded = JSON.parse(JSON.stringify(this.state.expanded)); - const pos = expanded.indexOf(id); - if (pos === -1) { - expanded.push(id); - expanded.sort(); - } else { - expanded.splice(pos, 1); - } - - this.localStorage.setItem(`${this.props.dialogName || 'App'}.objectExpanded`, JSON.stringify(expanded)); - - this.setState({ expanded }); - } - - private onCopy(e: React.MouseEvent, text: string | undefined): void { - e.stopPropagation(); - e.preventDefault(); - if (text) { - Utils.copyToClipboard(text); - if (text.length < 50) { - this.setState({ toast: this.props.t('ra_Copied %s', text) }); - } else { - this.setState({ toast: this.props.t('ra_Copied') }); - } - } - } - - renderTooltipAccessControl = (acl: ioBroker.StateACL): null | JSX.Element => { - // acl ={object,state,owner,ownerGroup} - if (!acl) { - return null; - } - const check = [ - { - value: '0x400', - valueNum: 0x400, - title: 'read', - group: 'Owner', - }, - { - value: '0x200', - valueNum: 0x200, - title: 'write', - group: 'Owner', - }, - { - value: '0x40', - valueNum: 0x40, - title: 'read', - group: 'Group', - }, - { - value: '0x20', - valueNum: 0x20, - title: 'write', - group: 'Group', - }, - { - value: '0x4', - valueNum: 0x4, - title: 'read', - group: 'Everyone', - }, - { - value: '0x2', - valueNum: 0x2, - title: 'write', - group: 'Everyone', - }, - ]; - const arrayTooltipText = []; - const funcRenderStateObject = (value: 'object' | 'state'): void => { - const rights: number = acl[value]; - check.forEach((el, i) => { - if (rights & el.valueNum) { - arrayTooltipText.push( - - {this.texts[`acl${el.group}_${el.title}_${value}`]}, - - {el.value} - - , - ); - } - }); - }; - - arrayTooltipText.push( - - {`${this.texts.ownerGroup}: ${(acl.ownerGroup || '').replace('system.group.', '')}`} - , - ); - arrayTooltipText.push( - {`${this.texts.ownerUser}: ${(acl.owner || '').replace('system.user.', '')}`}, - ); - funcRenderStateObject('object'); - if (acl.state) { - funcRenderStateObject('state'); - } - - return arrayTooltipText.length ? ( - {arrayTooltipText.map(el => el)} - ) : null; - }; - - renderColumnButtons(id: string, item: TreeItem): (JSX.Element | null)[] | JSX.Element | null { - if (!item.data.obj) { - return this.props.onObjectDelete || this.props.objectEditOfAccessControl ? ( -
- {this.state.filter.expertMode && this.props.objectEditOfAccessControl ? ( - - this.setState({ modalEditOfAccess: true, modalEditOfAccessObjData: item.data }) - } - size="large" - > -
---
-
- ) : null} - {this.props.onObjectDelete && item.children && item.children.length ? ( - { - // calculate the number of children - const keys = Object.keys(this.objects); - keys.sort(); - let count = 0; - const start = `${id}.`; - for (let i = 0; i < keys.length; i++) { - if (keys[i].startsWith(start)) { - count++; - } else if (keys[i] > start) { - break; - } - } - - if (this.props.onObjectDelete) { - this.props.onObjectDelete(id, !!item.children?.length, false, count + 1); - } - }} - > - - - ) : null} -
- ) : null; - } - - item.data.aclTooltip = - item.data.aclTooltip || this.renderTooltipAccessControl(item.data.obj.acl as ioBroker.StateACL); - - const acl = item.data.obj.acl - ? item.data.obj.type === 'state' - ? item.data.obj.acl.state - : item.data.obj.acl.object - : 0; - const aclSystemConfig = - item.data.obj.acl && - (item.data.obj.type === 'state' - ? this.systemConfig.common.defaultNewAcl.state - : this.systemConfig.common.defaultNewAcl.object); - - const showEdit = this.state.filter.expertMode || ObjectBrowserClass.isNonExpertId(item.data.id); - - return [ - this.state.filter.expertMode && this.props.objectEditOfAccessControl ? ( - - this.setState({ modalEditOfAccess: true, modalEditOfAccessObjData: item.data })} - size="large" - > -
- {Number.isNaN(Number(acl)) - ? Number(aclSystemConfig).toString(16) - : Number(acl).toString(16)} -
-
-
- ) : ( -
- ), - - showEdit ? ( - { - this.localStorage.setItem(`${this.props.dialogName || 'App'}.objectSelected`, id); - this.setState({ editObjectDialog: id, editObjectAlias: false }); - }} - > - - - ) : ( - - ), - - this.props.onObjectDelete && (item.children?.length || !item.data.obj.common?.dontDelete) ? ( - { - const keys = Object.keys(this.objects); - keys.sort(); - let count = 0; - const start = `${id}.`; - for (let i = 0; i < keys.length; i++) { - if (keys[i].startsWith(start)) { - count++; - } else if (keys[i] > start) { - break; - } - } - if (this.props.onObjectDelete) { - this.props.onObjectDelete( - id, - !!item.children?.length, - !item.data.obj?.common?.dontDelete, - count, - ); - } - }} - title={this.texts.deleteObject} - > - - - ) : null, - - this.props.objectCustomDialog && - this.info.hasSomeCustoms && - item.data.obj.type === 'state' && - // @ts-expect-error deprecated from js-controller 6 - item.data.obj.common?.type !== 'file' ? ( - { - this.localStorage.setItem(`${this.props.dialogName || 'App'}.objectSelected`, id); - - this.pauseSubscribe(true); - this.props.router?.doNavigate(null, 'customs', id); - this.setState({ customDialog: [id], customDialogAll: false }); - }} - > - - - ) : null, - ]; - } - - private readHistory(id: string): void { - /* interface GetHistoryOptions { - instance?: string; - start?: number; - end?: number; - step?: number; - count?: number; - from?: boolean; - ack?: boolean; - q?: boolean; - addID?: boolean; - limit?: number; - ignoreNull?: boolean; - sessionId?: any; - aggregate?: 'minmax' | 'min' | 'max' | 'average' | 'total' | 'count' | 'none'; - } */ - if ( - window.sparkline && - this.defaultHistory && - this.objects[id]?.common?.custom && - this.objects[id].common.custom[this.defaultHistory] - ) { - const now = new Date(); - now.setHours(now.getHours() - 24); - now.setMinutes(0); - now.setSeconds(0); - now.setMilliseconds(0); - const nowMs = now.getTime(); - - this.props.socket - .getHistory(id, { - instance: this.defaultHistory, - start: nowMs, - end: Date.now(), - step: 3600000, - from: false, - ack: false, - q: false, - addID: false, - aggregate: 'minmax', - }) - .then(values => { - const sparks: HTMLDivElement[] = window.document.getElementsByClassName( - 'sparkline', - ) as any as HTMLDivElement[]; - - for (let s = 0; s < sparks.length; s++) { - if (sparks[s].dataset.id === id) { - const v = prepareSparkData(values, nowMs); - - window.sparkline.sparkline(sparks[s], v); - break; - } - } - }) - .catch(e => console.warn(`Cannot read history: ${e}`)); - } - } - - private getTooltipInfo(id: string, cb?: () => void): void { - const obj = this.objects[id]; - const state = this.states[id]; - - const { valFull, fileViewer } = formatValue({ - state, - obj: obj as ioBroker.StateObject, - texts: this.texts, - dateFormat: this.props.dateFormat || this.systemConfig.common.dateFormat, - isFloatComma: - this.props.isFloatComma === undefined ? this.systemConfig.common.isFloatComma : this.props.isFloatComma, - full: true, - }); - const valFullRx: JSX.Element[] = []; - - valFull?.forEach(_item => { - if (_item.t === this.texts.quality && state.q) { - valFullRx.push( -
- {_item.t} - :  - {_item.v} -
, - ); - //
{item.v}
, - if (!_item.nbr) { - valFullRx.push(
); - } - } else { - valFullRx.push( -
- {_item.t} - :  -
, - ); - valFullRx.push( -
- {_item.v} -
, - ); - if (!_item.nbr) { - valFullRx.push(
); - } - } - }); - - if (fileViewer === 'image') { - valFullRx.push( - {id}, - ); - } else if ( - this.defaultHistory && - this.objects[id]?.common?.custom && - this.objects[id].common.custom[this.defaultHistory] - ) { - valFullRx.push( - , - ); - } - - this.setState({ tooltipInfo: { el: valFullRx, id } }, () => cb && cb()); - } - - private renderColumnValue(id: string, item: TreeItem, narrowStyleWithDetails?: boolean): JSX.Element | null { - const obj = item.data.obj; - if (!obj || !this.states) { - return null; - } - - if (obj.common?.type === 'file') { - return ( - - [file] - - ); - } - if (!this.states[id]) { - if (obj.type === 'state') { - // we are waiting for state - if (!this.recordStates.includes(id)) { - this.recordStates.push(id); - } - this.states[id] = { val: null } as ioBroker.State; - this.subscribe(id); - } - return null; - } - if (!this.recordStates.includes(id)) { - this.recordStates.push(id); - } - - const state = this.states[id]; - - let info = item.data.state; - if (!info) { - const { valText } = formatValue({ - state, - obj: obj as ioBroker.StateObject, - texts: this.texts, - dateFormat: this.props.dateFormat || this.systemConfig.common.dateFormat, - isFloatComma: - this.props.isFloatComma === undefined - ? this.systemConfig.common.isFloatComma - : this.props.isFloatComma, - }); - const valTextRx: JSX.Element[] = []; - item.data.state = { valTextRx }; - - const copyText = valText.v || ''; - valTextRx.push( - - {valText.v.toString()} - , - ); - if (valText.u) { - valTextRx.push( - - {valText.u} - , - ); - } - if (valText.s !== undefined) { - valTextRx.push( - - ({valText.s}) - , - ); - } - if (!narrowStyleWithDetails) { - valTextRx.push( - this.onCopy(e, copyText)} - key="cc" - />, - ); - } - // - - info = item.data.state; - } - - info.style = getValueStyle({ state, isExpertMode: this.state.filter.expertMode, isButton: item.data.button }); - - let val: JSX.Element[] = info.valTextRx; - if (!this.state.filter.expertMode) { - if (item.data.button) { - val = [ - , - ]; - } else if (item.data.switch) { - val = [ - , - ]; - } - } - - return ( - this.getTooltipInfo(id, () => this.readHistory(id))} - onClose={() => this.state.tooltipInfo?.id === id && this.setState({ tooltipInfo: null })} - > - - {val} - - - ); - } - - private _syncEnum(id: string, enumIds: string[], newArray: string[], cb: () => void): void { - if (!enumIds || !enumIds.length) { - if (cb) { - cb(); - } - return; - } - const enumId = enumIds.pop() || ''; - const promises = []; - if (this.info.objects[enumId]?.common) { - if (this.info.objects[enumId].common.members?.length) { - const pos = this.info.objects[enumId].common.members.indexOf(id); - if (pos !== -1 && !newArray.includes(enumId)) { - // delete it from members - const obj = JSON.parse(JSON.stringify(this.info.objects[enumId])); - obj.common.members.splice(pos, 1); - promises.push( - this.props.socket - .setObject(enumId, obj) - .then(() => (this.info.objects[enumId] = obj)) - .catch(e => this.showError(e)), - ); - } - } - - // add to it - if (newArray.includes(enumId) && !this.info.objects[enumId].common.members?.includes(id)) { - // add to object - const obj = JSON.parse(JSON.stringify(this.info.objects[enumId])); - obj.common.members = obj.common.members || []; - obj.common.members.push(id); - obj.common.members.sort(); - promises.push( - this.props.socket - .setObject(enumId, obj) - .then(() => (this.info.objects[enumId] = obj)) - .catch(e => this.showError(e)), - ); - } - } - - void Promise.all(promises).then(() => { - setTimeout(() => this._syncEnum(id, enumIds, newArray, cb), 0); - }); - } - - private syncEnum(id: string, enumName: 'func' | 'room', newArray: string[]): Promise { - const toCheck = [...this.info[enumName === 'func' ? 'funcEnums' : 'roomEnums']]; - - return new Promise(resolve => { - this._syncEnum(id, toCheck, newArray, () => { - // force update of an object - resolve(); - }); - }); - } - - private renderEnumDialog(): JSX.Element | null { - if (!this.state.enumDialog) { - return null; - } - const type = this.state.enumDialog.type; - const item = this.state.enumDialog.item; - const itemEnums: string[] = this.state.enumDialogEnums; - const enumsOriginal = this.state.enumDialog.enumsOriginal; - - const enums = (type === 'room' ? this.info.roomEnums : this.info.funcEnums) - .map(id => ({ - name: getName(this.objects[id]?.common?.name || id.split('.').pop() || '', this.props.lang), - value: id, - icon: getSelectIdIconFromObjects(this.objects, id, this.props.lang, this.imagePrefix), - })) - .sort((a, b) => (a.name > b.name ? 1 : -1)); - - enums.forEach(_item => { - if (_item.icon && typeof _item.icon === 'string') { - _item.icon = ( - - {_item.name} - - ); - } - }); - - // const hasIcons = !!enums.find(item => item.icon); - - return ( - this.setState({ enumDialog: null })} - aria-labelledby="enum-dialog-title" - open={!0} // true - > - - {type === 'func' ? this.props.t('ra_Define functions') : this.props.t('ra_Define rooms')} - - this.syncEnum(item.data.id, type, itemEnums).then(() => - this.setState({ enumDialog: null, enumDialogEnums: null }), - ) - } - > - - - - - {enums.map(_item => { - let id: string; - let name: string; - let icon: string | JSX.Element | null; - - if (typeof _item === 'object') { - id = _item.value; - name = _item.name; - icon = _item.icon; - } else { - id = _item; - name = _item; - } - const labelId = `checkbox-list-label-${id}`; - - return ( - { - const pos = itemEnums.indexOf(id); - const enumDialogEnums = JSON.parse(JSON.stringify(this.state.enumDialogEnums)); - if (pos === -1) { - enumDialogEnums.push(id); - enumDialogEnums.sort(); - } else { - enumDialogEnums.splice(pos, 1); - } - this.setState({ enumDialogEnums }); - }} - secondaryAction={icon} - > - - - - {name} - - ); - })} - - - ); - } - - private renderEditRoleDialog(): JSX.Element | null { - if (!this.state.roleDialog || !this.props.objectBrowserEditRole) { - return null; - } - - if (this.state.roleDialog && this.props.objectBrowserEditRole) { - const ObjectBrowserEditRole = this.props.objectBrowserEditRole; - - return ( - { - if (obj) { - this.info.objects[this.state.roleDialog] = obj; - } - this.setState({ roleDialog: null }); - }} - /> - ); - } - return null; - } - - private onColumnsEditCustomDialogClose(isSave?: boolean): void { - // cannot be null - const customColumnDialog: { - value: boolean | number | string; - type: 'boolean' | 'number' | 'string'; - initValue: boolean | number | string; - } = this.customColumnDialog as { - value: boolean | number | string; - type: 'boolean' | 'number' | 'string'; - initValue: boolean | number | string; - }; - - if (isSave) { - let value: string | number | boolean = customColumnDialog.value; - if (customColumnDialog.type === 'boolean') { - value = value === 'true' || value === true; - } else if (customColumnDialog.type === 'number') { - value = parseFloat(value as any as string); - } - this.customColumnDialog = null; - this.props.socket - .getObject(this.state.columnsEditCustomDialog?.obj?._id || '') - .then(obj => { - if (obj && ObjectBrowserClass.setCustomValue(obj, this.state.columnsEditCustomDialog?.it, value)) { - return this.props.socket.setObject(obj._id, obj); - } - throw new Error(this.props.t('ra_Cannot update attribute, because not found in the object')); - }) - .then(() => this.setState({ columnsEditCustomDialog: null })) - .catch(e => this.showError(e)); - } else { - this.customColumnDialog = null; - this.setState({ columnsEditCustomDialog: null }); - } - } - - private renderColumnsEditCustomDialog(): JSX.Element | null { - if (!this.state.columnsEditCustomDialog) { - return null; - } - if (!this.customColumnDialog) { - const value = ObjectBrowserClass.getCustomValue( - this.state.columnsEditCustomDialog.obj, - this.state.columnsEditCustomDialog.it, - ); - this.customColumnDialog = { - type: (this.state.columnsEditCustomDialog.it.type || typeof value) as 'boolean' | 'string' | 'number', - initValue: (value === null || value === undefined ? '' : value).toString(), - value: (value === null || value === undefined ? '' : value).toString(), - }; - } - - return ( - this.setState({ columnsEditCustomDialog: null })} - maxWidth="md" - aria-labelledby="custom-dialog-title" - open={!0} - > - - {`${this.props.t('ra_Edit object field')}: ${this.state.columnsEditCustomDialog.obj._id}`} - - - - {this.customColumnDialog.type === 'boolean' ? ( - e.key === 'Enter' && this.onColumnsEditCustomDialogClose(true)} - defaultChecked={this.customColumnDialog.value === 'true'} - onChange={e => { - const customColumnDialog: { - value: boolean | number | string; - type: 'boolean' | 'number' | 'string'; - initValue: boolean | number | string; - } = this.customColumnDialog as { - value: boolean | number | string; - type: 'boolean' | 'number' | 'string'; - initValue: boolean | number | string; - }; - - customColumnDialog.value = e.target.checked.toString(); - const changed = customColumnDialog.value !== customColumnDialog.initValue; - if (changed === !this.state.customColumnDialogValueChanged) { - this.setState({ customColumnDialogValueChanged: changed }); - } - }} - /> - } - label={`${this.state.columnsEditCustomDialog.it.name} (${this.state.columnsEditCustomDialog.it.pathText})`} - /> - ) : ( - e.key === 'Enter' && this.onColumnsEditCustomDialogClose(true)} - label={`${this.state.columnsEditCustomDialog.it.name} (${this.state.columnsEditCustomDialog.it.pathText})`} - onChange={e => { - const customColumnDialog: { - value: boolean | number | string; - type: 'boolean' | 'number' | 'string'; - initValue: boolean | number | string; - } = this.customColumnDialog as { - value: boolean | number | string; - type: 'boolean' | 'number' | 'string'; - initValue: boolean | number | string; - }; - - customColumnDialog.value = e.target.value; - const changed = customColumnDialog.value !== customColumnDialog.initValue; - if (changed === !this.state.customColumnDialogValueChanged) { - this.setState({ customColumnDialogValueChanged: changed }); - } - }} - autoFocus - /> - )} - - - - - - - - ); - } - - private static getCustomValue(obj: ioBroker.Object, it: AdapterColumn): string | number | boolean | null { - if (obj?._id?.startsWith(`${it.adapter}.`) && it.path.length > 1) { - const p = it.path; - let value; - const anyObj: Record = obj as Record; - if (anyObj[p[0]] && typeof anyObj[p[0]] === 'object') { - if (p.length === 2) { - // most common case - value = anyObj[p[0]][p[1]]; - } else if (p.length === 3) { - value = - anyObj[p[0]][p[1]] && typeof anyObj[p[0]][p[1]] === 'object' ? anyObj[p[0]][p[1]][p[2]] : null; - } else if (p.length === 4) { - value = - anyObj[p[0]][p[1]] && typeof anyObj[p[0]][p[1]] === 'object' && anyObj[p[0]][p[1]][p[2]] - ? anyObj[p[0]][p[1]][p[2]][p[3]] - : null; - } else if (p.length === 5) { - value = - anyObj[p[0]][p[1]] && - typeof anyObj[p[0]][p[1]] === 'object' && - anyObj[p[0]][p[1]][p[2]] && - anyObj[p[0]][p[1]][p[2]][p[3]] - ? anyObj[p[0]][p[1]][p[2]][p[3]][p[4]] - : null; - } else if (p.length === 6) { - value = - anyObj[p[0]][p[1]] && - typeof anyObj[p[0]][p[1]] === 'object' && - anyObj[p[0]][p[1]][p[2]] && - anyObj[p[0]][p[1]][p[2]][p[3]] && - anyObj[p[0]][p[1]][p[2]][p[3]][p[4]] - ? anyObj[p[0]][p[1]][p[2]][p[3]][p[4]][p[5]] - : null; - } - if (value === undefined || value === null) { - return null; - } - return value; - } - } - - return null; - } - - private static setCustomValue(obj: ioBroker.Object, it: AdapterColumn, value: string | number | boolean): boolean { - if (obj?._id?.startsWith(`${it.adapter}.`) && it.path.length > 1) { - const p = it.path; - const anyObj: Record = obj as Record; - if (anyObj[p[0]] && typeof anyObj[p[0]] === 'object') { - if (p.length === 2) { - // most common case - anyObj[p[0]][p[1]] = value; - return true; - } - if (p.length === 3) { - if (anyObj[p[0]][p[1]] && typeof anyObj[p[0]][p[1]] === 'object') { - anyObj[p[0]][p[1]][p[2]] = value; - return true; - } - } else if (p.length === 4) { - if ( - anyObj[p[0]][p[1]] && - typeof anyObj[p[0]][p[1]] === 'object' && - anyObj[p[0]][p[1]][p[2]] && - typeof anyObj[p[0]][p[1]][p[2]] === 'object' - ) { - anyObj[p[0]][p[1]][p[2]][p[3]] = value; - return true; - } - } else if (p.length === 5) { - if ( - anyObj[p[0]][p[1]] && - typeof anyObj[p[0]][p[1]] === 'object' && - anyObj[p[0]][p[1]][p[2]] && - typeof anyObj[p[0]][p[1]][p[2]] === 'object' && - anyObj[p[0]][p[1]][p[2]][p[3]] && - typeof anyObj[p[0]][p[1]][p[2]][p[3]] === 'object' - ) { - anyObj[p[0]][p[1]][p[2]][p[3]][p[4]] = value; - return true; - } - } else if (p.length === 6) { - if ( - anyObj[p[0]][p[1]] && - typeof anyObj[p[0]][p[1]] === 'object' && - anyObj[p[0]][p[1]][p[2]] && - typeof anyObj[p[0]][p[1]][p[2]] === 'object' && - anyObj[p[0]][p[1]][p[2]][p[3]] && - typeof anyObj[p[0]][p[1]][p[2]][p[3]] === 'object' && - anyObj[p[0]][p[1]][p[2]][p[3]][p[4]] && - typeof anyObj[p[0]][p[1]][p[2]][p[3]][p[4]] === 'object' - ) { - anyObj[p[0]][p[1]][p[2]][p[3]][p[4]][p[5]] = value; - return true; - } - } - } - } - return false; - } - - /** - * Renders a custom value. - */ - renderCustomValue(obj: ioBroker.Object, it: AdapterColumn, item: TreeItem): JSX.Element | null { - const text = ObjectBrowserClass.getCustomValue(obj, it); - if (text !== null && text !== undefined) { - if (it.edit && !this.props.notEditable && (!it.objTypes || it.objTypes.includes(obj.type))) { - return ( - - this.setState({ - columnsEditCustomDialog: { item, it, obj }, - customColumnDialogValueChanged: false, - }) - } - > - {text} - - ); - } - return ( - - {text} - - ); - } - return null; - } - - renderAliasLink(id: string, index?: number, customStyle?: Record): JSX.Element | null { - const _index = index || 0; - // read the type of operation - const aliasObj = this.objects[this.info.aliasesMap[id][_index]].common.alias.id; - if (aliasObj) { - return ( - { - e.stopPropagation(); - e.preventDefault(); - const aliasId = this.info.aliasesMap[id][_index]; - // if more than one alias, close the menu - if (this.info.aliasesMap[id].length > 1) { - this.setState({ aliasMenu: '' }); - } - this.onSelect(aliasId); - setTimeout(() => this.expandAllSelected(() => this.scrollToItem(aliasId)), 100); - }} - sx={customStyle || this.styles.aliasAlone} - > - - {typeof aliasObj === 'string' || (aliasObj.read === id && aliasObj.write === id) - ? '↔' - : aliasObj.read === id - ? '→' - : '←'} - - {this.info.aliasesMap[id][_index]} - - ); - } - - return null; - } - - /** - * Renders a leaf. - */ - renderLeaf( - item: TreeItem, - isExpanded: boolean | undefined, - counter: { count: number }, - ): { row: JSX.Element; details: JSX.Element | null } { - const id = item.data.id; - counter.count++; - isExpanded = isExpanded === undefined ? this.state.expanded.includes(id) : isExpanded; - - // icon - let iconFolder; - const obj = item.data.obj; - const itemType = obj?.type; - - if ( - item.children || - itemType === 'folder' || - itemType === 'device' || - itemType === 'channel' || - itemType === 'meta' - ) { - iconFolder = isExpanded ? ( - this.toggleExpanded(id)} - /> - ) : ( - this.toggleExpanded(id)} - /> - ); - } else if (obj && obj.common && obj.common.write === false && obj.type === 'state') { - iconFolder = ; - } else { - iconFolder = ; - } - - let iconItem = null; - if (item.data.icon) { - if (typeof item.data.icon === 'string') { - if (item.data.icon.length < 3) { - iconItem = ( - - {item.data.icon} - - ); // utf-8 char - } else { - iconItem = ( - - ); - } - } else { - iconItem = item.data.icon; - } - } - - const common = obj?.common; - - const typeImg = (obj?.type && ITEM_IMAGES[obj.type]) ||
; - - const paddingLeft = this.levelPadding * (item.data.level || 0); - - // recalculate rooms and function names if the language changed - if (item.data.lang !== this.props.lang) { - const { rooms, per } = findRoomsForObject(this.info, id, this.props.lang); - item.data.rooms = rooms.join(', '); - item.data.per = per; - const { funcs, pef } = findFunctionsForObject(this.info, id, this.props.lang); - item.data.funcs = funcs.join(', '); - item.data.pef = pef; - item.data.lang = this.props.lang; - } - - const checkbox = - this.props.multiSelect && - this.objects[id] && - (!this.props.types || this.props.types.includes(this.objects[id].type)) ? ( - - ) : null; - - let valueEditable = - !this.props.notEditable && - itemType === 'state' && - (this.state.filter.expertMode || common?.write !== false); - if (this.props.objectBrowserViewFile && common?.type === 'file') { - valueEditable = true; - } - const enumEditable = - !this.props.notEditable && - this.objects[id] && - (this.state.filter.expertMode || itemType === 'state' || itemType === 'channel' || itemType === 'device'); - - const checkVisibleObjectType = - this.state.statesView && (itemType === 'state' || itemType === 'channel' || itemType === 'device'); - - let newValue = ''; - const newValueTitle = []; - if (checkVisibleObjectType) { - newValue = this.states[id]?.from; - if (newValue === undefined) { - newValue = ' '; - } else { - newValue = newValue ? newValue.replace(/^system\.adapter\.|^system\./, '') : ''; - newValueTitle.push(`${this.texts.stateChangedFrom} ${newValue}`); - } - if (obj?.user) { - const user = obj.user.replace('system.user.', ''); - newValue += `/${user}`; - newValueTitle.push(`${this.texts.stateChangedBy} ${user}`); - } - } - - if (obj) { - if (obj.from) { - newValueTitle.push( - `${this.texts.objectChangedFrom} ${obj.from.replace(/^system\.adapter\.|^system\./, '')}`, - ); - } - if (obj.user) { - newValueTitle.push(`${this.texts.objectChangedBy} ${obj.user.replace(/^system\.user\./, '')}`); - } - if (obj.ts) { - newValueTitle.push( - `${this.texts.objectChangedByUser} ${Utils.formatDate(new Date(obj.ts), this.props.dateFormat || this.systemConfig.common.dateFormat)}`, - ); - } - } - - let readWriteAlias = false; - let alias: JSX.Element | null = null; - if (id.startsWith('alias.') && common?.alias?.id) { - readWriteAlias = typeof common.alias.id === 'object'; - if (readWriteAlias) { - alias = ( -
- {common.alias.id.read ? ( - { - e.stopPropagation(); - e.preventDefault(); - this.onSelect(common.alias.id.read); - setTimeout( - () => this.expandAllSelected(() => this.scrollToItem(common.alias.id.read)), - 100, - ); - }} - sx={this.styles.aliasReadWrite} - > - ←{common.alias.id.read} - - ) : null} - {common.alias.id.write ? ( - { - e.stopPropagation(); - e.preventDefault(); - this.onSelect(common.alias.id.write); - setTimeout( - () => this.expandAllSelected(() => this.scrollToItem(common.alias.id.write)), - 100, - ); - }} - sx={this.styles.aliasReadWrite} - > - →{common.alias.id.write} - - ) : null} -
- ); - } else { - alias = ( - { - e.stopPropagation(); - e.preventDefault(); - this.onSelect(common.alias.id); - setTimeout(() => this.expandAllSelected(() => this.scrollToItem(common.alias.id)), 100); - }} - sx={this.styles.aliasAlone} - > - →{common.alias.id} - - ); - } - } else if (this.info.aliasesMap[id]) { - // Some alias points to this object. It can be more than one - if (this.info.aliasesMap[id].length > 1) { - // Show number of aliases and open a menu by click - alias = ( - { - e.stopPropagation(); - e.preventDefault(); - this.setState({ aliasMenu: id }); - }} - sx={this.styles.aliasAlone} - > - {this.props.t('ra_%s links from aliases', this.info.aliasesMap[id].length)} - - ); - } else { - // Show name of alias and open it by click - alias = this.renderAliasLink(id, 0); - } - } - - let checkColor = common?.color; - let invertBackground; - if (checkColor && !this.state.selected.includes(id)) { - const background = - this.props.themeName === 'dark' ? '#1f1f1f' : this.props.themeName === 'blue' ? '#222a2e' : '#FFFFFF'; - const distance = Utils.colorDistance(checkColor, background); - // console.log(`Distance: ${checkColor} - ${background} = ${distance}`); - if (distance < 1000) { - invertBackground = this.props.themeType === 'dark' ? '#9a9a9a' : '#565656'; - } - } - let bold = false; - if (id === '0_userdata') { - checkColor = COLOR_NAME_USERDATA(this.props.themeType); - bold = true; - } else if (id === 'alias') { - checkColor = COLOR_NAME_ALIAS(this.props.themeType); - bold = true; - } else if (id === 'javascript') { - checkColor = COLOR_NAME_JAVASCRIPT(this.props.themeType); - bold = true; - } else if (id === 'system') { - checkColor = COLOR_NAME_SYSTEM(this.props.themeType); - bold = true; - } else if (id === 'system.adapter') { - checkColor = COLOR_NAME_SYSTEM_ADAPTER(this.props.themeType); - } else if (!checkColor || this.state.selected.includes(id)) { - checkColor = 'inherit'; - } - - const icons = []; - - if (common?.statusStates) { - const ids: Record = {}; - Object.keys(common.statusStates).forEach(name => { - let _id = common.statusStates[name]; - if (_id.split('.').length < 3) { - _id = `${id}.${_id}`; - } - ids[name] = _id; - - if (!this.states[_id]) { - if (this.objects[_id]?.type === 'state') { - if (!this.recordStates.includes(_id)) { - this.recordStates.push(_id); - } - this.states[_id] = { val: null } as ioBroker.State; - this.subscribe(_id); - } - } else if (!this.recordStates.includes(_id)) { - this.recordStates.push(_id); - } - }); - // calculate color - // errorId has priority - let colorSet = false; - if (common.statusStates.errorId && this.states[ids.errorId] && this.states[ids.errorId].val) { - checkColor = this.props.themeType === 'dark' ? COLOR_NAME_ERROR_DARK : COLOR_NAME_ERROR_LIGHT; - colorSet = true; - icons.push( - , - ); - } - - if (ids.onlineId && this.states[ids.onlineId]) { - if (!colorSet) { - if (this.states[ids.onlineId].val) { - checkColor = - this.props.themeType === 'dark' ? COLOR_NAME_CONNECTED_DARK : COLOR_NAME_CONNECTED_LIGHT; - icons.push( - , - ); - } else { - checkColor = - this.props.themeType === 'dark' - ? COLOR_NAME_DISCONNECTED_DARK - : COLOR_NAME_DISCONNECTED_LIGHT; - icons.push( - , - ); - } - } else if (this.states[ids.onlineId].val) { - icons.push( - , - ); - } else { - icons.push( - , - ); - } - } else if (ids.offlineId && this.states[ids.offlineId]) { - if (!colorSet) { - if (this.states[ids.offlineId].val) { - checkColor = - this.props.themeType === 'dark' - ? COLOR_NAME_DISCONNECTED_DARK - : COLOR_NAME_DISCONNECTED_LIGHT; - icons.push( - , - ); - } else { - checkColor = - this.props.themeType === 'dark' ? COLOR_NAME_CONNECTED_DARK : COLOR_NAME_CONNECTED_LIGHT; - icons.push( - , - ); - } - } else if (this.states[ids.offlineId].val) { - icons.push( - , - ); - } else { - icons.push( - , - ); - } - } - } - - const q = checkVisibleObjectType ? Utils.quality2text(this.states[id]?.q || 0).join(', ') : null; - - let name: JSX.Element[] | string = item.data?.title || ''; - let useDesc = false; - if (this.state.showDescription) { - const oTooltip: string | null = getObjectTooltip(item.data, this.props.lang); - if (oTooltip) { - name = [ -
- {name} -
, -
- {oTooltip} -
, - ]; - useDesc = !!oTooltip; - } - } - - const narrowStyleWithDetails = this.props.width === 'xs' && this.state.focused === id; - - const colID = ( - - - {checkbox} - {iconFolder} - - - -
{item.data.name}
-
- {alias} - {icons} -
-
- - {iconItem} - - {this.props.width !== 'xs' ? ( -
- this.onCopy(e, id)} - /> -
- ) : null} - - ); - - let colName = - (narrowStyleWithDetails && name) || this.columnsVisibility.name ? ( - - {name} - {!narrowStyleWithDetails && item.data?.title ? ( - - this.onCopy(e, item.data?.title)} - /> - - ) : null} - - ) : null; - - let colMiddle: - | ({ - el: JSX.Element; - type: - | 'filter_type' - | 'filter_role' - | 'filter_func' - | 'filter_room' - | 'quality' - | 'from' - | 'lc' - | 'ts'; - onClick?: (() => void) | null | undefined; - } | null)[] - | null; - if (!this.state.statesView) { - colMiddle = [ - (narrowStyleWithDetails && obj?.type) || this.columnsVisibility.type - ? { - el: ( -
- {typeImg} -   - {obj?.type} -
- ), - type: 'filter_type', - } - : null, - (narrowStyleWithDetails && common) || this.columnsVisibility.role - ? { - el: ( -
this.setState({ roleDialog: item.data.id }) - : undefined - } - > - {common?.role} -
- ), - type: 'filter_role', - onClick: - narrowStyleWithDetails && - this.state.filter.expertMode && - enumEditable && - this.props.objectBrowserEditRole - ? () => this.setState({ roleDialog: item.data.id }) - : undefined, - } - : null, - (narrowStyleWithDetails && common) || this.columnsVisibility.room - ? { - el: ( -
{ - const enums = findEnumsForObjectAsIds( - this.info, - item.data.id, - 'roomEnums', - ); - this.setState({ - enumDialogEnums: enums, - enumDialog: { - item, - type: 'room', - enumsOriginal: JSON.stringify(enums), - }, - }); - } - : undefined - } - > - {item.data.rooms} -
- ), - type: 'filter_room', - onClick: - narrowStyleWithDetails && enumEditable - ? () => { - const enums = findEnumsForObjectAsIds(this.info, item.data.id, 'roomEnums'); - this.setState({ - enumDialogEnums: enums, - enumDialog: { - item, - type: 'room', - enumsOriginal: JSON.stringify(enums), - }, - }); - } - : undefined, - } - : null, - (narrowStyleWithDetails && common) || this.columnsVisibility.func - ? { - el: ( -
{ - const enums = findEnumsForObjectAsIds( - this.info, - item.data.id, - 'funcEnums', - ); - this.setState({ - enumDialogEnums: enums, - enumDialog: { - item, - type: 'func', - enumsOriginal: JSON.stringify(enums), - }, - }); - } - : undefined - } - > - {item.data.funcs} -
- ), - type: 'filter_func', - onClick: - narrowStyleWithDetails && enumEditable - ? () => { - const enums = findEnumsForObjectAsIds(this.info, item.data.id, 'funcEnums'); - this.setState({ - enumDialogEnums: enums, - enumDialog: { - item, - type: 'func', - enumsOriginal: JSON.stringify(enums), - }, - }); - } - : undefined, - } - : null, - ]; - } else { - colMiddle = [ - (narrowStyleWithDetails && checkVisibleObjectType && this.states[id]?.from) || - this.columnsVisibility.changedFrom - ? { - el: ( -
- {checkVisibleObjectType && this.states[id]?.from ? newValue : null} -
- ), - type: 'from', - } - : null, - (narrowStyleWithDetails && q) || this.columnsVisibility.qualityCode - ? { - el: ( -
- {q} -
- ), - type: 'quality', - } - : null, - (narrowStyleWithDetails && checkVisibleObjectType && this.states[id]?.ts) || - this.columnsVisibility.timestamp - ? { - el: ( -
- {checkVisibleObjectType && this.states[id]?.ts - ? Utils.formatDate( - new Date(this.states[id].ts), - this.props.dateFormat || this.systemConfig.common.dateFormat, - ) - : null} -
- ), - type: 'ts', - } - : null, - (narrowStyleWithDetails && checkVisibleObjectType && this.states[id]?.lc) || - this.columnsVisibility.lastChange - ? { - el: ( -
- {checkVisibleObjectType && this.states[id]?.lc - ? Utils.formatDate( - new Date(this.states[id].lc), - this.props.dateFormat || this.systemConfig.common.dateFormat, - ) - : null} -
- ), - type: 'lc', - } - : null, - ]; - } - - let colCustom: JSX.Element[] | null = - this.adapterColumns?.map(it => ( -
)[it.id] - : undefined, - }} - key={it.id} - title={`${it.adapter} => ${it.pathText}`} - > - {obj ? this.renderCustomValue(obj, it, item) : null} -
- )) || null; - - const columnValue = - narrowStyleWithDetails || this.columnsVisibility.val - ? this.renderColumnValue(id, item, narrowStyleWithDetails) - : null; - - let colValue = - (narrowStyleWithDetails && columnValue) || this.columnsVisibility.val ? ( -
{ - if (!obj || !this.states) { - // return; - } else if (common?.type === 'file') { - this.setState({ viewFileDialog: id }); - } else if (!this.state.filter.expertMode && item.data.button) { - // in non-expert mode control button directly - this.props.socket - .setState(id, true) - .catch(e => window.alert(`Cannot write state "${id}": ${e}`)); - } else if (!this.state.filter.expertMode && item.data.switch) { - // in non-expert mode control switch directly - this.props.socket - .setState(id, !this.states[id].val) - .catch(e => window.alert(`Cannot write state "${id}": ${e}`)); - } else { - this.edit = { - val: this.states[id] ? this.states[id].val : '', - q: this.states[id] ? this.states[id].q || 0 : 0, - ack: false, - id, - }; - this.setState({ updateOpened: true }); - } - } - : undefined - } - > - {columnValue} -
- ) : null; - - let colButtons = - narrowStyleWithDetails || this.columnsVisibility.buttons ? ( -
- {this.renderColumnButtons(id, item)} -
- ) : null; - - let colDetails: JSX.Element | null = null; - if (this.props.width === 'xs' && this.state.focused === id) { - colMiddle = colMiddle.filter(a => a); - let renderedMiddle: (JSX.Element | null)[] | null; - if (!colMiddle.length) { - renderedMiddle = null; - } else { - renderedMiddle = colMiddle.map(it => { - if (!it) { - return null; - } - return ( -
- {this.texts[it.type]}: - {it.el} -
- {it.onClick ? ( - { - if (it?.onClick) { - it.onClick(); - } - }} - /> - ) : null} -
- ); - }); - } - if (!colCustom.length) { - colCustom = null; - } - colDetails = ( - -
-
- this.onCopy(e, id)} - /> -
- {colName && ( -
- {this.texts.name}: - {colName} -
- {item.data?.title ? ( - this.onCopy(e, item.data?.title)} - /> - ) : null} -
- )} - {renderedMiddle} - {colCustom &&
{colCustom}
} - {this.objects[id]?.type === 'state' && ( -
- {this.texts.value}: - {colValue} -
- { - const { valText } = formatValue({ - state: this.states[id], - obj: this.objects[id] as ioBroker.StateObject, - texts: this.texts, - dateFormat: this.props.dateFormat || this.systemConfig.common.dateFormat, - isFloatComma: - this.props.isFloatComma === undefined - ? this.systemConfig.common.isFloatComma - : this.props.isFloatComma, - }); - this.onCopy(e, valText.v.toString()); - }} - key="cc" - /> -
- )} - {colButtons && ( -
{colButtons}
- )} - - ); - - colName = null; - colMiddle = null; - colCustom = null; - colValue = null; - colButtons = null; - } - - const row = ( - { - this.onSelect(id); - let isRightMB; - if ('which' in e) { - // Gecko (Firefox), WebKit (Safari/Chrome) & Opera - isRightMB = e.which === 3; - } else if ('button' in e) { - // IE, Opera - isRightMB = e.button === 2; - } - if (isRightMB) { - this.contextMenu = { - item, - ts: Date.now(), - }; - } else { - this.contextMenu = null; - } - }} - onDoubleClick={() => { - if (!item.children) { - this.onSelect(id, true); - } else { - this.toggleExpanded(id); - } - }} - > - {colID} - {colName} - {colMiddle?.map(it => it?.el)} - {colCustom} - {colValue} - {colButtons} - - ); - return { row, details: colDetails }; - } - - /** - * Renders an item. - */ - renderItem(root: TreeItem, isExpanded: boolean | undefined, counter?: { count: number }): JSX.Element[] { - const items: (JSX.Element | null)[] = []; - counter = counter || { count: 0 }; - const result = this.renderLeaf(root, isExpanded, counter); - let leaf: JSX.Element; - const DragWrapper = this.props.DragWrapper; - if (this.props.dragEnabled && DragWrapper) { - if (root.data.sumVisibility) { - leaf = ( - - {result.row} - - ); - } else { - // change cursor - leaf = ( -
- {result.row} -
- ); - } - } else { - leaf = result.row; - } - if (root.data.id && leaf) { - items.push(leaf); - } - if (result.details) { - items.push(result.details); - } - - isExpanded = isExpanded === undefined ? binarySearch(this.state.expanded, root.data.id) : isExpanded; - - if (!root.data.id || isExpanded) { - if (!this.state.foldersFirst) { - if (root.children) { - items.push( - root.children.map(item => { - // do not render too many items in column editor mode - if (!this.state.columnsSelectorShow || counter.count < 15) { - if (item.data.sumVisibility) { - return this.renderItem(item, undefined, counter); - } - } - return null; - }) as any as JSX.Element, - ); - } - } else if (root.children) { - // first only folder - items.push( - root.children.map(item => { - if (item.children) { - // do not render too many items in column editor mode - if (!this.state.columnsSelectorShow || counter.count < 15) { - if (item.data.sumVisibility) { - return this.renderItem(item, undefined, counter); - } - } - } - - return null; - }) as any as JSX.Element, - ); - - // then items - items.push( - root.children.map(item => { - if (!item.children) { - // do not render too many items in column editor mode - if (!this.state.columnsSelectorShow || counter.count < 15) { - if (item.data.sumVisibility) { - return this.renderItem(item, undefined, counter); - } - } - } - return null; - }) as any as JSX.Element, - ); - } - } - - return items; - } - - private calculateColumnsVisibility( - aColumnsAuto?: boolean | null, - aColumns?: string[] | null, - aColumnsForAdmin?: Record | null, - aColumnsWidths?: Record, - ): void { - let columnsWidths: Record = aColumnsWidths || this.state.columnsWidths; - const columnsForAdmin: Record | null = - aColumnsForAdmin || this.state.columnsForAdmin; - const columns: string[] = aColumns || this.state.columns || []; - const columnsAuto: boolean = typeof aColumnsAuto !== 'boolean' ? this.state.columnsAuto : aColumnsAuto; - - columnsWidths = JSON.parse(JSON.stringify(columnsWidths)); - Object.keys(columnsWidths).forEach(name => { - if (columnsWidths[name]) { - columnsWidths[name] = parseInt(columnsWidths[name] as any as string, 10) || 0; - } - }); - - this.adapterColumns = []; - const WIDTHS = SCREEN_WIDTHS[this.props.width || 'lg'].widths; - - if (columnsAuto) { - this.columnsVisibility = { - id: SCREEN_WIDTHS[this.props.width || 'lg'].idWidth, - name: this.visibleCols.includes('name') ? WIDTHS.name || 0 : 0, - nameHeader: this.visibleCols.includes('name') ? WIDTHS.name || 0 : 0, - type: this.visibleCols.includes('type') ? WIDTHS.type || 0 : 0, - role: this.visibleCols.includes('role') ? WIDTHS.role || 0 : 0, - room: this.visibleCols.includes('room') ? WIDTHS.room || 0 : 0, - func: this.visibleCols.includes('func') ? WIDTHS.func || 0 : 0, - changedFrom: this.visibleCols.includes('changedFrom') ? WIDTHS.changedFrom || 0 : 0, - qualityCode: this.visibleCols.includes('qualityCode') ? WIDTHS.qualityCode || 0 : 0, - timestamp: this.visibleCols.includes('timestamp') ? WIDTHS.timestamp || 0 : 0, - lastChange: this.visibleCols.includes('lastChange') ? WIDTHS.lastChange || 0 : 0, - val: this.visibleCols.includes('val') ? WIDTHS.val || 0 : 0, - buttons: this.visibleCols.includes('buttons') ? WIDTHS.buttons || 0 : 0, - }; - - // in xs name is not visible - if (this.columnsVisibility.name && !this.customWidth) { - let widthSum: number = (this.columnsVisibility.id as number) || 0; // id is always visible - if (this.state.statesView) { - widthSum += this.columnsVisibility.changedFrom || 0; - widthSum += this.columnsVisibility.qualityCode || 0; - widthSum += this.columnsVisibility.timestamp || 0; - widthSum += this.columnsVisibility.lastChange || 0; - } else { - widthSum += this.columnsVisibility.type || 0; - widthSum += this.columnsVisibility.role || 0; - widthSum += this.columnsVisibility.room || 0; - widthSum += this.columnsVisibility.func || 0; - } - widthSum += this.columnsVisibility.val || 0; - widthSum += this.columnsVisibility.buttons || 0; - this.columnsVisibility.name = `calc(100% - ${widthSum + 5}px)`; - this.columnsVisibility.nameHeader = `calc(100% - ${widthSum + 5 + this.state.scrollBarWidth}px)`; - } else if (!this.customWidth) { - // Calculate the width of ID - let widthSum = 0; // id is always visible - if (this.state.statesView) { - widthSum += this.columnsVisibility.changedFrom || 0; - widthSum += this.columnsVisibility.qualityCode || 0; - widthSum += this.columnsVisibility.timestamp || 0; - widthSum += this.columnsVisibility.lastChange || 0; - } else { - widthSum += this.columnsVisibility.type || 0; - widthSum += this.columnsVisibility.role || 0; - widthSum += this.columnsVisibility.room || 0; - widthSum += this.columnsVisibility.func || 0; - } - widthSum += this.columnsVisibility.val || 0; - widthSum += this.columnsVisibility.buttons || 0; - this.columnsVisibility.id = `calc(100% - ${widthSum + 5}px)`; - } - } else { - const width = this.props.width || 'lg'; - this.columnsVisibility = { - id: columnsWidths.id || SCREEN_WIDTHS[width].idWidth, - name: columns.includes('name') - ? columnsWidths.name || WIDTHS.name || SCREEN_WIDTHS[width].widths.name || 0 - : 0, - type: columns.includes('type') - ? columnsWidths.type || WIDTHS.type || SCREEN_WIDTHS[width].widths.type || 0 - : 0, - role: columns.includes('role') - ? columnsWidths.role || WIDTHS.role || SCREEN_WIDTHS[width].widths.role || 0 - : 0, - room: columns.includes('room') - ? columnsWidths.room || WIDTHS.room || SCREEN_WIDTHS[width].widths.room || 0 - : 0, - func: columns.includes('func') - ? columnsWidths.func || WIDTHS.func || SCREEN_WIDTHS[width].widths.func || 0 - : 0, - }; - let widthSum: number = this.columnsVisibility.id as number; // id is always visible - if (this.columnsVisibility.name) { - widthSum += this.columnsVisibility.type || 0; - widthSum += this.columnsVisibility.role || 0; - widthSum += this.columnsVisibility.room || 0; - widthSum += this.columnsVisibility.func || 0; - } - - if (columnsForAdmin && columns) { - Object.keys(columnsForAdmin) - .sort() - .forEach(adapter => - columnsForAdmin[adapter].forEach(column => { - const id = `_${adapter}_${column.path}`; - if (columns.includes(id)) { - const item: AdapterColumn = { - adapter, - id: `_${adapter}_${column.path}`, - name: column.name, - path: column.path.split('.'), - pathText: column.path, - }; - if (column.edit) { - item.edit = true; - if (column.type) { - item.type = column.type as 'number' | 'boolean' | 'string'; - } - if (column.objTypes) { - item.objTypes = column.objTypes; - } - } - - this.adapterColumns.push(item); - (this.columnsVisibility as Record)[id] = - columnsWidths[item.id] || - column.width || - SCREEN_WIDTHS[width].widths.func || - SCREEN_WIDTHS.xl.widths.func || - 0; - widthSum += (this.columnsVisibility as Record)[id]; - } else { - (this.columnsVisibility as Record)[id] = 0; - } - }), - ); - } - this.adapterColumns.sort((a, b) => (a.id > b.id ? -1 : a.id < b.id ? 1 : 0)); - this.columnsVisibility.val = columns.includes('val') - ? columnsWidths.val || WIDTHS.val || SCREEN_WIDTHS.xl.widths.val - : 0; - - // do not show buttons if not desired - if (!this.props.columns || this.props.columns.includes('buttons')) { - this.columnsVisibility.buttons = columns.includes('buttons') - ? columnsWidths.buttons || WIDTHS.buttons || SCREEN_WIDTHS.xl.widths.buttons - : 0; - widthSum += this.columnsVisibility.buttons || 0; - } - - if (this.columnsVisibility.name && !columnsWidths.name) { - widthSum += this.columnsVisibility.val || 0; - this.columnsVisibility.name = `calc(100% - ${widthSum}px)`; - this.columnsVisibility.nameHeader = `calc(100% - ${widthSum + 5 + this.state.scrollBarWidth}px)`; - } else { - const newWidth = Object.keys(this.columnsVisibility).reduce((accumulator: number, name: string) => { - // do not summarize strings - if ( - name === 'id' || - typeof (this.columnsVisibility as Record)[name] === 'string' || - !(this.columnsVisibility as Record)[name] - ) { - return accumulator; - } - return accumulator + (this.columnsVisibility as Record)[name]; - }, 0); - this.columnsVisibility.id = `calc(100% - ${newWidth}px)`; - } - } - } - - resizerMouseMove = (e: MouseEvent): void => { - if (this.resizerActiveDiv) { - let width: number; - let widthNext: number; - if (this.resizeLeft) { - width = this.resizerOldWidth - e.clientX + this.resizerPosition; - widthNext = this.resizerOldWidthNext + e.clientX - this.resizerPosition; - } else { - width = this.resizerOldWidth + e.clientX - this.resizerPosition; - widthNext = this.resizerOldWidthNext - e.clientX + this.resizerPosition; - } - - if ( - this.resizerActiveName && - this.resizerNextName && - (!this.resizerMin || width > this.resizerMin) && - (!this.resizerNextMin || widthNext > this.resizerNextMin) - ) { - this.resizerCurrentWidths[this.resizerActiveName] = width; - this.resizerCurrentWidths[this.resizerNextName] = widthNext; - - this.resizerActiveDiv.style.width = `${width}px`; - if (this.resizerNextDiv) { - this.resizerNextDiv.style.width = `${widthNext}px`; - } - - (this.columnsVisibility as Record)[this.resizerActiveName] = width; - (this.columnsVisibility as Record)[this.resizerNextName] = widthNext; - if (this.resizerNextName === 'nameHeader') { - this.columnsVisibility.name = widthNext - this.state.scrollBarWidth; - this.resizerCurrentWidths.name = widthNext - this.state.scrollBarWidth; - } else if (this.resizerActiveName === 'nameHeader') { - this.columnsVisibility.name = width - this.state.scrollBarWidth; - this.resizerCurrentWidths.name = width - this.state.scrollBarWidth; - } - this.customWidth = true; - if (this.resizeTimeout) { - clearTimeout(this.resizeTimeout); - } - this.resizeTimeout = setTimeout(() => { - this.resizeTimeout = null; - this.forceUpdate(); - }, 200); - } - } - }; - - resizerMouseUp = (): void => { - this.localStorage.setItem(`${this.props.dialogName || 'App'}.table`, JSON.stringify(this.resizerCurrentWidths)); - this.resizerActiveName = null; - this.resizerNextName = null; - this.resizerActiveDiv = null; - this.resizerNextDiv = null; - window.removeEventListener('mousemove', this.resizerMouseMove); - window.removeEventListener('mouseup', this.resizerMouseUp); - }; - - resizerMouseDown = (e: React.MouseEvent): void => { - this.storedWidths = - this.storedWidths || - (JSON.parse(JSON.stringify(SCREEN_WIDTHS[this.props.width || 'lg'])) as ScreenWidthOne); - - this.resizerCurrentWidths = this.resizerCurrentWidths || {}; - this.resizerActiveDiv = (e.target as HTMLDivElement).parentNode as HTMLDivElement; - this.resizerActiveName = this.resizerActiveDiv.dataset.name || null; - if (this.resizerActiveName) { - let i = 0; - if ((e.target as HTMLDivElement).dataset.left === 'true') { - this.resizeLeft = true; - this.resizerNextDiv = this.resizerActiveDiv.previousElementSibling as HTMLDivElement; - let handle: HTMLDivElement | null = this.resizerNextDiv.querySelector('.iob-ob-resize-handler'); - while (this.resizerNextDiv && !handle && i < 10) { - this.resizerNextDiv = this.resizerNextDiv.previousElementSibling as HTMLDivElement; - handle = this.resizerNextDiv.querySelector('.iob-ob-resize-handler'); - i++; - } - if (handle?.dataset.left !== 'true') { - this.resizerNextDiv = this.resizerNextDiv.nextElementSibling as HTMLDivElement; - } - } else { - this.resizeLeft = false; - this.resizerNextDiv = this.resizerActiveDiv.nextElementSibling as HTMLDivElement; - /* while (this.resizerNextDiv && !this.resizerNextDiv.querySelector('.iob-ob-resize-handler') && i < 10) { - this.resizerNextDiv = this.resizerNextDiv.nextElementSibling; - i++; - } */ - } - this.resizerNextName = this.resizerNextDiv.dataset.name || null; - - this.resizerMin = parseInt(this.resizerActiveDiv.dataset.min, 10) || 0; - this.resizerNextMin = parseInt(this.resizerNextDiv.dataset.min, 10) || 0; - - this.resizerPosition = e.clientX; - - this.resizerCurrentWidths[this.resizerActiveName] = this.resizerActiveDiv.offsetWidth; - this.resizerOldWidth = this.resizerCurrentWidths[this.resizerActiveName]; - - if (this.resizerNextName) { - this.resizerCurrentWidths[this.resizerNextName] = this.resizerNextDiv.offsetWidth; - this.resizerOldWidthNext = this.resizerCurrentWidths[this.resizerNextName]; - } - - window.addEventListener('mousemove', this.resizerMouseMove); - window.addEventListener('mouseup', this.resizerMouseUp); - } - }; - - /** - * Handle keyboard events for navigation - */ - navigateKeyPress(event: React.KeyboardEvent): void { - const selectedId = this.state.selectedNonObject || this.state.selected[0]; - - if (!selectedId) { - return; - } - - if (event.code === 'ArrowUp' || event.code === 'ArrowDown') { - event.preventDefault(); - const ids: string[] = []; - this.tableRef.current?.childNodes.forEach((node: any) => ids.push((node as HTMLDivElement).id)); - const idx = ids.indexOf(selectedId); - const newIdx = event.code === 'ArrowDown' ? idx + 1 : idx - 1; - const newId = ids[newIdx] || selectedId; - this.onSelect(newId); - this.scrollToItem(newId); - } - - if (event.code === 'ArrowRight' || event.code === 'ArrowLeft') { - this.toggleExpanded(selectedId); - } - - if (event.code === 'Delete' && this.root && selectedId) { - const item = ObjectBrowserClass.getItemFromRoot(this.root, selectedId); - if (item) { - const { obj } = item.data; - if (obj && !obj.common?.dontDelete) { - this.showDeleteDialog({ id: selectedId, obj, item }); - } - } - } - } - - /** - * Find the id from the root - * - * @param root The current root - * @param id The object id to find - */ - private static getItemFromRoot(root: TreeItem, id: string): TreeItem | null { - const idArr = id.split('.'); - let currId = ''; - let _root: TreeItem | null | undefined = root; - - for (let i = 0; i < idArr.length; i++) { - const idEntry = idArr[i]; - currId = currId ? `${currId}.${idEntry}` : idEntry; - let found = false; - if (_root.children) { - for (let j = 0; j < _root.children.length; j++) { - if (_root.children[j].data.id === currId) { - _root = _root.children[j]; - found = true; - break; - } - } - } - if (!found) { - return null; - } - } - - return _root || null; - } - - resizerReset = (): void => { - this.customWidth = false; - SCREEN_WIDTHS[this.props.width || 'lg'] = JSON.parse(JSON.stringify(this.storedWidths)); - this.calculateColumnsVisibility(); - this.localStorage.removeItem(`${this.props.dialogName || 'App'}.table`); - this.forceUpdate(); - }; - - /** - * Render the right handle for resizing - */ - renderHandleRight(): JSX.Element { - return ( - - ); - } - - private renderHeader(): JSX.Element { - let filterClearInValue = null; - - if (!this.columnsVisibility.buttons && !this.isFilterEmpty()) { - filterClearInValue = ( - this.clearFilter()} - style={styles.buttonClearFilter} - title={this.props.t('ra_Clear filter')} - size="large" - > - - - - ); - } - - if (this.props.width === 'xs') { - return ( -
-
{this.getFilterInput('id')}
-
- ); - } - - return ( -
-
- {this.getFilterInput('id')} - {this.renderHandleRight()} -
- {this.columnsVisibility.name ? ( -
- {this.getFilterInput('name')} - {this.renderHandleRight()} -
- ) : null} - {!this.state.statesView && ( - <> - {this.columnsVisibility.type ? ( -
- {this.getFilterSelectType()} - {this.renderHandleRight()} -
- ) : null} - {this.columnsVisibility.role ? ( -
- {this.getFilterSelectRole()} - {this.renderHandleRight()} -
- ) : null} - {this.columnsVisibility.room ? ( -
- {this.getFilterSelectRoom()} - {this.renderHandleRight()} -
- ) : null} - {this.columnsVisibility.func ? ( -
- {this.getFilterSelectFunction()} - {this.renderHandleRight()} -
- ) : null} - - )} - {this.state.statesView && ( - <> -
- {this.props.t('ra_Changed from')} - {this.renderHandleRight()} -
-
- {this.props.t('ra_Quality code')} - {this.renderHandleRight()} -
-
- {this.props.t('ra_Timestamp')} - {this.renderHandleRight()} -
-
- {this.props.t('ra_Last change')} - {this.renderHandleRight()} -
- - )} - {this.adapterColumns.map(item => ( -
)[item.id], - }} - title={item.adapter} - key={item.id} - data-min={100} - data-name={item.id} - > - {item.name} -
- ))} - {this.columnsVisibility.val ? ( -
- {this.props.t('ra_Value')} - {filterClearInValue} -
- ) : null} - {this.columnsVisibility.buttons ? ( -
- {' '} - {this.getFilterSelectCustoms()} -
- ) : null} -
- ); - } - - private renderToast(): JSX.Element { - return ( - this.setState({ toast: '' })} - onClose={() => this.setState({ toast: '' })} - message={this.state.toast} - action={ - this.setState({ toast: '' })} - > - - - } - /> - ); - } - - /** - * Called when component is updated. - */ - componentDidUpdate(): void { - if (this.tableRef.current) { - const scrollBarWidth = this.tableRef.current.offsetWidth - this.tableRef.current.clientWidth; - if (this.state.scrollBarWidth !== scrollBarWidth) { - setTimeout(() => this.setState({ scrollBarWidth }), 100); - } else if (this.selectFirst) { - this.scrollToItem(this.selectFirst); - } - } - } - - scrollToItem(id: string): void { - this.selectFirst = ''; - - const node = window.document.getElementById(id); - node?.scrollIntoView({ - behavior: 'auto', - block: 'center', - inline: 'center', - }); - } - - private renderCustomDialog(): JSX.Element | null { - if (this.state.customDialog && this.props.objectCustomDialog) { - const ObjectCustomDialog = this.props.objectCustomDialog; - - return ( - (this.changedIds = [...changedIds])} - objectIDs={this.state.customDialog} - allVisibleObjects={!!this.state.customDialogAll} - expertMode={this.state.filter.expertMode} - isFloatComma={ - this.props.isFloatComma === undefined - ? this.systemConfig.common.isFloatComma - : this.props.isFloatComma - } - t={this.props.t} - lang={this.props.lang} - socket={this.props.socket} - themeName={this.props.themeName} - themeType={this.props.themeType} - theme={this.props.theme} - objects={this.objects} - customsInstances={this.info.customs} - onClose={() => { - this.pauseSubscribe(false); - this.setState({ customDialog: null }); - if (this.changedIds) { - this.changedIds = null; - // update all changed IDs - this.forceUpdate(); - } - - this.props.router?.doNavigate('tab-objects'); - }} - systemConfig={this.systemConfig} - /> - ); - } - return null; - } - - private onUpdate(valAck: { - val: ioBroker.StateValue; - ack: boolean; - q: ioBroker.STATE_QUALITY[keyof ioBroker.STATE_QUALITY]; - expire: number | undefined; - }): void { - this.props.socket - .setState(this.edit.id, { - val: valAck.val, - ack: valAck.ack, - q: valAck.q || 0, - expire: valAck.expire || undefined, - }) - .catch(e => this.showError(`Cannot write value: ${e}`)); - } - - private renderEditObjectDialog(): JSX.Element | null { - if (!this.state.editObjectDialog || !this.props.objectBrowserEditObject) { - return null; - } - - const ObjectBrowserEditObject = this.props.objectBrowserEditObject; - - return ( - - this.props.socket - .setObject(obj._id, obj) - .then(() => - this.setState({ editObjectDialog: obj._id, editObjectAlias: false }, () => - this.onSelect(obj._id), - ), - ) - .catch(e => this.showError(`Cannot write object: ${e}`)) - } - onClose={(obj?: ioBroker.AnyObject) => { - if (obj) { - let updateAlias: string; - if (this.state.editObjectDialog.startsWith('alias.')) { - if ( - JSON.stringify(this.objects[this.state.editObjectDialog].common?.alias) !== - JSON.stringify((obj as ioBroker.StateObject).common?.alias) - ) { - updateAlias = this.state.editObjectDialog; - } - } - - this.props.socket - .setObject(obj._id, obj) - .then(() => { - if (updateAlias && this.subscribes.includes(updateAlias)) { - this.unsubscribe(updateAlias); - setTimeout(() => this.subscribe(updateAlias), 100); - } - }) - .catch(e => this.showError(`Cannot write object: ${e}`)); - } - this.setState({ editObjectDialog: '', editObjectAlias: false }); - }} - width={this.props.width} - /> - ); - } - - private renderViewObjectFileDialog(): JSX.Element | null { - if (!this.state.viewFileDialog || !this.props.objectBrowserViewFile) { - return null; - } - const ObjectBrowserViewFile = this.props.objectBrowserViewFile; - - return ( - this.setState({ viewFileDialog: '' })} - /> - ); - } - - private renderAliasEditorDialog(): JSX.Element | null { - if (!this.props.objectBrowserAliasEditor || !this.state.showAliasEditor) { - return null; - } - const ObjectBrowserAliasEditor = this.props.objectBrowserAliasEditor; - - return ( - this.setState({ showAliasEditor: '' })} - onRedirect={(id: string, timeout?: number) => - setTimeout( - () => - this.onSelect(id, false, () => - this.expandAllSelected(() => { - this.scrollToItem(id); - setTimeout( - () => - this.setState({ - editObjectDialog: id, - showAliasEditor: '', - editObjectAlias: true, - }), - 300, - ); - }), - ), - timeout || 0, - ) - } - /> - ); - } - - showAddDataPointDialog(id: string, initialType: ioBroker.ObjectType, initialStateType?: ioBroker.CommonType): void { - this.setState({ - showContextMenu: null, - modalNewObj: { - id, - initialType, - initialStateType, - }, - }); - } - - /** Renders the aliases list for one state (if more than 2) */ - private renderAliasMenu(): JSX.Element | null { - if (!this.state.aliasMenu) { - return null; - } - - return ( - this.setState({ aliasMenu: '' })} - > - {this.info.aliasesMap[this.state.aliasMenu].map((aliasId, i) => ( - this.onSelect(aliasId)} - > - - {this.renderAliasLink(this.state.aliasMenu, i, { - '& .admin-browser-arrow': { - mr: '8px', - }, - })} - - - ))} - - ); - } - - /** - * Renders the right mouse button context menu - */ - private renderContextMenu(): JSX.Element | null { - if (!this.state.showContextMenu) { - return null; - } - const item = this.state.showContextMenu.item; - const id = item.data.id; - const items: JSX.Element[] = []; - // const ctrl = isIOS() ? '⌘' : (this.props.lang === 'de' ? 'Strg+' : 'Ctrl+'); - - const obj = item.data.obj; - - let showACL = ''; - if (this.props.objectEditOfAccessControl && this.state.filter.expertMode) { - if (!obj) { - showACL = '---'; - } else { - const acl = obj.acl ? (obj.type === 'state' ? obj.acl.state : obj.acl.object) : 0; - const aclSystemConfig = - obj.acl && - (obj.type === 'state' - ? this.systemConfig.common.defaultNewAcl.state - : this.systemConfig.common.defaultNewAcl.object); - showACL = Number.isNaN(Number(acl)) ? Number(aclSystemConfig).toString(16) : Number(acl).toString(16); - } - } - - const enumEditable = - !this.props.notEditable && - obj && - (this.state.filter.expertMode || obj.type === 'state' || obj.type === 'channel' || obj.type === 'device'); - - const createStateVisible = - !item.data.obj || - item.data.obj.type === 'folder' || - item.data.obj.type === 'channel' || - item.data.obj.type === 'device' || - item.data.id === '0_userdata.0' || - item.data.obj.type === 'meta'; - const createChannelVisible = - !item.data.obj || - item.data.obj.type === 'folder' || - item.data.obj.type === 'device' || - item.data.id === '0_userdata.0' || - item.data.obj.type === 'meta'; - const createDeviceVisible = - !item.data.obj || - item.data.obj.type === 'folder' || - item.data.id === '0_userdata.0' || - item.data.obj.type === 'meta'; - const createFolderVisible = - !item.data.obj || - item.data.obj.type === 'folder' || - item.data.id === '0_userdata.0' || - item.data.obj.type === 'meta'; - - const ITEMS: Record = { - EDIT: { - key: '0', - visibility: !!( - this.props.objectBrowserEditObject && - obj && - (this.state.filter.expertMode || ObjectBrowserClass.isNonExpertId(id)) - ), - icon: ( - - ), - label: this.texts.editObject, - onClick: () => - this.setState({ editObjectDialog: item.data.id, showContextMenu: null, editObjectAlias: false }), - }, - EDIT_VALUE: { - key: '1', - visibility: !!( - this.states && - !this.props.notEditable && - obj && - obj.type === 'state' && - // @ts-expect-error deprecated from js-controller 6 - obj.common?.type !== 'file' && - (this.state.filter.expertMode || obj.common.write !== false) - ), - icon: ( - - ), - label: this.props.t('ra_Edit value'), - onClick: () => { - this.edit = { - val: this.states[id] ? this.states[id].val : '', - q: this.states[id] ? this.states[id].q || 0 : 0, - ack: false, - id, - }; - this.setState({ updateOpened: true, showContextMenu: null }); - }, - }, - VIEW: { - visibility: - !!this.props.objectBrowserViewFile && - obj?.type === 'state' && - // @ts-expect-error deprecated from js-controller 6 - obj.common?.type === 'file', - icon: ( - - ), - label: this.props.t('ra_View file'), - onClick: () => this.setState({ viewFileDialog: obj?._id || '', showContextMenu: null }), - }, - CUSTOM: { - key: '2', - visibility: !( - this.props.objectCustomDialog && - this.info.hasSomeCustoms && - obj && - obj.type === 'state' && - // @ts-expect-error deprecated from js-controller 6 - obj.common?.type !== 'file' - ), - icon: ( - - ), - style: this.styles.contextMenuCustom, - label: this.texts.customConfig, - onClick: () => { - this.pauseSubscribe(true); - this.props.router?.doNavigate(null, 'customs', id); - this.setState({ customDialog: [id], showContextMenu: null }); - }, - }, - ACL: { - key: '3', - visibility: !!showACL, - icon: showACL, - iconStyle: { fontSize: 'smaller' }, - listItemIconStyle: this.styles.contextMenuACL, - style: this.styles.contextMenuACL, - label: this.props.t('ra_Edit ACL'), - onClick: () => - this.setState({ - showContextMenu: null, - modalEditOfAccess: true, - modalEditOfAccessObjData: item.data, - }), - }, - ROLE: { - key: '4', - visibility: !!(this.state.filter.expertMode && enumEditable && this.props.objectBrowserEditRole), - icon: ( - - ), - label: this.props.t('ra_Edit role'), - onClick: () => this.setState({ roleDialog: item.data.id, showContextMenu: null }), - }, - FUNCTION: { - key: '5', - visibility: !!enumEditable, - icon: ( - - ), - label: this.props.t('ra_Edit function'), - onClick: () => { - const enums = findEnumsForObjectAsIds(this.info, item.data.id, 'funcEnums'); - this.setState({ - enumDialogEnums: enums, - enumDialog: { - item, - type: 'func', - enumsOriginal: JSON.stringify(enums), - }, - showContextMenu: null, - }); - }, - }, - ROOM: { - key: '6', - visibility: !!enumEditable, - icon: ( - - ), - label: this.props.t('ra_Edit room'), - onClick: () => { - const enums = findEnumsForObjectAsIds(this.info, item.data.id, 'roomEnums'); - this.setState({ - enumDialogEnums: enums, - enumDialog: { - item, - type: 'room', - enumsOriginal: JSON.stringify(enums), - }, - showContextMenu: null, - }); - }, - }, - ALIAS: { - key: '7', - visibility: !!( - !this.props.notEditable && - this.props.objectBrowserAliasEditor && - this.props.objectBrowserEditObject && - obj?.type === 'state' && - // @ts-expect-error deprecated from js-controller 6 - obj.common?.type !== 'file' - ), - icon: ( - - ), - label: this.info.aliasesMap[item.data.id] - ? this.props.t('ra_Edit alias') - : this.props.t('ra_Create alias'), - onClick: () => { - if (obj?.common?.alias) { - this.setState({ showContextMenu: null, editObjectDialog: item.data.id, editObjectAlias: true }); - } else { - this.setState({ showContextMenu: null, showAliasEditor: item.data.id }); - } - }, - }, - CREATE: { - key: '+', - visibility: - (item.data.id.startsWith('0_userdata.0') || item.data.id.startsWith('javascript.')) && - (createStateVisible || createChannelVisible || createDeviceVisible || createFolderVisible), - icon: ( - - ), - style: styles.contextMenuWithSubMenu, - label: this.texts.create, - subMenu: [ - { - label: this.texts.createBooleanState, - visibility: createStateVisible, - icon: , - onClick: () => this.showAddDataPointDialog(item.data.id, 'state', 'boolean'), - }, - { - label: this.texts.createNumberState, - visibility: createStateVisible, - icon: , - onClick: () => this.showAddDataPointDialog(item.data.id, 'state', 'number'), - }, - { - label: this.texts.createStringState, - visibility: createStateVisible, - icon: , - onClick: () => this.showAddDataPointDialog(item.data.id, 'state', 'string'), - }, - { - label: this.texts.createState, - visibility: createStateVisible, - icon: , - onClick: () => this.showAddDataPointDialog(item.data.id, 'state'), - }, - { - label: this.texts.createChannel, - visibility: createChannelVisible, - icon: , - onClick: () => this.showAddDataPointDialog(item.data.id, 'channel'), - }, - { - label: this.texts.createDevice, - visibility: createDeviceVisible, - icon: , - onClick: () => this.showAddDataPointDialog(item.data.id, 'device'), - }, - { - label: this.texts.createFolder, - icon: , - visibility: createFolderVisible, - onClick: () => this.showAddDataPointDialog(item.data.id, 'folder'), - }, - ], - }, - DELETE: { - key: 'Delete', - visibility: !!( - this.props.onObjectDelete && - (item.children?.length || (obj && !obj.common?.dontDelete)) - ), - icon: ( - - ), - style: this.styles.contextMenuDelete, - label: this.texts.deleteObject, - onClick: () => - this.setState({ showContextMenu: null }, () => - this.showDeleteDialog({ - id, - obj: obj || ({} as ioBroker.Object), - item, - }), - ), - }, - }; - - Object.keys(ITEMS).forEach(key => { - if (ITEMS[key].visibility) { - if (ITEMS[key].subMenu) { - items.push( - ) => - this.state.showContextMenu && - this.setState({ - showContextMenu: { - item: this.state.showContextMenu.item, - position: this.state.showContextMenu.position, - subItem: key, - subAnchor: e.target as HTMLLIElement, - }, - }) - } - style={ITEMS[key].style} - > - - {ITEMS[key].icon} - - - {ITEMS[key].label} - ... - -
- -
-
, - ); - - if (this.state.showContextMenu?.subItem === key) { - items.push( - { - if (this.state.showContextMenu) { - this.setState({ - showContextMenu: { - item: this.state.showContextMenu.item, - position: this.state.showContextMenu.position, - }, - }); - } - this.contextMenu = null; - }} - > - {ITEMS[key].subMenu?.map(subItem => - subItem.visibility ? ( - - - {subItem.icon} - - {subItem.label} - - ) : null, - )} - , - ); - } - } else { - items.push( - - - {ITEMS[key].icon} - - {ITEMS[key].label} - {ITEMS[key].key ? ( -
- {`Alt+${ITEMS[key].key === 'Delete' ? this.props.t('ra_Del') : ITEMS[key].key}`} -
- ) : null} -
, - ); - } - } - }); - - if (!items.length) { - setTimeout(() => this.setState({ showContextMenu: null }), 100); - return null; - } - - return ( - { - e.preventDefault(); - if (e.altKey) { - Object.keys(ITEMS).forEach(key => { - if (e.key === ITEMS[key].key && ITEMS[key].onClick) { - ITEMS[key].onClick(); - } - }); - } - }} - anchorReference="anchorPosition" - anchorPosition={this.state.showContextMenu.position} - onClose={() => { - this.setState({ showContextMenu: null }); - this.contextMenu = null; - }} - > - {items} - - ); - } - - private renderEditValueDialog(): JSX.Element | null { - if (!this.state.updateOpened || !this.props.objectBrowserValue) { - return null; - } - - if (!this.edit.id) { - console.error(`Invalid ID for edit: ${JSON.stringify(this.edit)}`); - return null; - } - - if (!this.objects[this.edit.id]) { - console.error(`Something went wrong. Possibly the object ${this.edit.id} was deleted.`); - return null; - } - - const type = this.objects[this.edit.id].common?.type - ? this.objects[this.edit.id].common.type - : typeof this.edit.val; - - const role = this.objects[this.edit.id].common.role; - - const ObjectBrowserValue = this.props.objectBrowserValue; - - return ( - { - this.setState({ updateOpened: false }); - if (res) { - this.onUpdate(res); - } - }} - width={this.props.width} - /> - ); - } - - /** - * The rendering method of this component. - */ - render(): JSX.Element { - this.recordStates = []; - if (this.unsubscribeTimer) { - clearTimeout(this.unsubscribeTimer); - } - - if (this.styleTheme !== this.props.themeType) { - this.styles = { - cellIdIconFolder: Utils.getStyle(this.props.theme, styles.cellIdIconFolder), - cellIdIconDocument: Utils.getStyle(this.props.theme, styles.cellIdIconDocument), - iconDeviceError: Utils.getStyle(this.props.theme, styles.iconDeviceError), - iconDeviceConnected: Utils.getStyle(this.props.theme, styles.iconDeviceConnected), - iconDeviceDisconnected: Utils.getStyle(this.props.theme, styles.iconDeviceDisconnected), - cellButtonsButtonWithCustoms: Utils.getStyle(this.props.theme, styles.cellButtonsButtonWithCustoms), - invertedBackground: Utils.getStyle(this.props.theme, styles.invertedBackground), - invertedBackgroundFlex: Utils.getStyle(this.props.theme, styles.invertedBackgroundFlex), - contextMenuEdit: Utils.getStyle(this.props.theme, styles.contextMenuEdit), - contextMenuEditValue: Utils.getStyle(this.props.theme, styles.contextMenuEditValue), - contextMenuView: Utils.getStyle(this.props.theme, styles.contextMenuView), - contextMenuCustom: Utils.getStyle(this.props.theme, styles.contextMenuCustom), - contextMenuACL: Utils.getStyle(this.props.theme, styles.contextMenuACL), - contextMenuRoom: Utils.getStyle(this.props.theme, styles.contextMenuRoom), - contextMenuRole: Utils.getStyle(this.props.theme, styles.contextMenuRole), - contextMenuDelete: Utils.getStyle(this.props.theme, styles.contextMenuDelete), - filterInput: Utils.getStyle(this.props.theme, styles.headerCellInput, styles.filterInput), - iconCopy: Utils.getStyle( - this.props.theme, - styles.cellButtonsValueButton, - styles.cellButtonsValueButtonCopy, - ), - aliasReadWrite: Utils.getStyle(this.props.theme, styles.cellIdAlias, styles.cellIdAliasReadWrite), - aliasAlone: Utils.getStyle(this.props.theme, styles.cellIdAlias, styles.cellIdAliasAlone), - }; - this.styleTheme = this.props.themeType; - } - - // apply filter if changed - const jsonFilter = JSON.stringify(this.state.filter); - - if (this.lastAppliedFilter !== jsonFilter && this.objects && this.root) { - const counter = { count: 0 }; - - applyFilter( - this.root, - this.state.filter, - this.props.lang, - this.objects, - undefined, - counter, - this.props.customFilter, - this.props.types, - ); - - if (counter.count < 500 && !this.state.expandAllVisible) { - setTimeout(() => this.setState({ expandAllVisible: true })); - } else if (counter.count >= 500 && this.state.expandAllVisible) { - setTimeout(() => this.setState({ expandAllVisible: false })); - } - - this.lastAppliedFilter = jsonFilter; - } - - this.unsubscribeTimer = setTimeout(() => { - this.unsubscribeTimer = null; - this.checkUnsubscribes(); - }, 200); - - if (!this.state.loaded) { - return ; - } - const items = this.root ? this.renderItem(this.root, undefined) : null; - - return ( - - - {this.getToolbar()} - - {this.renderHeader()} -
this.navigateKeyPress(event)} - > - {items} -
-
- {this.renderContextMenu()} - {this.renderAliasMenu()} - {this.renderToast()} - {this.renderColumnsEditCustomDialog()} - {this.renderColumnsSelectorDialog()} - {this.renderCustomDialog()} - {this.renderEditValueDialog()} - {this.renderEditObjectDialog()} - {this.renderViewObjectFileDialog()} - {this.renderAliasEditorDialog()} - {this.renderEditRoleDialog()} - {this.renderEnumDialog()} - {this.renderErrorDialog()} - {this.renderExportDialog()} - {this.state.modalNewObj && this.props.modalNewObject && this.props.modalNewObject(this)} - {this.state.modalEditOfAccess && - this.state.modalEditOfAccessObjData && - this.props.modalEditOfAccessControl && - this.props.modalEditOfAccessControl(this, this.state.modalEditOfAccessObjData)} -
- ); - } -} - -export default withWidth()(ObjectBrowserClass); diff --git a/packages/admin/src-admin/src/dialogs/FileEditOfAccessControl.tsx b/packages/admin/src-admin/src/dialogs/FileEditOfAccessControl.tsx index b8b5e7535..d53ccb895 100644 --- a/packages/admin/src-admin/src/dialogs/FileEditOfAccessControl.tsx +++ b/packages/admin/src-admin/src/dialogs/FileEditOfAccessControl.tsx @@ -8,10 +8,12 @@ import { type ThemeType, type Translate, type IobTheme, + type FolderOrFileItem, + type Folders, + type MetaACL, + type MetaObject, } from '@iobroker/adapter-react-v5'; -import type { FolderOrFileItem, Folders, MetaACL, MetaObject } from '@/components/FileBrowser'; - import AdminUtils from '../helpers/AdminUtils'; import CustomModal from '../components/CustomModal'; diff --git a/packages/admin/src-admin/src/tabs/Files.tsx b/packages/admin/src-admin/src/tabs/Files.tsx index 4f58f2958..0f076336a 100644 --- a/packages/admin/src-admin/src/tabs/Files.tsx +++ b/packages/admin/src-admin/src/tabs/Files.tsx @@ -11,8 +11,9 @@ import { TabContent, } from '@iobroker/adapter-react-v5'; -import FileBrowser, { type FileBrowserClass, type MetaObject } from '../components/FileBrowser'; +import { FileBrowser, type FileBrowserClass, type MetaObject } from '@iobroker/adapter-react-v5'; +import { FileEditor } from '../components/FileEditor'; import FileEditOfAccessControl from '../dialogs/FileEditOfAccessControl'; interface FilesProps { @@ -157,6 +158,7 @@ class Files extends Component { allowDelete expertMode={this.props.expertMode} modalEditOfAccessControl={(context: FileBrowserClass) => this.renderAclDialog(context)} + FileViewer={FileEditor} /> diff --git a/packages/admin/src-admin/src/tabs/Objects.tsx b/packages/admin/src-admin/src/tabs/Objects.tsx index 4a097cdc8..e1914d33d 100644 --- a/packages/admin/src-admin/src/tabs/Objects.tsx +++ b/packages/admin/src-admin/src/tabs/Objects.tsx @@ -22,15 +22,14 @@ import { type Translate, withWidth, Router, + ObjectBrowser, + type ObjectBrowserClass, + type ObjectBrowserFilter, + type TreeItemData, } from '@iobroker/adapter-react-v5'; import type ObjectsWorker from '@/Workers/ObjectsWorker'; -import ObjectBrowser, { - type ObjectBrowserClass, - type ObjectBrowserFilter, - type TreeItemData, -} from '../components/ObjectBrowser'; import ObjectCustomDialog from '../dialogs/ObjectCustomDialog'; import ObjectBrowserValue from '../components/Object/ObjectBrowserValue'; import ObjectBrowserEditObject from '../components/Object/ObjectBrowserEditObject'; diff --git a/packages/admin/src/lib/testPassword.ts b/packages/admin/src/lib/testPassword.ts index 7570e431b..9402668d8 100644 --- a/packages/admin/src/lib/testPassword.ts +++ b/packages/admin/src/lib/testPassword.ts @@ -16,6 +16,7 @@ const mutableStdout = new Writable({ }, }); +// eslint-disable-next-line @typescript-eslint/no-floating-promises checkWellKnownPasswords().then(found => { if (found) { console.log(`Found well-known password: ${JSON.stringify(found)}`); diff --git a/packages/admin/src/lib/web.ts b/packages/admin/src/lib/web.ts index 7ade9f7d5..25fa0f1c8 100644 --- a/packages/admin/src/lib/web.ts +++ b/packages/admin/src/lib/web.ts @@ -1,18 +1,19 @@ -import * as utils from '@iobroker/adapter-core'; +import { commonTools, EXIT_CODES } from '@iobroker/adapter-core'; import * as IoBWebServer from '@iobroker/webserver'; import * as express from 'express'; -import { type RequestHandler } from 'express'; -import * as fs from 'node:fs'; -import * as util from 'util'; -import * as path from 'node:path'; -import * as stream from 'node:stream'; +import type { Express, Response, Request, NextFunction } from 'express'; +import type { Server } from 'node:http'; +import { readFileSync, existsSync, createReadStream, readdirSync, lstatSync } from 'node:fs'; +import { inherits } from 'util'; +import { join, normalize, parse, dirname } from 'node:path'; +import { Transform } from 'node:stream'; import * as compression from 'compression'; import * as mime from 'mime'; -import * as zlib from 'node:zlib'; +import { gunzipSync } from 'node:zlib'; import * as archiver from 'archiver'; import axios from 'axios'; -import * as Ajv from 'ajv'; -import * as JSON5 from 'json5'; +import { Ajv } from 'ajv'; +import { parse as JSON5 } from 'json5'; import * as passport from 'passport'; import * as fileUpload from 'express-fileupload'; import { Strategy } from 'passport-local'; @@ -22,10 +23,6 @@ import * as session from 'express-session'; import * as bodyParser from 'body-parser'; import * as cookieParser from 'cookie-parser'; -interface IConnectFlashOptions { - unsafe?: boolean | undefined; -} - export interface AdminAdapterConfig extends ioBroker.AdapterConfig { accessAllowedConfigs: string[]; accessAllowedTabs: string[]; @@ -62,13 +59,12 @@ export interface AdminAdapterConfig extends ioBroker.AdapterConfig { } let AdapterStore; -let flash: ((options?: IConnectFlashOptions) => RequestHandler) | undefined; /** Content of a socket-io file */ let socketIoFile: false | string; /** UUID of the installation */ let uuid: string; -const page404 = fs.readFileSync(`${__dirname}/../../public/404.html`).toString('utf8'); -const logTemplate = fs.readFileSync(`${__dirname}/../../public/logTemplate.html`).toString('utf8'); +const page404 = readFileSync(`${__dirname}/../../public/404.html`).toString('utf8'); +const logTemplate = readFileSync(`${__dirname}/../../public/logTemplate.html`).toString('utf8'); // const FORBIDDEN_CHARS = /[\]\[*,;'"`<>\\\s?]/g; // with space const ONE_MONTH_SEC = 30 * 24 * 3_600; @@ -119,6 +115,14 @@ function escapeHtml(string: string): string { return lastIndex !== index ? html + str.substring(lastIndex, index) : html; } +function isLocalUrl(path: string): boolean { + try { + return new URL(path, 'http://127.0.0.1:3000').origin === 'http://127.0.0.1:3000'; + } catch { + return false; + } +} + function get404Page(customText?: string): string { if (customText) { return page404.replace('
', `
${customText}
`); @@ -162,7 +166,7 @@ async function readFolderRecursive( } function MemoryWriteStream(): void { - stream.Transform.call(this); + Transform.call(this); this._chunks = []; this._transform = (chunk: unknown, enc: unknown, cb: () => void): void => { this._chunks.push(chunk); @@ -174,7 +178,7 @@ function MemoryWriteStream(): void { return result; }; } -util.inherits(MemoryWriteStream, stream.Transform); +inherits(MemoryWriteStream, Transform); interface WebOptions { systemLanguage: ioBroker.Languages; @@ -190,7 +194,10 @@ interface AdminAdapter extends ioBroker.Adapter { */ class Web { // eslint-disable-next-line @typescript-eslint/consistent-type-imports - server: { app: null | ReturnType; server: null | import('http').Server } = { + server: { + app: null | Express; + server: null | (Server & { __server: { app: null | Express; server: null | Server } }); + } = { app: null, server: null, }; @@ -205,13 +212,13 @@ class Web { private bruteForce: Record = {}; private store: unknown = null; private indexHTML: string; - baseDir = path.join(__dirname, '..', '..'); - dirName = path.normalize(`${this.baseDir}/admin/`.replace(/\\/g, '/')).replace(/\\/g, '/'); + baseDir = join(__dirname, '..', '..'); + dirName = normalize(`${this.baseDir}/admin/`.replace(/\\/g, '/')).replace(/\\/g, '/'); private unprotectedFiles: { name: string; isDir: boolean }[]; systemConfig: Partial; // todo delete after React will be main - wwwDir = path.join(this.baseDir, 'adminWww'); + wwwDir = join(this.baseDir, 'adminWww'); private settings: AdminAdapterConfig; private readonly adapter: AdminAdapter; @@ -244,9 +251,9 @@ class Web { void this.#init(); } - decorateLogFile(filename: string, text?: string): string { - const log = text || fs.readFileSync(filename).toString(); - return logTemplate.replace('@@title@@', path.parse(filename).name).replace('@@body@@', log); + decorateLogFile(fileName: string, text?: string): string { + const log = text || readFileSync(fileName).toString(); + return logTemplate.replace('@@title@@', parse(fileName).name).replace('@@body@@', log); } setLanguage(lang: ioBroker.Languages): void { @@ -264,14 +271,14 @@ class Web { } prepareIndex(): string { - let template = fs.readFileSync(path.join(this.wwwDir, 'index.html')).toString('utf8'); + let template = readFileSync(join(this.wwwDir, 'index.html')).toString('utf8'); const m = template.match(/(["']?@@\w+@@["']?)/g); m.forEach(pattern => { pattern = pattern.replace(/@/g, '').replace(/'/g, '').replace(/"/g, ''); if (pattern === 'disableDataReporting') { template = template.replace( /['"]@@disableDataReporting@@["']/g, - // @ts-expect-error this is not used on instance objects use system.adapter.xy.plugins.sentry.enabled + // @ts-expect-error deprecated: this is not used on instance objects use system.adapter.xy.plugins.sentry.enabled this.adapter.common?.disableDataReporting ? 'true' : 'false', ); } else if (pattern === 'loginBackgroundImage') { @@ -385,29 +392,29 @@ class Web { // @ts-expect-error fixed in js-controller 7.x if (res?.common.adminUI?.config === 'json') { try { - const ajv = new Ajv.Ajv({ + const ajv = new Ajv({ allErrors: false, strict: 'log', }); - const adapterPath = path.dirname(require.resolve(`iobroker.${adapterName}/package.json`)); + const adapterPath = dirname(require.resolve(`iobroker.${adapterName}/package.json`)); - const jsonConfPath = path.join(adapterPath, 'admin', 'jsonConfig.json'); - const json5ConfPath = path.join(adapterPath, 'admin', 'jsonConfig.json5'); + const jsonConfPath = join(adapterPath, 'admin', 'jsonConfig.json'); + const json5ConfPath = join(adapterPath, 'admin', 'jsonConfig.json5'); let jsonConf: string; - if (fs.existsSync(jsonConfPath)) { - jsonConf = fs.readFileSync(jsonConfPath, { + if (existsSync(jsonConfPath)) { + jsonConf = readFileSync(jsonConfPath, { encoding: 'utf-8', }); } else { - jsonConf = fs.readFileSync(json5ConfPath, { + jsonConf = readFileSync(json5ConfPath, { encoding: 'utf-8', }); } const validate = ajv.compile(schema); - const valid = validate(JSON5.parse(jsonConf)); + const valid = validate(JSON5(jsonConf)); if (!valid) { this.adapter.log.warn( @@ -420,21 +427,21 @@ class Web { } } - unzipFile(filename: string, data: string, res: express.Response): void { + unzipFile(fileName: string, data: string, res: Response): void { // extract the file try { - const text = zlib.gunzipSync(data).toString('utf8'); + const text = gunzipSync(data).toString('utf8'); if (text.length > 2 * 1024 * 1024) { res.header('Content-Type', 'text/plain'); res.send(text); } else { res.header('Content-Type', 'text/html'); - res.send(this.decorateLogFile(filename, text)); + res.send(this.decorateLogFile(fileName, text)); } } catch (e) { res.header('Content-Type', 'application/gzip'); res.send(data); - this.adapter.log.error(`Cannot extract file ${filename}: ${e}`); + this.adapter.log.error(`Cannot extract file ${fileName}: ${e}`); } } @@ -453,27 +460,24 @@ class Web { this.server.app.disable('x-powered-by'); // enable use of i-frames together with HTTPS - this.server.app.get( - '/*', - (_req: express.Request, res: express.Response, next: express.NextFunction): void => { - res.header('X-Frame-Options', 'SAMEORIGIN'); - next(); // http://expressjs.com/guide.html#passing-route control - }, - ); + this.server.app.get('/*', (_req: Request, res: Response, next: NextFunction): void => { + res.header('X-Frame-Options', 'SAMEORIGIN'); + next(); // http://expressjs.com/guide.html#passing-route control + }); // ONLY for DEBUG - /*server.app.use((req: express.Request, res: express.Response, next: express.NextFunction): void => { + /*server.app.use((req: Request, res: Response, next: NextFunction): void => { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); next(); });*/ - this.server.app.get('/version', (_req: express.Request, res: express.Response): void => { + this.server.app.get('/version', (_req: Request, res: Response): void => { res.status(200).send(this.adapter.version); }); // replace socket.io - this.server.app.use((req: express.Request, res: express.Response, next: express.NextFunction): void => { + this.server.app.use((req: Request, res: Response, next: NextFunction): void => { // return favicon always if (req.url === '/favicon.ico') { res.set('Content-Type', 'image/x-icon'); @@ -484,7 +488,7 @@ class Web { return; } - res.send(fs.readFileSync(path.join(this.wwwDir, 'favicon.ico'))); + res.send(readFileSync(join(this.wwwDir, 'favicon.ico'))); return; } else if ( socketIoFile !== false && @@ -495,7 +499,7 @@ class Web { res.status(200).send(socketIoFile); return; } - socketIoFile = fs.readFileSync(path.join(this.wwwDir, 'lib', 'js', 'socket.io.js'), { + socketIoFile = readFileSync(join(this.wwwDir, 'lib', 'js', 'socket.io.js'), { encoding: 'utf-8', }); if (socketIoFile) { @@ -510,14 +514,14 @@ class Web { next(); }); - this.server.app.get('*/_socket/info.js', (_req: express.Request, res: express.Response): void => { + this.server.app.get('*/_socket/info.js', (_req: Request, res: Response): void => { res.set('Content-Type', 'application/javascript'); res.status(200).send(this.getInfoJs()); }); if (this.settings.auth) { - AdapterStore = utils.commonTools.session(session, this.settings.ttl); - flash = await import('connect-flash'); + AdapterStore = commonTools.session(session, this.settings.ttl); + const flash = await import('connect-flash'); this.store = new AdapterStore({ adapter: this.adapter }); passport.use( @@ -613,76 +617,73 @@ class Web { this.server.app.use(passport.session()); this.server.app.use(flash()); - this.server.app.post( - '/login', - (req: express.Request, res: express.Response, next: express.NextFunction): void => { - let redirect = '/'; - req.body = req.body || {}; - const isDev = req.url.includes('?dev&'); - - const origin = req.body.origin || '?href=%2F'; - if (origin) { - const parts = origin.match(/href=(.+)$/); - if (parts && parts.length > 1 && parts[1]) { - redirect = decodeURIComponent(parts[1]); - // if some invalid characters in redirect - if (redirect.match(/[^-_a-zA-Z0-9&%?./]/)) { - redirect = '/'; - } - } else { - // extract pathname - redirect = origin.split('?')[0] || '/'; + this.server.app.post('/login', (req: Request, res: Response, next: NextFunction): void => { + let redirect = '/'; + req.body = req.body || {}; + const isDev = req.url.includes('?dev&'); + + const origin = (req.body.origin || '?href=%2F').trim(); + if (origin) { + const parts = origin.split('href='); + if (parts?.length > 1 && parts[1]) { + redirect = decodeURIComponent(parts[1]); + // if some invalid characters in redirect + if (redirect.match(/[^-_a-zA-Z0-9&%?./]/) || !isLocalUrl(redirect)) { + redirect = '/'; } + } else { + // extract pathname + redirect = origin.split('?')[0] || '/'; } - req.body.password = (req.body.password || '').toString(); - req.body.username = (req.body.username || '').toString(); - req.body.stayLoggedIn = - req.body.stayloggedin === 'true' || - req.body.stayloggedin === true || - req.body.stayloggedin === 'on'; - - passport.authenticate('local', (err: Error | null, user: string): void => { + } + req.body.password = (req.body.password || '').toString(); + req.body.username = (req.body.username || '').toString(); + req.body.stayLoggedIn = + req.body.stayloggedin === 'true' || + req.body.stayloggedin === true || + req.body.stayloggedin === 'on'; + + passport.authenticate('local', (err: Error | null, user: string): void => { + if (err) { + this.adapter.log.warn(`Cannot login user: ${err.message}`); + return res.redirect(this.getErrorRedirect(origin)); + } + if (!user) { + return res.redirect(this.getErrorRedirect(origin)); + } + req.logIn(user, err => { if (err) { - this.adapter.log.warn(`Cannot login user: ${err.message}`); + this.adapter.log.warn(`Cannot login user: ${err}`); return res.redirect(this.getErrorRedirect(origin)); } - if (!user) { - return res.redirect(this.getErrorRedirect(origin)); - } - req.logIn(user, err => { - if (err) { - this.adapter.log.warn(`Cannot login user: ${err}`); - return res.redirect(this.getErrorRedirect(origin)); - } - if (req.body.stayLoggedIn) { - req.session.cookie.httpOnly = true; - // https://www.npmjs.com/package/express-session#cookiemaxage-1 - // Interval in ms - req.session.cookie.maxAge = - (this.settings.ttl > ONE_MONTH_SEC ? this.settings.ttl : ONE_MONTH_SEC) * 1000; - } else { - req.session.cookie.httpOnly = true; - // https://www.npmjs.com/package/express-session#cookiemaxage-1 - // Interval in ms - req.session.cookie.maxAge = this.settings.ttl * 1000; - } + if (req.body.stayLoggedIn) { + req.session.cookie.httpOnly = true; + // https://www.npmjs.com/package/express-session#cookiemaxage-1 + // Interval in ms + req.session.cookie.maxAge = + (this.settings.ttl > ONE_MONTH_SEC ? this.settings.ttl : ONE_MONTH_SEC) * 1000; + } else { + req.session.cookie.httpOnly = true; + // https://www.npmjs.com/package/express-session#cookiemaxage-1 + // Interval in ms + req.session.cookie.maxAge = this.settings.ttl * 1000; + } - if (isDev) { - return res.redirect(`http://127.0.0.1:3000${redirect}`); - } + if (isDev) { + return res.redirect(`http://127.0.0.1:3000${redirect}`); + } - return res.redirect(redirect); - }); - })(req, res, next); - }, - ); + return res.redirect(redirect); + }); + })(req, res, next); + }); - this.server.app.get('/session', (req: express.Request, res: express.Response): void => { + this.server.app.get('/session', (req: Request, res: Response): void => { res.json({ expireInSec: Math.round(req.session.cookie.maxAge / 1_000) }); }); - this.server.app.get('/logout', (req: express.Request, res: express.Response): void => { + this.server.app.get('/logout', (req: Request, res: Response): void => { const isDev = req.url.includes('?dev'); let origin = req.url.split('origin=')[1]; if (origin) { @@ -702,7 +703,7 @@ class Web { }); // route middleware to make sure a user is logged in - this.server.app.use((req: express.Request, res: express.Response, next: express.NextFunction): void => { + this.server.app.use((req: Request, res: Response, next: NextFunction): void => { // return favicon always if (req.url === '/favicon.ico') { res.set('Content-Type', 'image/x-icon'); @@ -712,7 +713,7 @@ class Web { res.send(Buffer.from(text, 'base64')); return; } - res.send(fs.readFileSync(path.join(this.wwwDir, 'favicon.ico'))); + res.send(readFileSync(join(this.wwwDir, 'favicon.ico'))); return; } if (/admin\.\d+\/login-bg\.png(\?.*)?$/.test(req.originalUrl)) { @@ -735,8 +736,8 @@ class Web { // protect all paths except this.unprotectedFiles = this.unprotectedFiles || - fs.readdirSync(this.wwwDir).map(file => { - const stat = fs.lstatSync(path.join(this.wwwDir, file)); + readdirSync(this.wwwDir).map(file => { + const stat = lstatSync(join(this.wwwDir, file)); return { name: file, isDir: stat.isDirectory() }; }); if ( @@ -757,112 +758,72 @@ class Web { } }); } else { - this.server.app.get('/logout', (_req: express.Request, res: express.Response): void => - res.redirect('/'), - ); + this.server.app.get('/logout', (_req: Request, res: Response): void => res.redirect('/')); } - this.server.app.get('/iobroker_check.html', (_req: express.Request, res: express.Response): void => { + this.server.app.get('/iobroker_check.html', (_req: Request, res: Response): void => { res.status(200).send('ioBroker'); }); - this.server.app.get( - '/validate_config/*', - async (req: express.Request, res: express.Response): Promise => { - const adapterName = req.url.split('/').pop(); - - await this.validateJsonConfig(adapterName.toLowerCase()); - - res.status(200).send('validated'); - }, - ); + this.server.app.get('/validate_config/*', async (req: Request, res: Response): Promise => { + const adapterName = req.url.split('/').pop(); - this.server.app.get('/zip/*', (req: express.Request, res: express.Response): void => { - const parts = req.url.split('/'); - const filename = parts.pop(); - let hostname = parts.pop(); - // backwards compatibility with JavaScript < 3.5.5 - if (hostname === 'zip') { - hostname = `system.host.${this.adapter.host}`; - } + await this.validateJsonConfig(adapterName.toLowerCase()); - // @ts-expect-error TODO: binary states have been removed - if (this.adapter.getBinaryState) { - // @ts-expect-error TODO: binary states have been removed - this.adapter.getBinaryState(`${hostname}.zip.${filename}`, (err, buff): void => { - if (err) { - res.status(500).send(escapeHtml(typeof err === 'string' ? err : JSON.stringify(err))); - } else { - if (!buff) { - res.status(404).send(get404Page(escapeHtml(`File ${filename}.zip not found`))); - } else { - // remove file - // @ts-expect-error TODO: binary states have been removed - if (this.adapter.delBinaryState) { - // @ts-expect-error TODO: binary states have been removed - this.adapter.delBinaryState(`system.host.${this.adapter.host}.zip.${filename}`); - } - res.set('Content-Type', 'application/zip'); - res.send(buff); - } - } - }); - } else { - res.status(501).send('Cannot get binary states'); - } + res.status(200).send('validated'); }); // send log files - this.server.app.get('/log/*', (req: express.Request, res: express.Response): void => { + this.server.app.get('/log/*', (req: Request, res: Response): void => { let parts = decodeURIComponent(req.url).split('/'); if (parts.length === 5) { + // remove first "/" parts.shift(); + // remove "log" parts.shift(); const [host, transport] = parts; parts = parts.splice(2); - const filename = parts.join('/'); - this.adapter.sendToHost(`system.host.${host}`, 'getLogFile', { filename, transport }, result => { - // @ts-expect-error fix later - if (!result || result.error) { - res.status(404).send(get404Page(`File ${escapeHtml(filename)} not found`)); + const fileName = parts.join('/'); + if (fileName.includes('..')) { + res.status(404).send( + get404Page(`File ${escapeHtml(fileName)} not found. Do not use relative paths!`), + ); + return; + } + + this.adapter.sendToHost(`system.host.${host}`, 'getLogFile', { fileName, transport }, result => { + const _result = result as { error?: string; data?: string; size?: number; gz?: boolean }; + if (!_result || _result.error) { + res.status(404).send(get404Page(`File ${escapeHtml(fileName)} not found`)); } else { - // @ts-expect-error fix later - if (result.gz) { - // @ts-expect-error fix later - if (result.size > 1024 * 1024) { + if (_result.gz) { + if (_result.size > 1024 * 1024) { res.header('Content-Type', 'application/gzip'); - // @ts-expect-error fix later - res.send(result.data); + res.send(_result.data); } else { try { - // @ts-expect-error fix later - this.unzipFile(filename, result.data, res); + this.unzipFile(fileName, _result.data, res); } catch (e) { res.header('Content-Type', 'application/gzip'); - // @ts-expect-error fix later - res.send(result.data); - this.adapter.log.error(`Cannot extract file ${filename}: ${e}`); + res.send(_result.data); + this.adapter.log.error(`Cannot extract file ${fileName}: ${e}`); } } - // @ts-expect-error fix later - } else if (result.data === undefined || result.data === null) { - res.status(404).send(get404Page(`File ${escapeHtml(filename)} not found`)); - // @ts-expect-error fix later - } else if (result.size > 2 * 1024 * 1024) { + } else if (_result.data === undefined || _result.data === null) { + res.status(404).send(get404Page(`File ${escapeHtml(fileName)} not found`)); + } else if (_result.size > 2 * 1024 * 1024) { res.header('Content-Type', 'text/plain'); - // @ts-expect-error fix later - res.send(result.data); + res.send(_result.data); } else { res.header('Content-Type', 'text/html'); - // @ts-expect-error fix later - res.send(this.decorateLogFile(filename, result.data)); + res.send(this.decorateLogFile(fileName, _result.data)); } } }); } else { parts = parts.splice(2); const transport = parts.shift(); - let filename = parts.join('/'); + let fileName = parts.join('/'); const config = this.adapter.systemConfig; // detect file log @@ -872,55 +833,53 @@ class Web { if (config.log.transport[transport].filename) { parts = config.log.transport[transport].filename.replace(/\\/g, '/').split('/'); parts.pop(); - logFolder = path.normalize(parts.join('/')); + logFolder = normalize(parts.join('/')); } else { - logFolder = path.join(process.cwd(), 'log'); + logFolder = join(process.cwd(), 'log'); } if (logFolder[0] !== '/' && logFolder[0] !== '\\' && !logFolder.match(/^[a-zA-Z]:/)) { - const _logFolder = path - .normalize(path.join(`${this.baseDir}/../../`, logFolder).replace(/\\/g, '/')) - .replace(/\\/g, '/'); - if (!fs.existsSync(_logFolder)) { - logFolder = path - .normalize(path.join(`${this.baseDir}/../`, logFolder).replace(/\\/g, '/')) - .replace(/\\/g, '/'); + const _logFolder = normalize( + join(`${this.baseDir}/../../`, logFolder).replace(/\\/g, '/'), + ).replace(/\\/g, '/'); + if (!existsSync(_logFolder)) { + logFolder = normalize( + join(`${this.baseDir}/../`, logFolder).replace(/\\/g, '/'), + ).replace(/\\/g, '/'); } else { logFolder = _logFolder; } } - filename = path - .normalize(path.join(logFolder, filename).replace(/\\/g, '/')) - .replace(/\\/g, '/'); + fileName = normalize(join(logFolder, fileName).replace(/\\/g, '/')).replace(/\\/g, '/'); - if (filename.startsWith(logFolder) && fs.existsSync(filename)) { - const stat = fs.lstatSync(filename); + if (fileName.startsWith(logFolder) && existsSync(fileName)) { + const stat = lstatSync(fileName); // if a file is an archive - if (filename.toLowerCase().endsWith('.gz')) { + if (fileName.toLowerCase().endsWith('.gz')) { // try to not process to big files - if (stat.size > 1024 * 1024 /* || !fs.existsSync('/dev/null')*/) { + if (stat.size > 1024 * 1024 /* || !existsSync('/dev/null')*/) { res.header('Content-Type', 'application/gzip'); - res.sendFile(filename); + res.sendFile(fileName); } else { try { this.unzipFile( - filename, - fs.readFileSync(filename, { encoding: 'utf-8' }), + fileName, + readFileSync(fileName, { encoding: 'utf-8' }), res, ); } catch (e) { res.header('Content-Type', 'application/gzip'); - res.sendFile(filename); - this.adapter.log.error(`Cannot extract file ${filename}: ${e}`); + res.sendFile(fileName); + this.adapter.log.error(`Cannot extract file ${fileName}: ${e}`); } } } else if (stat.size > 2 * 1024 * 1024) { res.header('Content-Type', 'text/plain'); - res.sendFile(filename); + res.sendFile(fileName); } else { res.header('Content-Type', 'text/html'); - res.send(this.decorateLogFile(filename)); + res.send(this.decorateLogFile(fileName)); } return; @@ -928,7 +887,7 @@ class Web { } } - res.status(404).send(get404Page(`File ${escapeHtml(filename)} not found`)); + res.status(404).send(get404Page(`File ${escapeHtml(fileName)} not found`)); } }); @@ -945,7 +904,7 @@ class Web { tempFileDir: this.settings.tmpPath, }), ); - this.server.app.post('/upload', (req: express.Request, res: express.Response): void => { + this.server.app.post('/upload', (req: Request, res: Response): void => { if (!req.files) { res.status(400).send('No files were uploaded.'); return; @@ -983,24 +942,24 @@ class Web { }); } - if (!fs.existsSync(this.wwwDir)) { - this.server.app.use('/', (_req: express.Request, res: express.Response): void => { + if (!existsSync(this.wwwDir)) { + this.server.app.use('/', (_req: Request, res: Response): void => { res.header('Content-Type', 'text/plain'); res.status(404).send( 'This adapter cannot be installed directly from GitHub.
You must install it from npm.
Write for that "npm install iobroker.admin" in according directory.', ); }); } else { - this.server.app.get('/empty.html', (_req: express.Request, res: express.Response): void => { + this.server.app.get('/empty.html', (_req: Request, res: Response): void => { res.status(200).send(''); }); - this.server.app.get('/index.html', (_req: express.Request, res: express.Response): void => { + this.server.app.get('/index.html', (_req: Request, res: Response): void => { this.indexHTML = this.indexHTML || this.prepareIndex(); res.header('Content-Type', 'text/html'); res.status(200).send(this.indexHTML); }); - this.server.app.get('/', (_req: express.Request, res: express.Response): void => { + this.server.app.get('/', (_req: Request, res: Response): void => { this.indexHTML = this.indexHTML || this.prepareIndex(); res.header('Content-Type', 'text/html'); res.status(200).send(this.indexHTML); @@ -1010,7 +969,7 @@ class Web { } // reverse proxy with url rewrite for couchdb attachments in .admin - this.server.app.use('/adapter/', (req: express.Request, res: express.Response): void => { + this.server.app.use('/adapter/', (req: Request, res: Response): void => { // Example: /example/?0&attr=1 let url: string; try { @@ -1020,21 +979,22 @@ class Web { url = req.url; } + // sanitize url + // add index.html url = url.replace(/\/($|\?|#)/, '/index.html$1'); // Read config files for admin from /adapters/admin/admin/... if (url.startsWith(`/${this.adapter.name}/`)) { url = url.replace(`/${this.adapter.name}/`, this.dirName); - // important: Linux does not normalize "\" but fs.readFile accepts it as '/' - url = path.normalize(url.replace(/\?.*/, '').replace(/\\/g, '/')).replace(/\\/g, '/'); + // important: Linux does not normalize "\" but readFile accepts it as '/' + url = normalize(url.replace(/\?.*/, '').replace(/\\/g, '/')).replace(/\\/g, '/'); if (url.startsWith(this.dirName)) { try { - if (fs.existsSync(url)) { - // @ts-expect-error types may be wrong + if (existsSync(url)) { res.contentType(mime.getType(url) || 'text/javascript'); - fs.createReadStream(url).pipe(res); + createReadStream(url).pipe(res); } else { res.status(404).send(get404Page(`File not found`)); } @@ -1090,7 +1050,6 @@ class Web { res.contentType(mimeType); } else { try { - // @ts-expect-error types might be wrong const _mimeType = mime.getType(url); res.contentType(_mimeType || 'text/javascript'); } catch { @@ -1103,7 +1062,7 @@ class Web { }); // reverse proxy with url rewrite for couchdb attachments in - this.server.app.use('/files/', async (req: express.Request, res: express.Response): Promise => { + this.server.app.use('/files/', async (req: Request, res: Response): Promise => { // Example: /vis.0/main/img/image.png let url: string; try { @@ -1200,7 +1159,7 @@ class Web { }); // handler for oauth2 redirects - this.server.app.use('/oauth2_callbacks/', (req: express.Request, res: express.Response): void => { + this.server.app.use('/oauth2_callbacks/', (req: Request, res: Response): void => { // extract instance from "http://localhost:8081/oauth2_callbacks/netatmo.0/?state=ABC&code=CDE" const [_instance, params] = req.url.split('?'); const instance = _instance.replace(/^\//, '').replace(/\/$/, ''); // remove last and first "/" in "/netatmo.0/" @@ -1225,7 +1184,7 @@ class Web { (): void => { if (timeout) { timeout = null; - let text = fs.readFileSync(`${this.baseDir}/public/oauthError.html`).toString('utf8'); + let text = readFileSync(`${this.baseDir}/public/oauthError.html`).toString('utf8'); text = text.replace('%LANGUAGE%', this.systemLanguage); text = text.replace('%ERROR%', 'TIMEOUT'); res.setHeader('Content-Type', 'text/html'); @@ -1236,22 +1195,20 @@ class Web { ); this.adapter.sendTo(instance, 'oauth2Callback', query, result => { + const _result = result as { error?: string; result?: string }; if (timeout) { clearTimeout(timeout); timeout = null; - // @ts-expect-error fix later - if (result?.error) { - let text = fs.readFileSync(`${this.baseDir}/public/oauthError.html`).toString('utf8'); + if (_result?.error) { + let text = readFileSync(`${this.baseDir}/public/oauthError.html`).toString('utf8'); text = text.replace('%LANGUAGE%', this.systemLanguage); - // @ts-expect-error fix later - text = text.replace('%ERROR%', result.error); + text = text.replace('%ERROR%', _result.error); res.setHeader('Content-Type', 'text/html'); res.status(500).send(text); } else { - let text = fs.readFileSync(`${this.baseDir}/public/oauthSuccess.html`).toString('utf8'); + let text = readFileSync(`${this.baseDir}/public/oauthSuccess.html`).toString('utf8'); text = text.replace('%LANGUAGE%', this.systemLanguage); - // @ts-expect-error fix later - text = text.replace('%MESSAGE%', result ? result.result || '' : ''); + text = text.replace('%MESSAGE%', _result?.result || ''); res.setHeader('Content-Type', 'text/html'); res.status(200).send(text); } @@ -1260,7 +1217,7 @@ class Web { }); // 404 handler - this.server.app.use((req: express.Request, res: express.Response): void => { + this.server.app.use((req: Request, res: Response): void => { res.status(404).send(get404Page(`File ${escapeHtml(req.url)} not found`)); }); @@ -1274,30 +1231,29 @@ class Web { } catch (err) { this.adapter.log.error(`Cannot create web-server: ${err}`); if (this.adapter.terminate) { - this.adapter.terminate(utils.EXIT_CODES.ADAPTER_REQUESTED_TERMINATION); + this.adapter.terminate(EXIT_CODES.ADAPTER_REQUESTED_TERMINATION); } else { - process.exit(utils.EXIT_CODES.ADAPTER_REQUESTED_TERMINATION); + process.exit(EXIT_CODES.ADAPTER_REQUESTED_TERMINATION); } return; } if (!this.server.server) { this.adapter.log.error(`Cannot create web-server`); if (this.adapter.terminate) { - this.adapter.terminate(utils.EXIT_CODES.ADAPTER_REQUESTED_TERMINATION); + this.adapter.terminate(EXIT_CODES.ADAPTER_REQUESTED_TERMINATION); } else { - process.exit(utils.EXIT_CODES.ADAPTER_REQUESTED_TERMINATION); + process.exit(EXIT_CODES.ADAPTER_REQUESTED_TERMINATION); } return; } - // @ts-expect-error fix later this.server.server.__server = this.server; } else { this.adapter.log.error('port missing'); if (this.adapter.terminate) { - this.adapter.terminate('port missing', utils.EXIT_CODES.ADAPTER_REQUESTED_TERMINATION); + this.adapter.terminate('port missing', EXIT_CODES.ADAPTER_REQUESTED_TERMINATION); } else { - process.exit(utils.EXIT_CODES.ADAPTER_REQUESTED_TERMINATION); + process.exit(EXIT_CODES.ADAPTER_REQUESTED_TERMINATION); } } @@ -1335,9 +1291,9 @@ class Web { if (!serverListening) { if (this.adapter.terminate) { - this.adapter.terminate(utils.EXIT_CODES.ADAPTER_REQUESTED_TERMINATION); + this.adapter.terminate(EXIT_CODES.ADAPTER_REQUESTED_TERMINATION); } else { - process.exit(utils.EXIT_CODES.ADAPTER_REQUESTED_TERMINATION); + process.exit(EXIT_CODES.ADAPTER_REQUESTED_TERMINATION); } } }); diff --git a/packages/admin/src/main.ts b/packages/admin/src/main.ts index 3c097ac32..b55334c9a 100644 --- a/packages/admin/src/main.ts +++ b/packages/admin/src/main.ts @@ -532,9 +532,7 @@ class Admin extends Adapter { } onLog(obj: Record): void { - if (socket) { - socket.sendLog(obj); - } + socket?.sendLog(obj); } async createUpdateInfo(): Promise {