2023-01-27 03:22:44 -08:00
import type {
2022-03-20 01:54:31 -07:00
IDataObject ,
2023-03-09 09:13:15 -08:00
IExecuteFunctions ,
ILoadOptionsFunctions ,
2022-03-20 01:54:31 -07:00
IHookFunctions ,
IHttpRequestOptions ,
INodeExecutionData ,
INodePropertyOptions ,
IWebhookFunctions ,
} from 'n8n-workflow' ;
2023-02-23 07:16:05 -08:00
import set from 'lodash.set' ;
import concat from 'lodash.concat' ;
import split from 'lodash.split' ;
import every from 'lodash.every' ;
import toString from 'lodash.tostring' ;
import toNumber from 'lodash.tonumber' ;
import isString from 'lodash.isstring' ;
import compact from 'lodash.compact' ;
import first from 'lodash.first' ;
import last from 'lodash.last' ;
import clone from 'lodash.clone' ;
import some from 'lodash.some' ;
import isArray from 'lodash.isarray' ;
import trim from 'lodash.trim' ;
import escapeRegExp from 'lodash.escaperegexp' ;
2022-03-20 01:54:31 -07:00
2022-08-17 08:50:24 -07:00
export async function koBoToolboxApiRequest (
this : IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions ,
option : IDataObject = { } ,
) : Promise < any > {
2022-04-14 23:00:47 -07:00
const credentials = await this . getCredentials ( 'koBoToolboxApi' ) ;
2022-03-20 01:54:31 -07:00
// Set up pagination / scrolling
const returnAll = ! ! option . returnAll ;
if ( returnAll ) {
// Override manual pagination options
2023-02-23 07:16:05 -08:00
set ( option , 'qs.limit' , 3000 ) ;
2022-03-20 01:54:31 -07:00
// Don't pass this custom param to helpers.httpRequest
delete option . returnAll ;
}
const options : IHttpRequestOptions = {
url : '' ,
headers : {
2022-08-17 08:50:24 -07:00
Accept : 'application/json' ,
2022-03-20 01:54:31 -07:00
} ,
json : true ,
} ;
if ( Object . keys ( option ) ) {
Object . assign ( options , option ) ;
}
if ( options . url && ! /^http(s)?:/ . test ( options . url ) ) {
2023-01-13 09:11:56 -08:00
options . url = ( credentials . URL as string ) + options . url ;
2022-03-20 01:54:31 -07:00
}
let results = null ;
let keepLooking = true ;
while ( keepLooking ) {
2022-08-17 08:50:24 -07:00
const response = await this . helpers . httpRequestWithAuthentication . call (
this ,
'koBoToolboxApi' ,
options ,
) ;
2022-03-20 01:54:31 -07:00
// Append or set results
2023-02-23 07:16:05 -08:00
results = response . results ? concat ( results || [ ] , response . results ) : response ;
2022-03-20 01:54:31 -07:00
if ( returnAll && response . next ) {
options . url = response . next ;
2022-08-17 08:50:24 -07:00
} else {
2022-03-20 01:54:31 -07:00
keepLooking = false ;
}
}
return results ;
}
2022-12-06 02:00:53 -08:00
export async function koBoToolboxRawRequest (
this : IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions ,
option : IHttpRequestOptions ,
) : Promise < any > {
const credentials = await this . getCredentials ( 'koBoToolboxApi' ) ;
if ( option . url && ! /^http(s)?:/ . test ( option . url ) ) {
2023-01-13 09:11:56 -08:00
option . url = ( credentials . URL as string ) + option . url ;
2022-12-06 02:00:53 -08:00
}
2022-12-06 02:57:28 -08:00
return this . helpers . httpRequestWithAuthentication . call ( this , 'koBoToolboxApi' , option ) ;
2022-12-06 02:00:53 -08:00
}
2022-03-20 01:54:31 -07:00
function parseGeoPoint ( geoPoint : string ) : null | number [ ] {
// Check if it looks like a "lat lon z precision" flat string e.g. "-1.931161 30.079811 0 0" (lat, lon, elevation, precision)
2022-07-04 13:01:04 -07:00
// NOTE: we are discarding the elevation and precision values since they're not (well) supported in GeoJSON
2023-02-23 07:16:05 -08:00
const coordinates = split ( geoPoint , ' ' ) ;
2022-08-17 08:50:24 -07:00
if (
coordinates . length >= 2 &&
2023-02-23 07:16:05 -08:00
every ( coordinates , ( coord ) = > coord && /^-?\d+(?:\.\d+)?$/ . test ( toString ( coord ) ) )
2022-08-17 08:50:24 -07:00
) {
2022-03-20 01:54:31 -07:00
// NOTE: GeoJSON uses lon, lat, while most common systems use lat, lon order!
2023-02-23 07:16:05 -08:00
return [ toNumber ( coordinates [ 1 ] ) , toNumber ( coordinates [ 0 ] ) ] ;
2022-03-20 01:54:31 -07:00
}
return null ;
}
export function parseStringList ( value : string ) : string [ ] {
2023-02-23 07:16:05 -08:00
return split ( toString ( value ) , /[\s,]+/ ) ;
2022-03-20 01:54:31 -07:00
}
const matchWildcard = ( value : string , pattern : string ) : boolean = > {
2023-02-23 07:16:05 -08:00
const regex = new RegExp ( ` ^ ${ escapeRegExp ( pattern ) . replace ( '\\*' , '.*' ) } $ ` ) ;
2022-03-20 01:54:31 -07:00
return regex . test ( value ) ;
} ;
2022-08-17 08:50:24 -07:00
const formatValue = ( value : any , format : string ) : any = > {
2023-02-23 07:16:05 -08:00
if ( isString ( value ) ) {
2022-03-20 01:54:31 -07:00
// Sanitize value
2023-02-23 07:16:05 -08:00
value = toString ( value ) ;
2022-03-20 01:54:31 -07:00
// Parse geoPoints
2023-02-27 19:39:43 -08:00
const geoPoint = parseGeoPoint ( value as string ) ;
2022-03-20 01:54:31 -07:00
if ( geoPoint ) {
return {
type : 'Point' ,
coordinates : geoPoint ,
} ;
}
// Check if it's a closed polygon geo-shape: -1.954117 30.085159 0 0;-1.955005 30.084622 0 0;-1.956057 30.08506 0 0;-1.956393 30.086229 0 0;-1.955853 30.087143 0 0;-1.954609 30.08725 0 0;-1.953966 30.086735 0 0;-1.953805 30.085897 0 0;-1.954117 30.085159 0 0
const points = value . split ( ';' ) ;
2023-02-27 19:39:43 -08:00
if ( points . length >= 2 && /^[-\d\.\s;]+$/ . test ( value as string ) ) {
2022-03-20 01:54:31 -07:00
// Using the GeoJSON format as per https://geojson.org/
2023-02-27 19:39:43 -08:00
const coordinates = compact ( points . map ( parseGeoPoint ) as number [ ] ) ;
2022-03-20 01:54:31 -07:00
// Only return if all values are properly parsed
if ( coordinates . length === points . length ) {
2022-07-04 13:01:04 -07:00
// If the shape is closed, declare it as Polygon, otherwise as LineString
2023-02-27 19:39:43 -08:00
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
2023-02-23 07:16:05 -08:00
if ( first ( points ) === last ( points ) ) {
2022-08-17 08:50:24 -07:00
return {
2022-07-04 13:01:04 -07:00
type : 'Polygon' ,
2022-07-04 13:06:38 -07:00
coordinates : [ coordinates ] ,
2022-07-04 13:01:04 -07:00
} ;
2022-08-17 08:50:24 -07:00
}
return { type : 'LineString' , coordinates } ;
2022-03-20 01:54:31 -07:00
}
}
// Parse numbers
if ( 'number' === format ) {
2023-02-23 07:16:05 -08:00
return toNumber ( value ) ;
2022-03-20 01:54:31 -07:00
}
// Split multi-select
if ( 'multiSelect' === format ) {
2023-02-23 07:16:05 -08:00
return split ( toString ( value ) , ' ' ) ;
2022-03-20 01:54:31 -07:00
}
}
return value ;
} ;
2022-08-17 08:50:24 -07:00
export function formatSubmission (
submission : IDataObject ,
selectMasks : string [ ] = [ ] ,
numberMasks : string [ ] = [ ] ,
) : IDataObject {
2022-03-20 01:54:31 -07:00
// Create a shallow copy of the submission
const response = { } as IDataObject ;
for ( const key of Object . keys ( submission ) ) {
2023-02-23 07:16:05 -08:00
let value = clone ( submission [ key ] ) ;
2022-03-20 01:54:31 -07:00
// Sanitize key names: split by group, trim _
2022-08-17 08:50:24 -07:00
const sanitizedKey = key
. split ( '/' )
2023-02-23 07:16:05 -08:00
. map ( ( k ) = > trim ( k , ' _' ) )
2022-08-17 08:50:24 -07:00
. join ( '.' ) ;
2023-01-19 04:37:19 -08:00
const leafKey = sanitizedKey . split ( '.' ) . pop ( ) || '' ;
2022-03-20 01:54:31 -07:00
let format = 'string' ;
2023-02-23 07:16:05 -08:00
if ( some ( numberMasks , ( mask ) = > matchWildcard ( leafKey , mask ) ) ) {
2022-03-20 01:54:31 -07:00
format = 'number' ;
}
2023-02-23 07:16:05 -08:00
if ( some ( selectMasks , ( mask ) = > matchWildcard ( leafKey , mask ) ) ) {
2022-03-20 01:54:31 -07:00
format = 'multiSelect' ;
}
value = formatValue ( value , format ) ;
2023-02-23 07:16:05 -08:00
set ( response , sanitizedKey , value ) ;
2022-03-20 01:54:31 -07:00
}
// Reformat _geolocation
2022-08-17 08:50:24 -07:00
if (
2023-02-23 07:16:05 -08:00
isArray ( response . geolocation ) &&
2022-08-17 08:50:24 -07:00
response . geolocation . length === 2 &&
response . geolocation [ 0 ] &&
response . geolocation [ 1 ]
) {
2022-03-20 01:54:31 -07:00
response . geolocation = {
type : 'Point' ,
coordinates : [ response . geolocation [ 1 ] , response . geolocation [ 0 ] ] ,
} ;
}
return response ;
}
2022-08-17 08:50:24 -07:00
export async function downloadAttachments (
this : IExecuteFunctions | IWebhookFunctions ,
submission : IDataObject ,
options : IDataObject ,
) : Promise < INodeExecutionData > {
2022-03-20 01:54:31 -07:00
// Initialize return object with the original submission JSON content
const binaryItem : INodeExecutionData = {
json : {
. . . submission ,
} ,
binary : { } ,
} ;
2022-04-14 23:00:47 -07:00
const credentials = await this . getCredentials ( 'koBoToolboxApi' ) ;
2022-03-20 01:54:31 -07:00
// Look for attachment links - there can be more than one
2023-01-19 04:37:19 -08:00
const attachmentList = ( submission . _attachments || submission . attachments ) as any [ ] ;
2022-12-02 12:54:28 -08:00
if ( attachmentList ? . length ) {
2022-03-20 01:54:31 -07:00
for ( const [ index , attachment ] of attachmentList . entries ( ) ) {
// look for the question name linked to this attachment
2022-07-04 13:01:04 -07:00
const fileName = attachment . filename ;
2023-02-23 07:16:05 -08:00
const sanitizedFileName = toString ( fileName ) . replace ( /_[^_]+(?=\.\w+)/ , '' ) ; // strip suffix
2022-07-04 13:01:04 -07:00
2022-05-14 02:20:45 -07:00
let relatedQuestion = null ;
2022-08-17 08:50:24 -07:00
if ( 'question' === options . binaryNamingScheme ) {
for ( const question of Object . keys ( submission ) ) {
2022-07-04 13:01:04 -07:00
// The logic to map attachments to question is sometimes ambiguous:
// - If the attachment is linked to a question, the question's value is the same as the attachment's filename (with spaces replaced by underscores)
// - BUT sometimes the attachment's filename has an extra suffix, e.g. "My_Picture_0OdlaKJ.jpg", would map to the question "picture": "My Picture.jpg"
2023-02-23 07:16:05 -08:00
const sanitizedQuestionValue = toString ( submission [ question ] ) . replace ( /\s/g , '_' ) ; // replace spaces with underscores
2022-07-04 13:06:38 -07:00
if ( sanitizedFileName === sanitizedQuestionValue ) {
2022-05-14 02:20:45 -07:00
relatedQuestion = question ;
2022-07-04 13:01:04 -07:00
// Just use the first match...
break ;
2022-05-14 02:20:45 -07:00
}
2022-07-04 13:01:04 -07:00
}
2022-05-14 02:20:45 -07:00
}
2022-03-20 01:54:31 -07:00
// Download attachment
// NOTE: this needs to follow redirects (possibly across domains), while keeping Authorization headers
// The Axios client will not propagate the Authorization header on redirects (see https://github.com/axios/axios/issues/3607), so we need to follow ourselves...
let response = null ;
2022-08-17 08:50:24 -07:00
const attachmentUrl =
attachment [ options . version as string ] || ( attachment . download_url as string ) ;
let final = false ,
redir = 0 ;
2022-03-20 01:54:31 -07:00
const axiosOptions : IHttpRequestOptions = {
url : attachmentUrl ,
method : 'GET' ,
headers : {
2022-08-17 08:50:24 -07:00
Authorization : ` Token ${ credentials . token } ` ,
2022-03-20 01:54:31 -07:00
} ,
ignoreHttpStatusErrors : true ,
returnFullResponse : true ,
disableFollowRedirect : true ,
encoding : 'arraybuffer' ,
} ;
while ( ! final && redir < 5 ) {
response = await this . helpers . httpRequest ( axiosOptions ) ;
2022-12-02 12:54:28 -08:00
if ( response ? . headers . location ) {
2022-03-20 01:54:31 -07:00
// Follow redirect
axiosOptions . url = response . headers . location ;
redir ++ ;
} else {
final = true ;
}
}
2022-12-02 12:54:28 -08:00
if ( response ? . body ) {
2022-05-14 02:20:45 -07:00
// Use the provided prefix if any, otherwise try to use the original question name
let binaryName ;
2022-08-17 08:50:24 -07:00
if ( 'question' === options . binaryNamingScheme && relatedQuestion ) {
2022-05-14 02:20:45 -07:00
binaryName = relatedQuestion ;
2022-08-17 08:50:24 -07:00
} else {
2023-01-19 04:37:19 -08:00
binaryName = ` ${ options . dataPropertyAttachmentsPrefixName || 'attachment_' } ${ index } ` ;
2022-05-14 02:20:45 -07:00
}
2022-08-17 08:50:24 -07:00
binaryItem . binary ! [ binaryName ] = await this . helpers . prepareBinaryData (
2023-02-27 19:39:43 -08:00
response . body as Buffer ,
fileName as string ,
2022-08-17 08:50:24 -07:00
) ;
2022-03-20 01:54:31 -07:00
}
}
} else {
delete binaryItem . binary ;
}
// Add item to final output - even if there's no attachment retrieved
return binaryItem ;
}
export async function loadForms ( this : ILoadOptionsFunctions ) : Promise < INodePropertyOptions [ ] > {
const responseData = await koBoToolboxApiRequest . call ( this , {
url : '/api/v2/assets/' ,
qs : {
q : 'asset_type:survey' ,
ordering : 'name' ,
} ,
scroll : true ,
} ) ;
2022-12-02 06:25:21 -08:00
return responseData ? . map ( ( survey : any ) = > ( { name : survey.name , value : survey.uid } ) ) || [ ] ;
2022-03-20 01:54:31 -07:00
}