Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/base/src/formbuilder/formselectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { TileSourcePropertiesForm } from './objectform/tilesourceform';
import { VectorLayerPropertiesForm } from './objectform/vectorlayerform';
import { WebGlLayerPropertiesForm } from './objectform/webGlLayerForm';
import { GeoTiffSourcePropertiesForm } from './objectform/geotiffsource';
import { PathBasedSourcePropertiesForm } from './objectform/pathbasedsource';

export function getLayerTypeForm(
layerType: LayerType
Expand Down Expand Up @@ -36,6 +37,12 @@ export function getSourceTypeForm(sourceType: SourceType): typeof BaseForm {
case 'GeoJSONSource':
SourceForm = GeoJSONSourcePropertiesForm;
break;
case 'ImageSource':
SourceForm = PathBasedSourcePropertiesForm;
break;
case 'ShapefileSource':
SourceForm = PathBasedSourcePropertiesForm;
break;
case 'GeoTiffSource':
SourceForm = GeoTiffSourcePropertiesForm;
break;
Expand All @@ -45,6 +52,7 @@ export function getSourceTypeForm(sourceType: SourceType): typeof BaseForm {
break;
// ADD MORE FORM TYPES HERE
}
(SourceForm as any).sourceType = sourceType;

return SourceForm;
}
15 changes: 0 additions & 15 deletions packages/base/src/formbuilder/objectform/baseform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { Signal } from '@lumino/signaling';
import { deepCopy } from '../../tools';
import { IDict } from '../../types';
import { Slider, SliderLabel } from '@jupyter/react-components';
import { FileSelectorWidget } from './fileselectorwidget';

export interface IBaseFormStates {
schema?: IDict;
Expand Down Expand Up @@ -202,20 +201,6 @@ export class BaseForm extends React.Component<IBaseFormProps, IBaseFormStates> {
this.removeFormEntry(k, data, schema, uiSchema);
}
}

// Customize the widget for path field
if (schema.properties && schema.properties.path) {
const docManager =
this.props.formChangedSignal?.sender.props.formSchemaRegistry.getDocManager();

uiSchema.path = {
'ui:widget': FileSelectorWidget,
'ui:options': {
docManager,
formOptions: this.props
}
};
}
});
}

