diff --git a/ui/src/components/Document/DocumentViewMode.jsx b/ui/src/components/Document/DocumentViewMode.jsx index 9c5330313..111fa4e28 100644 --- a/ui/src/components/Document/DocumentViewMode.jsx +++ b/ui/src/components/Document/DocumentViewMode.jsx @@ -12,6 +12,7 @@ import FolderViewer from 'viewers/FolderViewer'; import EmailViewer from 'viewers/EmailViewer'; import VideoViewer from 'viewers/VideoViewer'; import ArticleViewer from 'viewers/ArticleViewer'; +import JsonViewer from 'viewers/JsonViewer'; import withRouter from 'app/withRouter'; import { SectionLoading } from 'components/common'; import { selectEntityDirectionality } from 'selectors'; @@ -77,6 +78,10 @@ export class DocumentViewMode extends React.Component { if (document.schema.isA('Article')) { return ; } + // Check for application/json MIME type + if (document.getProperty('mimeType')?.[0] === 'application/json') { + return ; + } return ; } @@ -92,4 +97,4 @@ const mapStateToProps = (state, ownProps) => { }; }; -export default compose(withRouter, connect(mapStateToProps))(DocumentViewMode); +export default compose(withRouter, connect(mapStateToProps))(DocumentViewMode); \ No newline at end of file diff --git a/ui/src/viewers/JsonViewer.jsx b/ui/src/viewers/JsonViewer.jsx new file mode 100644 index 000000000..e6bb5db50 --- /dev/null +++ b/ui/src/viewers/JsonViewer.jsx @@ -0,0 +1,253 @@ +import { useEffect, useState, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { NonIdealState, Spinner, Icon } from '@blueprintjs/core'; +import { useIntl } from 'react-intl'; +import queryString from 'query-string'; +import classNames from 'classnames'; + +import axios from 'axios'; +import './JsonViewer.scss'; + +// Helper function to get highlighted parts of a string +const getHighlightedParts = (text, searchTerms) => { + if (!searchTerms || searchTerms.length === 0 || typeof text !== 'string') { + return [text]; // Always return an array + } + const regex = new RegExp(`(${searchTerms.join('|')})`, 'gi'); + const parts = []; + let lastIndex = 0; + let match; + + while ((match = regex.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(text.substring(lastIndex, match.index)); + } + parts.push({match[0]}); + lastIndex = regex.lastIndex; + } + + if (lastIndex < text.length) { + parts.push(text.substring(lastIndex)); + } + return parts.length > 0 ? parts : [text]; +}; + +// Recursive rendering function for JSON nodes +const RenderJsonNode = ({ node, level = 0, searchTerms, isLastInObject }) => { + const [isOpen, setIsOpen] = useState(level < 2); // Initially open up to level 2 + + const toggleOpen = () => setIsOpen(!isOpen); + + if (typeof node === 'string') { + return "{getHighlightedParts(node, searchTerms)}"; + } + if (typeof node === 'number') { + return {node}; + } + if (typeof node === 'boolean') { + return {String(node)}; + } + if (node === null) { + return null; + } + + const padding = `json-level-${level}`; + + if (Array.isArray(node)) { + const isEmpty = node.length === 0; + return ( + + + {isOpen || isEmpty ? '[' : `[...${node.length} items...]`} + {!isEmpty && } + + {isOpen && !isEmpty && ( + <> + {node.map((item, index) => ( +
+ + {index < node.length - 1 && ,} +
+ ))} + + )} + {isOpen || isEmpty ? ']' : ''} +
+ ); + } + + if (typeof node === 'object' && node !== null) { + const keys = Object.keys(node); + const isEmpty = keys.length === 0; + return ( + + + {isOpen || isEmpty ? '{' : `{...${keys.length} props...}`} + {!isEmpty && } + + {isOpen && !isEmpty && ( + <> + {keys.map((key, index) => ( +
+ "{key}": + + {index < keys.length - 1 && ,} +
+ ))} + + )} + {isOpen || isEmpty ? '}' : ''} +
+ ); + } + return null; // Should not happen for valid JSON +}; + +RenderJsonNode.propTypes = { + node: PropTypes.any, + level: PropTypes.number, + searchTerms: PropTypes.arrayOf(PropTypes.string), + isLastInObject: PropTypes.bool, // To help with trailing commas if needed, though CSS handles it now. +}; + + +const JsonViewer = ({ document, query: queryProp }) => { + const [jsonData, setJsonData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const intl = useIntl(); + + const searchTerms = useMemo(() => { + if (queryProp) { + const parsedQuery = queryString.parse(queryProp); + if (parsedQuery && parsedQuery.q && typeof parsedQuery.q === 'string') { + // Ensure terms are not empty and are lowercased for case-insensitive search + return parsedQuery.q.toLowerCase().split(' ').filter(term => term.length > 0); + } + } + return []; + }, [queryProp]); + + useEffect(() => { + const fetchContent = async () => { + setLoading(true); + setError(null); + + const currentFileSize = Number(document?.getProperty('fileSize')?.[0] ?? 0) + + if (currentFileSize > 2500000) { + setError(intl.formatMessage({ + id: 'jsonViewer.fileSizeLimit', + defaultMessage: 'JSON Viewer not available: File size limit exceeded.', + })); + setLoading(false); + } + if (document && document.links && document.links.file) { + try { + const response = await axios.get(document.links.file, { + responseType: 'text', + transformResponse: [(data) => data], + }); + const content = response.data; + try { + const parsedData = JSON.parse(content); + setJsonData(parsedData); + } catch (e) { + setError(intl.formatMessage({ + id: 'jsonViewer.parseError', + defaultMessage: 'Error parsing JSON content.', + })); + } + } catch (e) { + setError(intl.formatMessage({ + id: 'jsonViewer.fetchError', + defaultMessage: 'Error fetching document content.', + })); + } + } else if (document && document.content) { + try { + const parsedData = JSON.parse(document.content); + setJsonData(parsedData); + } catch (e) { + setError(intl.formatMessage({ + id: 'jsonViewer.parseError', + defaultMessage: 'Error parsing JSON content.', + })); + } + } else { + setError(intl.formatMessage({ + id: 'jsonViewer.noContent', + defaultMessage: 'Document content or file link is not available.', + })); + } + setLoading(false); + }; + + fetchContent(); + }, [document, intl]); + + // Note: The getHighlightedJson and getHighlightedParts from previous step are not directly used. + // Highlighting is now integrated within RenderJsonNode's string rendering. + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( + + ); + } + + if (!jsonData) { + return ( + + ); + } + + return ( +
+
 {/* Using 
 for overall monospace font and pre-like layout */}
