nocin.eu

Homelab, Linux, JS & ABAP (~˘▾˘)~
 

[SuccessFactors] Create JavaScript Date-Object from DateTimeOffset

The SuccessFactors oData v2 API is returning timestamps in Unix Epoch format (unix-style milliseconds since 1/1/1970).

Many timestamp fields are of type Edm.Int64. When receiving the milliseconds as Integer, you can directly create a date-object of it using Date(1658237847).

But some timestamps are of type Edm.DateTimeOffset, i.e.: "createdDate": "/Date(1652252620000+0000)/".
When binding a timestamp property with an ODataModel, the internal lib datajs will convert the /Date(...)/ value to a standard JS date-object.

But in my case I manually had to convert the timestamp and this is the shortest way I found to convert the epoch string into a JS date-object.

// SF epoch date string
const SFdateString = '/Date(1652252620000+0000)/' 

// remove the '/' on both sides and create the date object
const oDate = eval('new ' + SFdateString .slice(1, -1))

console.log(typeof oDate )
console.log(oDate )

const oDateTimeFormat = sap.ui.core.format.DateFormat.getDateTimeInstance({
          pattern: "YYYY-MM-dd HH:mm"
})
return oDateTimeFormat.format(oDate)


[Workflow] You are not one of the possible agents of task ‘&1’

The Function Module SAP_WAPI_START_WORKFLOW uses RH_TASK_START_CHECK to check, if the calling user is allowed to start the Workflow.
In it RH_TASKS_TO_START is used to read the WF and Task IDs which the user is allowed to call. But it uses a buffer and if you just did some changes to the Workflow Classification, i.e. setting it to General Task,

it can be that this check will continue to fail as it is reading old data from the buffer.

You will receive an error message from Message Class WZ: You are not one of the possible agents of task ‘&1’

I had this problem sometimes when transporting objects to the next system, but until now I could not figure out when it happens and when not.

Luckily the solution is pretty simple, just call T-Code SWU_OBUF and do a buffer refresh/synchronization.
Rob Dielemans has explained the cause very well here.

[ABAP] Read ACTION_COMMENTS from Workitem

The Attribute ACTION_COMMENTS is of type SWC_VALUE, which is a char with length 255.

If the entered text has less than < 255 characters, there is just one element named ‘ACTION_COMMENTS’ in the workitem container.

If the user entered a text with more than 255 characters, the text is split and there are more elements named with ‘ACTION_COMMENTS_1’, ‘ACTION_COMMENTS_2’ etc. (Note 3017539)

There is the function module SAP_WAPI_READ_CONTAINER to read Workitems. As input for the function module, you need the workitemId from the decision step. You will then find the ACTION_COMMENTS in the simple_container table.

    DATA: return_code              TYPE sy-subrc,
          simple_container         TYPE TABLE OF swr_cont,
          message_lines            TYPE TABLE OF swr_messag,
          message_struct           TYPE TABLE OF swr_mstruc,
          subcontainer_bor_objects TYPE TABLE OF swr_cont,
          subcontainer_all_objects TYPE TABLE OF swr_cont,
          object_content           TYPE TABLE OF solisti1.

    " Read the work item container from the work item ID
    CALL FUNCTION 'SAP_WAPI_READ_CONTAINER'
      EXPORTING
        workitem_id              = wiid
      IMPORTING
        return_code              = return_code
      TABLES
        simple_container         = simple_container
        message_lines            = message_lines
        message_struct           = message_struct
        subcontainer_bor_objects = subcontainer_bor_objects
        subcontainer_all_objects = subcontainer_all_objects.


    TRY.
        DATA(text) = simple_container[ element = 'ACTION_COMMENTS' ]-value.
      CATCH cx_sy_itab_line_not_found.
      " Check for ACTION_COMMENTS_1 etc.
      " or follow the approach below
    ENDTRY.

The comment is also added as attachment to the workitem. Just check the table subcontainer_all_objects, which is also returned by the previous function module, for attribute _ATTACH_COMMENT_OBJECTS (or _ATTACH_OBJECT or DECISION_NOTE). With function module SO_DOCUMENT_READ_API1 you can then get the actual comment.

    " Read the _ATTACH_COMMENT_OBJECTS element
    " There can be more than one comment, just take the last one
    LOOP AT subcontainer_all_objects INTO DATA(comment_object) WHERE element = '_ATTACH_COMMENT_OBJECTS'.
    ENDLOOP.

    CHECK comment_object-value IS NOT INITIAL.

    " Read the SOFM Document
    CALL FUNCTION 'SO_DOCUMENT_READ_API1'
      EXPORTING
        document_id    = CONV so_entryid( comment_object-value )
      TABLES
        object_content = object_content
      EXCEPTIONS
        OTHERS         = 1.

    LOOP AT object_content INTO DATA(lv_soli).
      CONCATENATE text lv_soli-line INTO text.
    ENDLOOP.

As you get a table as a result, it’s properly easier to read long comments this way.

[JavaScript] Itereate through array of nested objects and delete specific objects

[
    {
        "NodeId": 1,
        "HierarchyLevel": 1,
        "type": "folder",
        "nodes": [
            {
                "NodeId": 2,
                "HierarchyLevel": 2,
                "type": "folder",
                "nodes": [
                    {
                        "NodeId": 3,
                        "HierarchyLevel": 3,
                        "type": "category"
                    }
                ]
            },
            {
                "NodeId": 4,
                "HierarchyLevel": 2,
                "type": "category",
                "nodes": [
                    {
                        "NodeId": 5,
                        "HierarchyLevel": 3,
                        "type": "file"
                    }
                ]
            }
        ]
    },
    {
        "NodeId": 6,
        "HierarchyLevel": 1,
        "type": "folder",
        "nodes": [
            {
                "NodeId": 7,
                "HierarchyLevel": 2,
                "type": "category"
            }
        ]
    }
]