Expand Down
15 changes: 11 additions & 4 deletions packages/base/src/formbuilder/objectform/fileselectorwidget.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { FileDialog } from '@jupyterlab/filebrowser';
import { Dialog } from '@jupyterlab/apputils';
import { CreationFormDialog } from '../../dialogs/formdialog';
Expand All @@ -10,9 +10,10 @@ export const FileSelectorWidget = (props: any) => {

const [serverFilePath, setServerFilePath] = useState('');
const [urlPath, setUrlPath] = useState('');
const isTypingURL = useRef(false); // Tracks whether the user is manually typing a URL

useEffect(() => {
if (props.value) {
if (!isTypingURL.current && props.value) {
if (
props.value.startsWith('http://') ||
props.value.startsWith('https://')
Expand Down Expand Up @@ -85,12 +86,17 @@ export const FileSelectorWidget = (props: any) => {
};

const handleURLChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const url = event.target.value;
setServerFilePath('');
const url = event.target.value.trim();
isTypingURL.current = true;
setUrlPath(url);
setServerFilePath('');
props.onChange(url);
};

const handleURLBlur = () => {
isTypingURL.current = false;
};

return (
<div>
<div>
Expand All @@ -114,6 +120,7 @@ export const FileSelectorWidget = (props: any) => {
id="root_path"
className="jp-mod-styled"
onChange={handleURLChange}
onBlur={handleURLBlur}
value={urlPath || ''}
style={{ width: '100%' }}
/>
Expand Down
49 changes: 7 additions & 42 deletions packages/base/src/formbuilder/objectform/geojsonsource.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { IDict } from '@jupytergis/schema';
import { showErrorMessage } from '@jupyterlab/apputils';
import { IChangeEvent, ISubmitEvent } from '@rjsf/core';
import { Ajv, ValidateFunction } from 'ajv';
import * as geojson from '@jupytergis/schema/src/schema/geojson.json';

import { BaseForm, IBaseFormProps } from './baseform';
import { IBaseFormProps } from './baseform';
import { PathBasedSourcePropertiesForm } from './pathbasedsource';
import { loadFile } from '../../tools';

/**
* The form to modify a GeoJSON source.
*/
export class GeoJSONSourcePropertiesForm extends BaseForm {
export class GeoJSONSourcePropertiesForm extends PathBasedSourcePropertiesForm {
private _validate: ValidateFunction;

constructor(props: IBaseFormProps) {
super(props);
const ajv = new Ajv();
Expand All @@ -28,48 +29,14 @@ export class GeoJSONSourcePropertiesForm extends BaseForm {
}

super.processSchema(data, schema, uiSchema);
if (!schema.properties || !data) {
return;
}

// This is not user-editable
delete schema.properties.valid;
}

protected onFormBlur(id: string, value: any) {
// Is there a better way to spot the path text entry?
if (!id.endsWith('_path')) {
return;
}

this._validatePath(value);
}

// we need to use `onFormChange` instead of `onFormBlur` because it's no longer a text field
protected onFormChange(e: IChangeEvent): void {
super.onFormChange(e);
if (e.formData?.path) {
this._validatePath(e.formData.path);
}
}

protected onFormSubmit(e: ISubmitEvent<any>) {
if (this.state.extraErrors?.path?.__errors?.length >= 1) {
showErrorMessage(
'Invalid JSON file',
this.state.extraErrors.path.__errors[0]
);
return;
}
super.onFormSubmit(e);
}

/**
* Validate the path, to avoid invalid path or invalid GeoJSON.
*
* @param path - the path to validate.
*/
private async _validatePath(path: string) {
protected async _validatePath(path: string) {
const extraErrors: IDict = this.state.extraErrors;

let error = '';
Expand All @@ -78,7 +45,7 @@ export class GeoJSONSourcePropertiesForm extends BaseForm {
try {
const geoJSONData = await loadFile({
filepath: path,
type: 'GeoJSONSource',
type: this._sourceType,
model: this.props.model
});
valid = this._validate(geoJSONData);
Expand Down Expand Up @@ -112,6 +79,4 @@ export class GeoJSONSourcePropertiesForm extends BaseForm {
this.props.formErrorSignal.emit(!valid);
}
}

private _validate: ValidateFunction;
}
119 changes: 119 additions & 0 deletions packages/base/src/formbuilder/objectform/pathbasedsource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { IDict, SourceType } from '@jupytergis/schema';
import { showErrorMessage } from '@jupyterlab/apputils';
import { IChangeEvent, ISubmitEvent } from '@rjsf/core';

import { BaseForm, IBaseFormProps } from './baseform';
import { loadFile } from '../../tools';
import { FileSelectorWidget } from './fileselectorwidget';

/**
* The form to modify a PathBasedSource source.
*/
export class PathBasedSourcePropertiesForm extends BaseForm {
protected _sourceType: SourceType;

constructor(props: IBaseFormProps) {
super(props);
this._sourceType = (
this.constructor as typeof BaseForm & { sourceType: SourceType }
).sourceType;
if (this._sourceType !== 'GeoJSONSource') {
this._validatePath(props.sourceData?.path ?? '');
}
}

protected processSchema(
data: IDict<any> | undefined,
schema: IDict,
uiSchema: IDict
) {
super.processSchema(data, schema, uiSchema);
if (!schema.properties || !data) {
return;
}

// Customize the widget for path field
if (schema.properties && schema.properties.path) {
const docManager =
this.props.formChangedSignal?.sender.props.formSchemaRegistry.getDocManager();

uiSchema.path = {
'ui:widget': FileSelectorWidget,
'ui:options': {
docManager,
formOptions: this.props
}
};
}
// This is not user-editable
delete schema.properties.valid;
}

protected onFormBlur(id: string, value: any) {
// Is there a better way to spot the path text entry?
if (!id.endsWith('_path')) {
return;
}
this._validatePath(value);
}

// we need to use `onFormChange` instead of `onFormBlur` because it's no longer a text field
protected onFormChange(e: IChangeEvent): void {
super.onFormChange(e);
if (e.formData?.path !== undefined) {
this._validatePath(e.formData.path);
}
}

protected onFormSubmit(e: ISubmitEvent<any>) {
if (this.state.extraErrors?.path?.__errors?.length >= 1) {
showErrorMessage('Invalid file', this.state.extraErrors.path.__errors[0]);
return;
}
super.onFormSubmit(e);
}

/**
* Validate the path, to avoid invalid path.
*
* @param path - the path to validate.
*/
protected async _validatePath(path: string) {
const extraErrors: IDict = this.state.extraErrors;

let error = '';
let valid = true;
if (!path) {
valid = false;
error = 'Path is required';
} else {
try {
await loadFile({
filepath: path,
type: this._sourceType,
model: this.props.model
});
} catch (e) {
valid = false;
error = `"${path}" is not a valid ${this._sourceType} file.`;
}
}

if (!valid) {
extraErrors.path = {
__errors: [error]
};

this.setState(old => ({ ...old, extraErrors }));
} else {
this.setState(old => ({
...old,
extraErrors: { ...extraErrors, path: { __errors: [] } }
}));
}

if (this.props.formErrorSignal) {
this.props.formErrorSignal.emit(!valid);
}
}
}
62 changes: 60 additions & 2 deletions packages/base/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,24 @@ export const loadFile = async (fileInfo: {
if (filepath.startsWith('http://') || filepath.startsWith('https://')) {
switch (type) {
case 'ImageSource': {
return filepath; // Return the URL directly
try {
const response = await fetch(filepath);
if (!response.ok) {
throw new Error(`Failed to fetch image from URL: ${filepath}`);
}

const contentType = response.headers.get('Content-Type');
if (!contentType || !contentType.startsWith('image/')) {
throw new Error(`Invalid image URL. Content-Type: ${contentType}`);
}

// load the image to verify it's not corrupted
await validateImage(await response.blob());
return filepath;
} catch (error) {
console.error('Error validating remote image:', error);
throw error;
}
}

case 'ShapefileSource': {
Expand Down Expand Up @@ -546,7 +563,18 @@ export const loadFile = async (fileInfo: {
case 'ImageSource': {
if (typeof file.content === 'string') {
const mimeType = getMimeType(filepath);
return `data:${mimeType};base64,${file.content}`;
if (!mimeType.startsWith('image/')) {
throw new Error(`Invalid image file. MIME type: ${mimeType}`);
}

// Attempt to decode the base64 data
try {
await validateImage(await base64ToBlob(file.content, mimeType));
return `data:${mimeType};base64,${file.content}`;
} catch (error) {
console.error('Error image content failed to decode.:', error);
throw error;
}
} else {
throw new Error('Invalid file format for image content.');
}
Expand All @@ -562,6 +590,36 @@ export const loadFile = async (fileInfo: {
}
};

/**
* Validates whether a given Blob represents a valid image.
*
* @param blob - The Blob to validate.
* @returns A promise that resolves if the Blob is a valid image, or rejects with an error otherwise.
*/
const validateImage = async (blob: Blob): Promise<void> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(); // Valid image
img.onerror = () => reject(new Error('Invalid image content.'));
img.src = URL.createObjectURL(blob);
});
};

/**
* Converts a base64-encoded string to a Blob.
*
* @param base64 - The base64-encoded string representing the file data.
* @param mimeType - The MIME type of the data.
* @returns A promise that resolves to a Blob representing the decoded data.
*/
export const base64ToBlob = async (
base64: string,
mimeType: string
): Promise<Blob> => {
const response = await fetch(`data:${mimeType};base64,${base64}`);
return await response.blob();
};

/**
* A mapping of file extensions to their corresponding MIME types.
*/
Expand Down
Loading