+        
+      
+
+ ); +}; + +JsonViewer.propTypes = { + document: PropTypes.shape({ + content: PropTypes.string, + links: PropTypes.shape({ + file: PropTypes.string, + }), + }), + query: PropTypes.string, +}; + +JsonViewer.defaultProps = { + document: null, + query: '', +}; + +export default JsonViewer; \ No newline at end of file diff --git a/ui/src/viewers/JsonViewer.scss b/ui/src/viewers/JsonViewer.scss new file mode 100644 index 000000000..80cbc85b5 --- /dev/null +++ b/ui/src/viewers/JsonViewer.scss @@ -0,0 +1,80 @@ +.json-viewer-error { + color: red; + padding: 10px; + border: 1px solid red; +} + +.json-viewer-loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; // Ensure spinner is visible + padding: 10px; +} + +.json-viewer { + font-family: monospace; + padding: 10px; + overflow-x: auto; // Handle very long lines if any + + pre { + white-space: pre; // Use pre for basic formatting, but custom components will handle wrap/indent + margin: 0; // Remove default pre margin + } + + .json-string { + color: green; + } + + .json-number { + color: darkorange; + } + + .json-boolean { + color: blue; + } + + .json-null { + color: magenta; + } + + .json-key { + color: #333; // Dark grey for keys + margin-right: 0.5em; + } + + .json-bracket, + .json-brace { + cursor: pointer; + user-select: none; // Prevent text selection on brackets/braces + .bp4-icon { // Blueprint icon styling + vertical-align: middle; + margin-left: 4px; + color: #5c7080; // Blueprint icon color + } + } + + .json-comma { + margin-right: 0.5em; + } + + // Indentation based on level + // Create padding classes for levels 0 through (say) 10 + @for $i from 0 through 10 { + .json-level-#{$i} { + padding-left: calc(#{$i} * 20px); // 20px indent per level + } + } + + .json-array-item, .json-key-value-pair { + display: block; // Each item/pair on a new line + position: relative; // For absolute positioning of commas if needed, though not used here + } + + mark { + background-color: yellow; + color: black; + padding: 0.1em; + border-radius: 3px; + } +} \ No newline at end of file