My task was to get rid of every Node which has no subnodes of type file at the last level of the hierachy. So for this example the result I needed was an array containing only the nodes 1,2,4,5.
Of course in reality the nested structure was way more complex. My approach was a recursive function which checks every element’s type and nodes length property and calls itself if there are any subnodes. Also it is recommended to loop backwards through the array while deleting from it.

            
            const removeEmptyNodes = nodes => {
                for (let i = nodes.length - 1; i > -1; i--) {
                    const n = nodes[i]
                    //call function recursive to go deeper through the nested structure
                    if (n.nodes) removeEmptyNodes(n.nodes)                        
                    //remove element if it's not a file and has no subnodes
                    if (n.type !== 'file' && (!n.nodes || n.nodes.length === 0)) nodes.splice(i, 1)
                }
            }

            // nodes contains the array data from above
            removeEmptyNodes(nodes)

[JavaScript] Copy an array of objects to a new array without any object references

The user tim-montague posted a great overview on the different deep copy techniques on stackoverflow:

In my case I had an array of objects which I wanted to copy without reference, therefore I used the option in the middle.

const copy = JSON.parse(JSON.stringify(myArray))

# Another way using ES6 Syntax
const copy = myArray.map(object => ({ ...object }))

[nodejs] Parsing multipart/mixed response (containing a file stream)

Recently I had to consume an API which returned multipart/mixed data. A response looked like this:

--Boundary_0000000000001
Content-Type: application/octet-stream
Content-Disposition: attachment; filename"test.pdf"

%PDF-1.7
%�������
1 0 obj
...
%%EOF

--Boundary_0000000000001
Content-Type: application/json

{"data":[]}
--Boundary_0000000000001--

There are some node packages for parsing multipart responses, but most can only handle multipart/formData and not multipart/mixed. The most recommended package for multipart/mixed is Dicer, but to be honest, I wasn’t sure how to use it properly. Therefore, I built my own parser. Luckily the user idbehold provided a function to parse a response string into a json object here. To get it working, I just had to change the regex expressions in the split function. The most important step is to convert the data from the arrayBuffer to a String in binary encoding before parsing.

Also, I wrote two helper functions. The first one to parse the boundary string from the Content-Type and the second one to parse the filename from the Content-Dispositon Header of your response.

module.exports = new class multipartMixedParser {

    parse(boundary, buffer) {
        const body = buffer.toString('binary') //toString encodes to utf-8 as default, this would lead to corrupted pdf's     
        return body.split(boundary).reduce((parts, part) => {
            if (part && part !== '--\r\n') {
                const [head, body] = part.trim().split(/\r\n\r\n/g)
                console.log({ body })
                parts.push({
                    body: body,
                    headers: head.split(/\r\n/g).reduce((headers, header) => {
                        const [key, value] = header.split(/:\s+/)
                        headers[key.toLowerCase()] = value
                        return headers
                    }, {})
                })
            }
            return parts
        }, [])
    }

    getBoundaryFromResponseHeaders(headers) {
        //example: multipart/mixed;boundary=Boundary_0000000000001 -> --Boundary_0000000000001
        const contentType = headers.get('content-type')
        return '--' + contentType.split("=")[1].split(";")[0]
    }

    getFileNameFromContentDisposition(cd) {
        //example: 'attachment; filename="example.pdf"' -> example.pdf
        return cd.slice(
            cd.indexOf('"') + 1,
            cd.lastIndexOf('"')
        )
    }

}

And that’s how I’m calling the API and using the multipartMixedParser Class to parse the response. The API I was using is expecting a file as formData and is also returning a file (as part of the multipart/mixed response).
It’s important to get the buffer from the response. If you would use response.getText() it would convert the data to an utf-8 encoded string which will lead to corrupted files.

Please note, I’m using node-fetch. When using Axios, the response object will look different.

const btoa = require('btoa')
const FormData = require('form-data')
const fetch = require('node-fetch')
const multipartMixedParser = require('./multipartMixedParser') 

function callAPI(file) {

        const form = new FormData()
        form.append('file', file.content, {
            contentType: file.mediaType,
            filename: file.fileName
        })

        const headers = {
            'Authorization': 'Basic ' + btoa(username + ':' + password),
            ...form.getHeaders()
        }

        const url = /my/api/path

        try {
            const response = await fetch(url, {
                method: 'POST',
                headers: headers,
                body: form
            })
            if (!response.ok) throw new Error(response.statusText)

            //parse the response
            const buffer = await response.buffer() 
            const boundary = multipartMixedParser.getBoundaryFromResponseHeaders(response.headers)

            const result = multipartMixedParser.parse(boundary, buffer)

            // in my case I only returned the file content as buffer and filename 
            return {
                fileContent: Buffer.from(result[0].body, 'binary'),
                fileName: multipartMixedParser.getFileNameFromContentDisposition(result[0].headers["content-disposition"])
            }
        } catch (err) {
            console.log("Error message: " + err.message)
        }

}

[SAPUI5] Get i18n texts

To simply get access to i18n texts, I useally add this helper function to my BaseController.js

// helper for direct access to the ResourceBundle getText() function
getText : function (sTextKey, aParamter) {
    return this.getOwnerComponent().getModel("i18n").getResourceBundle().getText(sTextKey, aParamter)
}

Texts can then be read in every controller with

// i18n: objects=Amount of objects: {0}
this.getText("objects", [iLength])