Extracting InVision V6 Document Export JSON Data In ColdFusion
The other day, I looked as hosting your exported InVision V6 documents on Cloudflare. This allows you to take your Prototype and Board ZIP archives, drag-and-drop them into a deployment workflow, and effortlessly publishing them "as is". Which is great for archival purposes. But, it doesn't help if you want to consume those exports programmatically. For programmatic access, I've created a ColdFusion utility that will help you extract the JSON configuration data that represents your exported document.
The InVision V6 document exports are designed to work directly from a user's local computer system. Which means that when the main index.html
file is opened, it's opened with a file://
protocol, not an https://
protocol. This creates a heightened security context in which the Angular SPA (Single-Page Application) executes. Which means, I can't use AJAX to load a .json
file from the user's computer.
In order to get the standalone experience of the exported InVision documents to work with this security model, I had to embed the configuration data inside a JavaScript file. And then use a <script>
tag to load the configuration file just as I would any other JavaScript file. Which means, the JSON configuration data is actually encoded inside a JSON.parse()
expression:
From the start, I designed the config.js
file to be future consumable. Which is why the argument of the JSON.parse()
call is shouldered by two landmarks:
// -- BEGIN_ENCODED_JSON_STRING -- //
// -- END_ENCODED_JSON_STRING -- //
In my ColdFusion utility, you upload this config.js
file (located within the public
directory of your document archive); and then I extract the encoded JSON string between said landmarks, decoded it, and return a new file, extracted-config.json
.
On the server side, this is the ColdFusion component that I'm using to extract the JSON. It builds on top of a previous post about decoding the output of encodeForJavaScript()
in ColdFusion:
component
output = false
hint = "I extract the JSON payload from the config.js file."
{
/**
* I extract the JSON string that's encoded into the given export config.js file.
*/
public string function extractJson( required string filepath ) {
// Since we're dealing with user-provided data, we can't trust it. Make sure that
// the given filepath lives inside the temporary directory used for file uploads.
if ( ! isTempFile( filepath ) ) {
throw(
type = "V6Data.Extractor.InvalidFilepath",
message = "Uploaded file is not located in temp file directory."
);
}
var fileContent = fileRead( filepath, "utf-8" );
// The embedded JSON payload lives between two comment landmarks. We need to
// locate these landmarks, extract the raw value, and decode it.
var startLandmark = "// -- BEGIN_ENCODED_JSON_STRING -- //";
var endLandmark = "// -- END_ENCODED_JSON_STRING -- //";
var startIndex = fileContent.find( startLandmark );
var endIndex = fileContent.find( endLandmark );
var startLength = startLandmark.len();
// Since we're dealing with user-provided data, we can't trust it. Make sure that
// the start/end locations make sense.
if ( endIndex <= ( startIndex + startLength ) ) {
throw(
type = "V6Data.Extractor.InvalidLandmarks",
message = "The start and end landmarks are invalid."
);
}
var encodedContent = fileContent
// Extract the content in between the landmarks.
.mid(
( startIndex + startLength ),
( endIndex - startIndex - startLength )
)
// Remove line-breaks.
.trim()
// Remove quote envelope.
.left( -1 )
.right( -1 )
;
// The JSON value has been encoded to safely render inside a browser-based
// JavaScript string context (this hex-encodes unsafe characters). In order to get
// the original JSON, we have to decode these hex-encoded characters.
var decodedContent = decodeForJavaScript( encodedContent );
if ( ! isJson( decodedContent ) ) {
throw(
type = "V6Data.Extractor.InvalidContent",
message = "The extracted content is not JSON."
);
}
return decodedContent;
}
// ---
// PRIVATE METHODS.
// ---
/**
* I return the String corresponding to the given codePoint. If the codePoint is
* outside the Basic Multilingual Plane (BMP) range (ie, above 65535), then the
* resultant string may contain multiple "characters".
*/
private string function chrFromCodePoint( required numeric codePoint ) {
// The in-built chr() function can handle code-point values up to 65535 (these
// are characters in the fixed-width 16-bit range, sometimes referred to as the
// Basic Multilingual Plane, or BMP, range). After 65535, we are dealing with
// supplementary characters that require more than 16-bits. For that, we have to
// drop down into the Java layer.
if ( codePoint <= 65535 ) {
return chr( codePoint );
}
// Since we are outside the Basic Multilingual Plane (BMP) range, the resulting
// array should contain the surrogate pair (ie, multiple characters) required to
// represent the supplementary Unicode value.
var chars = createObject( "java", "java.lang.Character" )
.toChars( val( codePoint ) )
;
return arrayToList( chars, "" );
}
/**
* I decode the given JavaScript-encoded value.
*/
private string function decodeForJavaScript( required string input ) {
// When encoding for JavaScript, each of the special characters appears to be
// encoded using using hexadecimal format with either a 2-digit notation (\xHH) or
// a 4-digit notation (\uHHHH). We can create a RegEx pattern that looks for both
// encodings, capturing each in a different group.
var decodedInput = jreReplaceAllQuoted(
input,
"(?i)\\x([0-9a-f]{2})|\\u([0-9a-f]{4})",
( $0, $1, $2 ) => {
var codePoint = $1.len()
? $1 // Hex encoding.
: $2 // Unicode encoding.
;
return chrFromCodePoint( inputBaseN( codePoint, 16 ) );
}
);
return decodedInput;
}
/**
* I determine if the given filepath points to a file in the root of the temp
* directory. This is where we expect normal uploads to live.
*/
private boolean function isTempFile( required string filepath ) {
return ( getDirectoryFromPath( filepath ) == getTempDirectory() );
}
/**
* I replace all of the pattern matches in the given input with the result of the given
* operator function. The replacements are quoted (ie, cannot consume back-references).
*/
private string function jreReplaceAllQuoted(
required string input,
required string pattern,
required function operator
) {
var matcher = createObject( "java", "java.util.regex.Pattern" )
.compile( pattern )
.matcher( input )
;
var buffer = createObject( "java", "java.lang.StringBuffer" )
.init()
;
while ( matcher.find() ) {
var args = [ matcher.group() ];
for ( var i = 1 ; i <= matcher.groupCount() ; i++ ) {
// NOTE: If I try to combine the .group() call with the fallback (?:)
// operator, it always results in an empty string. As such, I need to
// break the reading of the value into its own line. I believe this is a
// known bug in the Elvis operator implementation.
var groupValue = matcher.group( javaCast( "int", i ) );
args.append( groupValue ?: "" );
}
matcher.appendReplacement(
buffer,
matcher.quoteReplacement( operator( argumentCollection = args ) )
);
}
matcher.appendTail( buffer );
return buffer.toString();
}
}
This script is looking for the two landmarks inside the given config.js
file. If it finds them, it extracts the string in between the landmarks, strips off the quote envelope, decodes the hex-encoded values, and returns the resultant JSON payload.
This works for both prototype and board exports since they both use the same config.js
mechanism. I've attempted to provide a rough TypeScript-inspired type definition for each of the two document formats below.
InVision V6 Prototype Type Definition
Here's the basic representation of the prototype config file:
// All dates are exported as UTC milliseconds.
interface Config {
authenticatedUserID: number;
prototype: Prototype;
prototypeSettings: PrototypeSettings;
device: Device;
company: Company;
memberships: Membership[];
screens: Screen[];
screenStatuses: ScreenStatus[];
screenSettings: ScreenSettings;
screenPlaceholders: ScreenPlaceholder[];
hotspots: Hotspot[];
hotspotSettings: HotspotSettings;
dividers: Divider[];
conversations: Conversation[];
backgrounds: Background[];
tags: Tag[];
users: User[];
publicDirectory: string;
}
interface Prototype {
id: number;
userID: number;
companyID: number;
name: string;
createdAt: number;
exportedAt: number;
isMobile: boolean;
mobileDeviceID: number;
sortTypeID: number; // PrototypeSettings.sortTypes;
}
interface PrototypeSettings {
sortTypes: {
manual: 1;
alphabetical: 2;
};
}
interface Device {
id: number;
name: string;
isMobile: boolean;
width: number;
height: number;
}
interface Company {
id: number;
name: string;
}
interface Membership {
id: number;
userID: number;
startedAt: number;
lastAccessedAt: number;
role: {
id: number;
name: string;
};
}
interface Screen {
id: number;
userID: number;
name: string;
clientFilename: string;
serverFilename: string;
imageVersion: number;
screenTypeID: number;
screenGroupID: number;
sort: number;
naturalWidth: number;
naturalHeight: number;
displayScale: number;
deviceScale: number;
width: number;
height: number;
fixedHeaderHeight: number;
fixedFooterHeight: number;
alignment:
| "left"
| "right"
| "center"
;
zoomScrollBehavior: number; // ScreenSettings.zoomScrollBehaviors;
backgroundColor: string;
backgroundImageID: number;
backgroundImagePosition:
| "center"
| "50% 0 no-repeat"
| "tile"
| "repeat"
| "tile-horizontally"
| "0 0 repeat-x"
| "fixed"
;
backgroundAutostretch: boolean;
backgroundFrame: boolean;
imageVersion: number;
workflowStatusID: number;
createdAt: number;
updatedAt: number;
}
interface ScreenStatus {
id: number;
name: string;
color: string; // #{6-digit hex}
}
interface ScreenSettings {
zoomScrollBehaviors: {
normal: 1;
disableHorizontalScrolling: 2;
shrinkToViewport: 3;
};
}
interface ScreenPlaceholder {
id: number;
userID: number;
name: string;
items: ScreenPlaceholderItem[];
filename: string;
filenameKey: string;
createdAt: number;
updatedAt: number;
}
type ScreenPlaceholderItem =
| ScreenPlaceholderItemWithLabel
| ScreenPlaceholderItemWithoutLabel
;
interface ScreenPlaceholderItemWithLabel {
type: "field";
data: {
label: string;
value: string;
};
}
interface ScreenPlaceholderItemWithoutLabel {
type:
| "block"
| "button"
| "checkbox"
| "rule"
| "text"
| "title"
;
data: {
value: string;
};
}
interface Hotspot {
id: string; // "{screenID}:{id}";
screenID: number;
targetScreenID: number;
targetTypeID: number; //HotspotSettings.targetType;
eventTypeID: number; //HotspotSettings.eventTypes;
templateID: number;
metadata:
| HotspotMetadataForScreen
| HotspotMetadataForOverlay
| HotspotMetadataForPositionOnScreen
| HotspotMetadataForExternalUrl
| HotspotMetadataForLastScreenVisited
;
x: number;
y: number;
width: number;
height: number;
isScrollTo: boolean;
isBottomAligned: boolean;
}
interface HotspotMetadataForScreen {
redirectAfter?: number;
}
interface HotspotMetadataForOverlay {
stayOnScreen: boolean;
overlay: {
positionID: number; // HotspotSettings.overlayPositions;
transitionID: number; // HotspotSettings.transitionTypes;
bgOpacity: number;
positionOffset: {
x: number;
y: number;
};
reverseTransitionOnClose: boolean;
closeOnOutsideClick: boolean;
isFixedPosition: boolean;
};
}
interface HotspotMetadataForPositionOnScreen {
scrollOffset: number;
isSmoothScroll: boolean;
}
interface HotspotMetadataForExternalUrl {
url: string;
isOpenInNewWindow: boolean;
}
interface HotspotMetadataForLastScreenVisited {
stayOnScreen: boolean;
}
interface HotspotSettings {
targetTypes: {
screen: 1;
lastScreenVisited: 2;
previousScreenInSort: 3;
nextScreenInSort: 4;
externalUrl: 5;
positionOnScreen: 6;
screenOverlay: 7;
};
eventTypes: {
click: 1;
doubleTap: 2;
pressHold: 3;
swipeRight: 4;
swipeLeft: 5;
swipeUp: 6;
swipeDown: 7;
hover: 8;
autoRedirect: 9;
};
transitionTypes: {
none: 1;
pushRight: 2;
pushLeft: 3;
slideUp: 4;
slideDown: 5;
flipRight: 6;
flipLeft: 7;
dissolve: 8;
flow: 9;
pop: 10;
slideRight: 11;
slideLeft: 12;
slideFade: 13;
};
overlayPositions: {
custom: 1;
centered: 2;
topLeft: 5;
topCenter: 3;
topRight: 4;
bottomLeft: 8;
bottomCenter: 6;
bottomRight: 7;
};
overlayTransitionTypes: {
fadeInScale: 1;
slideInRight: 2;
slideInBottom: 3;
fadeIn: 4;
fall: 5;
sideFall: 6;
stickyUp: 7;
flipHorizontal: 8;
flipVertical: 9;
sign: 10;
superScaled: 11;
instant: 12;
rotateInBottom: 13;
rotateInLeft: 14;
rotateInTop: 15;
rotateInRight: 16;
slideInTop: 17;
slideInLeft: 18;
};
}
interface Divider {
id: number;
name: string;
sort: number;
}
interface Conversation {
id: number;
screenID: number;
label: string;
x: number;
y: number;
type:
| "tourpoint"
| "private"
| "note"
| "comment"
;
isComplete: boolean;
createdAt: number;
comments: Comment[];
}
interface Comment {
id: number;
userID: number;
comment: string;
createdAt: number;
}
interface Background {
id: number;
clientFilename: string;
serverFilename: string;
imageVersion: number;
createdAt: number;
width: number;
height: number;
}
interface Tag {
id: number;
name: string;
description: string;
color: string; // 6-digit hex.
userID: number;
}
interface User {
id: number;
name: string;
email: string;
}
InVision V6 Board Type Definition
Here's the basic representation of the board config file:
// All dates are exported as UTC milliseconds.
interface Config {
authenticatedUserID: number;
board: Board;
boardSettings: BoardSettings;
company: Company;
memberships: Membership[];
headerImage: HeaderImage;
items: Item[];
groups: Group[];
comments: Comment[];
users: User[];
}
interface Board {
id: number;
userID: number;
companyID: number;
name: string;
description: string;
layoutTypeID: number;
createdAt: number;
exportedAt: number;
}
interface BoardSettings {
layoutTypes: {
masonry: 1;
meticulous: 2;
grid: 3;
};
itemTypes: {
image: 1;
note: 2;
colorSwatch: 3;
font: 4;
document: 5;
media: 6;
sourceFile: 7;
generic: 8;
}
}
interface Company {
id: number;
name: string;
}
interface Membership {
id: number;
userID: number;
lastAccessedAt:
| number
| ""
;
role: {
id: number;
name: string;
}
}
interface HeaderImage {
clientFilename: string;
serverFilename: string;
imageVersion: number;
imageOffset: number;
imageBlur: number;
imageNoise: number;
imageTint: number;
imageSize: number;
}
interface Item {
id: number;
userID: number;
groupID: number;
itemTypeID: number;
name: string;
description: string;
columns: number;
sort: number;
metadata:
| ItemMetadataForColor
| ItemMetadataForDocument
| ItemMetadataForFont
| ItemMetadataForGeneric
| ItemMetadataForImage
| ItemMetadataForMedia
| ItemMetadataForNote
| ItemMetadataForSourceFile
;
createdAt: number;
updatedAt: number;
}
interface ItemMetadataForColor {
color: string;
}
interface ItemMetadataForDocument {
clientFilename: string;
serverFilename: string;
fileVersion: number;
}
interface ItemMetadataForFont {
fontFace: string;
clientFilename: string;
serverFilename: string;
fileVersion: number;
}
interface ItemMetadataForGeneric {
clientFilename: string;
serverFilename: string;
fileVersion: number;
}
interface ItemMetadataForImage {
displayScale: number;
naturalWidth: number;
naturalHeight: number;
width: number;
height: number;
imageColors?: string[];
clientFilename: string;
serverFilename: string;
fileVersion: number;
}
interface ItemMetadataForMedia {
clientFilename: string;
serverFilename: string;
fileVersion: number;
}
interface ItemMetadataForNote {
noteHtml: string;
}
interface ItemMetadataForSourceFile {
clientFilename: string;
serverFilename: string;
fileVersion: number;
}
interface Group {
id: number;
sort: number;
name: string;
description: string;
}
interface Comment {
id: number;
userID: number;
itemID: number;
comment: string;
hasAnnotation: boolean;
annotationLabel: string;
x: number;
y: number;
createdAt: number;
updatedAt: number;
}
interface User {
id: number;
name: string;
email: string;
}
If anyone has any questions about what any of the data means, just drop me a comment below.
Want to use code from this post? Check out the license.
Reader Comments
For those of you that exported prototypes, you may notice that there is
ScreenPlaceholder
type definition, but there is no placeholder experience in the offline prototype. This is true. In the InVision app, the placeholders are flattened into PNG images and used a screens. As such, they naturally slot into the prototype experience. But, I didn't want you to lose the underlying placeholder data; so, it's included in the export even if not rendered in the offline experience.Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →