2022-08-17 08:50:24 -07:00
import { IExecuteFunctions , ILoadOptionsFunctions } from 'n8n-core' ;
2022-03-20 01:54:31 -07:00
import {
IDataObject ,
IHookFunctions ,
IHttpRequestOptions ,
INodeExecutionData ,
INodePropertyOptions ,
IWebhookFunctions ,
} from 'n8n-workflow' ;
2022-04-08 14:32:08 -07:00
import _ from 'lodash' ;
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 = { } ,
// tslint:disable-next-line:no-any
) : 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
_ . set ( option , 'qs.limit' , 3000 ) ;
// 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 ) ) {
options . url = credentials . URL + options . url ;
}
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
results = response . results ? _ . concat ( results || [ ] , response . results ) : response ;
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 ;
}
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
2022-03-20 01:54:31 -07:00
const coordinates = _ . split ( geoPoint , ' ' ) ;
2022-08-17 08:50:24 -07:00
if (
coordinates . length >= 2 &&
_ . every ( coordinates , ( coord ) = > coord && /^-?\d+(?:\.\d+)?$/ . test ( _ . toString ( coord ) ) )
) {
2022-03-20 01:54:31 -07:00
// NOTE: GeoJSON uses lon, lat, while most common systems use lat, lon order!
2022-08-17 08:50:24 -07:00
return [ _ . toNumber ( coordinates [ 1 ] ) , _ . toNumber ( coordinates [ 0 ] ) ] ;
2022-03-20 01:54:31 -07:00
}
return null ;
}
export function parseStringList ( value : string ) : string [ ] {
return _ . split ( _ . toString ( value ) , /[\s,]+/ ) ;
}
const matchWildcard = ( value : string , pattern : string ) : boolean = > {
const regex = new RegExp ( ` ^ ${ _ . escapeRegExp ( pattern ) . replace ( '\\*' , '.*' ) } $ ` ) ;
return regex . test ( value ) ;
} ;
2022-08-17 08:50:24 -07:00
// tslint:disable-next-line:no-any
const formatValue = ( value : any , format : string ) : any = > {
2022-03-20 01:54:31 -07:00
if ( _ . isString ( value ) ) {
// Sanitize value
value = _ . toString ( value ) ;
// Parse geoPoints
const geoPoint = parseGeoPoint ( value ) ;
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 ( ';' ) ;
if ( points . length >= 2 && /^[-\d\.\s;]+$/ . test ( value ) ) {
// Using the GeoJSON format as per https://geojson.org/
const coordinates = _ . compact ( points . map ( parseGeoPoint ) ) ;
// 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
2022-08-17 08:50:24 -07:00
if ( _ . first ( points ) === _ . last ( points ) ) {
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 ) {
return _ . toNumber ( value ) ;
}
// Split multi-select
if ( 'multiSelect' === format ) {
return _ . split ( _ . toString ( value ) , ' ' ) ;
}
}
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 ) ) {
let value = _ . clone ( submission [ key ] ) ;
// Sanitize key names: split by group, trim _
2022-08-17 08:50:24 -07:00
const sanitizedKey = key
. split ( '/' )
. map ( ( k ) = > _ . trim ( k , ' _' ) )
. join ( '.' ) ;
2022-03-20 01:54:31 -07:00
const leafKey = sanitizedKey . split ( '.' ) . pop ( ) || '' ;
let format = 'string' ;
2022-08-17 08:50:24 -07:00
if ( _ . some ( numberMasks , ( mask ) = > matchWildcard ( leafKey , mask ) ) ) {
2022-03-20 01:54:31 -07:00
format = 'number' ;
}
2022-08-17 08:50:24 -07:00
if ( _ . some ( selectMasks , ( mask ) = > matchWildcard ( leafKey , mask ) ) ) {
2022-03-20 01:54:31 -07:00
format = 'multiSelect' ;
}
value = formatValue ( value , format ) ;
_ . set ( response , sanitizedKey , value ) ;
}
// Reformat _geolocation
2022-08-17 08:50:24 -07:00
if (
_ . isArray ( response . geolocation ) &&
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
2022-08-17 08:50:24 -07:00
const attachmentList = ( submission [ '_attachments' ] || submission [ 'attachments' ] ) as any [ ] ; // tslint:disable-line:no-any
2022-03-20 01:54:31 -07:00
if ( attachmentList && attachmentList . length ) {
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 ;
2022-08-17 08:50:24 -07: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"
2022-08-17 08:50:24 -07: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 ) ;
if ( response && response . headers . location ) {
// Follow redirect
axiosOptions . url = response . headers . location ;
redir ++ ;
} else {
final = true ;
}
}
if ( response && 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 {
2022-05-14 02:20:45 -07:00
binaryName = ` ${ options . dataPropertyAttachmentsPrefixName || 'attachment_' } ${ index } ` ;
}
2022-08-17 08:50:24 -07:00
binaryItem . binary ! [ binaryName ] = await this . helpers . prepareBinaryData (
response . body ,
fileName ,
) ;
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-08-17 08:50:24 -07:00
return responseData ? . map ( ( survey : any ) = > ( { name : survey.name , value : survey.uid } ) ) || [ ] ; // tslint:disable-line:no-any
2022-03-20 01:54:31 -07:00
}