2017-03-12 00:31:23 +00:00
|
|
|
import CodeMirror from 'codemirror';
|
|
|
|
import 'codemirror/addon/mode/overlay';
|
2017-06-09 01:10:12 +00:00
|
|
|
import {getDefaultFill} from '../../../../templating/utils';
|
2017-08-04 16:54:11 +00:00
|
|
|
import {escapeRegex} from '../../../../common/misc';
|
2017-03-12 00:31:23 +00:00
|
|
|
|
2017-03-12 22:44:46 +00:00
|
|
|
const NAME_MATCH_FLEXIBLE = /[\w.\][\-/]+$/;
|
|
|
|
const NAME_MATCH = /[\w.\][]+$/;
|
2017-03-12 00:31:23 +00:00
|
|
|
const AFTER_VARIABLE_MATCH = /{{\s*[\w.\][]*$/;
|
|
|
|
const AFTER_TAG_MATCH = /{%\s*[\w.\][]*$/;
|
2017-03-12 23:53:49 +00:00
|
|
|
const COMPLETE_AFTER_WORD = /[\w.\][-]+/;
|
2017-03-12 00:31:23 +00:00
|
|
|
const COMPLETE_AFTER_CURLIES = /[^{]*\{[{%]\s*/;
|
2017-03-12 23:53:49 +00:00
|
|
|
const COMPLETION_CLOSE_KEYS = /[}|-]/;
|
2017-03-12 00:31:23 +00:00
|
|
|
const MAX_HINT_LOOK_BACK = 100;
|
2017-04-20 17:18:00 +00:00
|
|
|
const HINT_DELAY_MILLIS = 700;
|
2017-03-12 00:31:23 +00:00
|
|
|
const TYPE_VARIABLE = 'variable';
|
|
|
|
const TYPE_TAG = 'tag';
|
|
|
|
const TYPE_CONSTANT = 'constant';
|
2017-03-12 05:38:44 +00:00
|
|
|
const MAX_CONSTANTS = -1;
|
|
|
|
const MAX_VARIABLES = -1;
|
|
|
|
const MAX_TAGS = -1;
|
2017-03-12 00:31:23 +00:00
|
|
|
|
|
|
|
const ICONS = {
|
2017-03-29 23:09:28 +00:00
|
|
|
[TYPE_CONSTANT]: {char: '𝒄', title: 'Constant'},
|
|
|
|
[TYPE_VARIABLE]: {char: '𝑥', title: 'Environment Variable'},
|
|
|
|
[TYPE_TAG]: {char: 'ƒ', title: 'Generator Tag'}
|
2017-03-12 00:31:23 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
CodeMirror.defineExtension('isHintDropdownActive', function () {
|
|
|
|
return (
|
|
|
|
this.state.completionActive &&
|
|
|
|
this.state.completionActive.data &&
|
|
|
|
this.state.completionActive.data.list &&
|
|
|
|
this.state.completionActive.data.list.length
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
2017-04-04 23:06:43 +00:00
|
|
|
CodeMirror.defineExtension('closeHint', function () {
|
|
|
|
if (this.state.completionActive) {
|
|
|
|
this.state.completionActive.close();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2017-03-12 00:31:23 +00:00
|
|
|
CodeMirror.defineOption('environmentAutocomplete', null, (cm, options) => {
|
|
|
|
if (!options) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2017-03-23 22:10:42 +00:00
|
|
|
async function completeAfter (cm, fn, showAllOnNoMatch = false) {
|
2017-03-12 00:31:23 +00:00
|
|
|
// Bail early if didn't match the callback test
|
|
|
|
if (fn && !fn()) {
|
2017-03-23 22:10:42 +00:00
|
|
|
return;
|
2017-03-12 00:31:23 +00:00
|
|
|
}
|
|
|
|
|
2017-04-20 17:18:00 +00:00
|
|
|
if (!cm.hasFocus()) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2017-03-12 00:31:23 +00:00
|
|
|
// Bail early if completions are showing already
|
|
|
|
if (cm.isHintDropdownActive()) {
|
2017-03-23 22:10:42 +00:00
|
|
|
return;
|
2017-03-12 00:31:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
let hintsContainer = document.querySelector('#hints-container');
|
|
|
|
if (!hintsContainer) {
|
|
|
|
const el = document.createElement('div');
|
|
|
|
el.id = 'hints-container';
|
2017-06-16 20:29:22 +00:00
|
|
|
el.className = 'theme--dropdown__menu';
|
2017-03-12 00:31:23 +00:00
|
|
|
document.body.appendChild(el);
|
|
|
|
hintsContainer = el;
|
|
|
|
}
|
|
|
|
|
2017-03-30 18:52:07 +00:00
|
|
|
const constants = options.getConstants ? await options.getConstants() : null;
|
|
|
|
const variables = options.getVariables ? await options.getVariables() : null;
|
2017-03-31 21:59:12 +00:00
|
|
|
const tags = options.getTags ? await options.getTags() : null;
|
2017-03-12 00:31:23 +00:00
|
|
|
|
|
|
|
// Actually show the hint
|
|
|
|
cm.showHint({
|
|
|
|
// Insomnia-specific options
|
2017-03-30 18:52:07 +00:00
|
|
|
constants: constants || [],
|
|
|
|
variables: variables || [],
|
2017-03-31 21:59:12 +00:00
|
|
|
tags: tags || [],
|
2017-03-30 18:52:07 +00:00
|
|
|
showAllOnNoMatch,
|
2017-03-12 00:31:23 +00:00
|
|
|
|
|
|
|
// Codemirror native options
|
|
|
|
hint,
|
|
|
|
container: hintsContainer,
|
|
|
|
closeCharacters: COMPLETION_CLOSE_KEYS,
|
|
|
|
completeSingle: false,
|
|
|
|
extraKeys: {
|
|
|
|
'Tab': (cm, widget) => {
|
|
|
|
// Override default behavior and don't select hint on Tab
|
|
|
|
widget.close();
|
|
|
|
return CodeMirror.Pass;
|
|
|
|
}
|
|
|
|
}
|
2017-04-04 23:06:43 +00:00
|
|
|
|
|
|
|
// Good for debugging
|
2017-06-16 20:29:22 +00:00
|
|
|
// ,closeOnUnfocus: false
|
2017-03-12 00:31:23 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
function completeIfInVariableName (cm) {
|
2017-03-23 22:10:42 +00:00
|
|
|
completeAfter(cm, () => {
|
2017-03-12 00:31:23 +00:00
|
|
|
const cur = cm.getCursor();
|
|
|
|
const pos = CodeMirror.Pos(cur.line, cur.ch - MAX_HINT_LOOK_BACK);
|
|
|
|
const range = cm.getRange(pos, cur);
|
2017-03-12 23:53:49 +00:00
|
|
|
return range.match(COMPLETE_AFTER_WORD);
|
2017-03-12 00:31:23 +00:00
|
|
|
});
|
2017-03-23 22:10:42 +00:00
|
|
|
|
|
|
|
return CodeMirror.Pass;
|
2017-03-12 00:31:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
function completeIfAfterTagOrVarOpen (cm) {
|
2017-03-23 22:10:42 +00:00
|
|
|
completeAfter(cm, () => {
|
2017-03-12 00:31:23 +00:00
|
|
|
const cur = cm.getCursor();
|
|
|
|
const pos = CodeMirror.Pos(cur.line, cur.ch - MAX_HINT_LOOK_BACK);
|
|
|
|
const range = cm.getRange(pos, cur);
|
|
|
|
return range.match(COMPLETE_AFTER_CURLIES);
|
|
|
|
}, true);
|
2017-03-23 22:10:42 +00:00
|
|
|
|
|
|
|
return CodeMirror.Pass;
|
2017-03-12 00:31:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
function completeForce (cm) {
|
2017-03-23 22:10:42 +00:00
|
|
|
completeAfter(cm, null, true);
|
|
|
|
return CodeMirror.Pass;
|
2017-03-12 00:31:23 +00:00
|
|
|
}
|
|
|
|
|
2017-07-25 18:00:30 +00:00
|
|
|
let keydownDebounce = null;
|
2017-03-16 17:51:56 +00:00
|
|
|
|
2017-03-12 00:31:23 +00:00
|
|
|
cm.on('keydown', (cm, e) => {
|
|
|
|
// Only operate on one-letter keys. This will filter out
|
|
|
|
// any special keys (Backspace, Enter, etc)
|
2017-04-04 23:06:43 +00:00
|
|
|
if (e.metaKey || e.ctrlKey || e.altKey || e.key.length > 1) {
|
2017-03-12 00:31:23 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2017-07-25 18:00:30 +00:00
|
|
|
clearTimeout(keydownDebounce);
|
|
|
|
keydownDebounce = setTimeout(() => {
|
|
|
|
completeIfInVariableName(cm);
|
|
|
|
}, HINT_DELAY_MILLIS);
|
|
|
|
});
|
|
|
|
|
|
|
|
// Clear timeout if we already closed the completion
|
|
|
|
cm.on('endCompletion', () => {
|
|
|
|
clearTimeout(keydownDebounce);
|
2017-03-12 00:31:23 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
// Add hot key triggers
|
|
|
|
cm.addKeyMap({
|
|
|
|
'Ctrl-Space': completeForce, // Force autocomplete on hotkey
|
|
|
|
"' '": completeIfAfterTagOrVarOpen
|
|
|
|
});
|
2017-05-26 18:17:35 +00:00
|
|
|
|
|
|
|
// Close dropdown whenever something is clicked
|
|
|
|
document.addEventListener('click', () => cm.closeHint());
|
2017-03-12 00:31:23 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Function to retrieve the list items
|
|
|
|
* @param cm
|
|
|
|
* @param options
|
|
|
|
* @returns {Promise.<{list: Array, from, to}>}
|
|
|
|
*/
|
2017-03-30 18:52:07 +00:00
|
|
|
function hint (cm, options) {
|
|
|
|
const variablesToMatch = options.variables || [];
|
|
|
|
const constantsToMatch = options.constants || [];
|
|
|
|
const tagsToMatch = options.tags || [];
|
|
|
|
|
2017-03-12 00:31:23 +00:00
|
|
|
// Get the text from the cursor back
|
|
|
|
const cur = cm.getCursor();
|
|
|
|
const pos = CodeMirror.Pos(cur.line, cur.ch - MAX_HINT_LOOK_BACK);
|
|
|
|
const previousText = cm.getRange(pos, cur);
|
|
|
|
|
|
|
|
// See if we're allowed matching tags, vars, or both
|
|
|
|
const isInVariable = previousText.match(AFTER_VARIABLE_MATCH);
|
|
|
|
const isInTag = previousText.match(AFTER_TAG_MATCH);
|
|
|
|
const isInNothing = !isInVariable && !isInTag;
|
|
|
|
const allowMatchingVariables = isInNothing || isInVariable;
|
2017-03-30 18:52:07 +00:00
|
|
|
const allowMatchingTags = (isInNothing || isInTag);
|
2017-03-12 00:31:23 +00:00
|
|
|
const allowMatchingConstants = isInNothing;
|
|
|
|
|
|
|
|
// Define fallback segment to match everything or nothing
|
|
|
|
const fallbackSegment = options.showAllOnNoMatch ? '' : '__will_not_match_anything__';
|
|
|
|
|
|
|
|
// See if we're completing a variable name
|
|
|
|
const nameMatch = previousText.match(NAME_MATCH);
|
2017-03-12 22:44:46 +00:00
|
|
|
const nameMatchLong = previousText.match(NAME_MATCH_FLEXIBLE);
|
2017-03-12 00:31:23 +00:00
|
|
|
const nameSegment = nameMatch ? nameMatch[0] : fallbackSegment;
|
2017-03-12 22:44:46 +00:00
|
|
|
const nameSegmentLong = nameMatchLong ? nameMatchLong[0] : fallbackSegment;
|
2017-04-20 01:37:40 +00:00
|
|
|
const nameSegmentFull = previousText;
|
2017-03-12 00:31:23 +00:00
|
|
|
|
|
|
|
// Actually try to match the list of things
|
2017-03-12 23:53:49 +00:00
|
|
|
const allShortMatches = [];
|
|
|
|
const allLongMatches = [];
|
2017-03-12 00:31:23 +00:00
|
|
|
|
2017-03-12 23:53:49 +00:00
|
|
|
// Match variables
|
2017-03-12 00:31:23 +00:00
|
|
|
if (allowMatchingVariables) {
|
2017-03-30 18:52:07 +00:00
|
|
|
matchSegments(variablesToMatch, nameSegment, TYPE_VARIABLE, MAX_VARIABLES)
|
2017-03-12 23:53:49 +00:00
|
|
|
.map(m => allShortMatches.push(m));
|
2017-03-30 18:52:07 +00:00
|
|
|
matchSegments(variablesToMatch, nameSegmentLong, TYPE_VARIABLE, MAX_VARIABLES)
|
2017-03-12 23:53:49 +00:00
|
|
|
.map(m => allLongMatches.push(m));
|
2017-03-12 00:31:23 +00:00
|
|
|
}
|
|
|
|
|
2017-03-31 21:59:12 +00:00
|
|
|
// Match constants (only use long segment for a more strict match)
|
|
|
|
// TODO: Make this more flexible. This is really only here as a hack to make
|
|
|
|
// constants only match full string prefixes.
|
2017-03-23 22:10:42 +00:00
|
|
|
if (allowMatchingConstants) {
|
2017-04-20 01:37:40 +00:00
|
|
|
// Only match full segments with constants
|
|
|
|
matchSegments(constantsToMatch, nameSegmentFull, TYPE_CONSTANT, MAX_CONSTANTS)
|
2017-03-23 22:10:42 +00:00
|
|
|
.map(m => allLongMatches.push(m));
|
|
|
|
}
|
|
|
|
|
2017-03-12 23:53:49 +00:00
|
|
|
// Match tags
|
2017-03-12 00:31:23 +00:00
|
|
|
if (allowMatchingTags) {
|
2017-03-30 18:52:07 +00:00
|
|
|
matchSegments(tagsToMatch, nameSegment, TYPE_TAG, MAX_TAGS)
|
2017-03-12 23:53:49 +00:00
|
|
|
.map(m => allShortMatches.push(m));
|
2017-03-30 18:52:07 +00:00
|
|
|
matchSegments(tagsToMatch, nameSegmentLong, TYPE_TAG, MAX_TAGS)
|
2017-03-12 23:53:49 +00:00
|
|
|
.map(m => allLongMatches.push(m));
|
2017-03-12 00:31:23 +00:00
|
|
|
}
|
|
|
|
|
2017-03-12 23:53:49 +00:00
|
|
|
/*
|
|
|
|
* If anything matched the longer segment, only return those. Otherwise return only
|
|
|
|
* the short form. For example, if the long form is "application/json" and short is "json",
|
|
|
|
* prioritise matches form "application/json" if there were any.
|
|
|
|
*/
|
|
|
|
const matches = allLongMatches.length ? allLongMatches : allShortMatches;
|
|
|
|
const segment = allLongMatches.length ? nameSegmentLong : nameSegment;
|
|
|
|
|
2017-03-12 22:44:46 +00:00
|
|
|
return {
|
2017-03-12 23:53:49 +00:00
|
|
|
list: matches,
|
|
|
|
from: CodeMirror.Pos(cur.line, cur.ch - segment.length),
|
2017-03-12 22:44:46 +00:00
|
|
|
to: CodeMirror.Pos(cur.line, cur.ch)
|
|
|
|
};
|
2017-03-12 00:31:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Replace the text in the editor when a hint is selected.
|
|
|
|
* This also makes sure there is whitespace surrounding it
|
|
|
|
* @param cm
|
|
|
|
* @param self
|
|
|
|
* @param data
|
|
|
|
*/
|
|
|
|
function replaceHintMatch (cm, self, data) {
|
2017-03-12 22:44:46 +00:00
|
|
|
const cur = cm.getCursor();
|
|
|
|
const from = CodeMirror.Pos(cur.line, cur.ch - data.segment.length);
|
|
|
|
const to = CodeMirror.Pos(cur.line, cur.ch);
|
|
|
|
|
|
|
|
const prevStart = CodeMirror.Pos(from.line, from.ch - 10);
|
|
|
|
const prevChars = cm.getRange(prevStart, from);
|
|
|
|
|
|
|
|
const nextEnd = CodeMirror.Pos(to.line, to.ch + 10);
|
|
|
|
const nextChars = cm.getRange(to, nextEnd);
|
2017-03-12 00:31:23 +00:00
|
|
|
|
|
|
|
let prefix = '';
|
|
|
|
let suffix = '';
|
|
|
|
|
2017-03-23 22:10:42 +00:00
|
|
|
if (data.type === TYPE_VARIABLE && !prevChars.match(/{{[^}]*$/)) {
|
2017-03-12 00:31:23 +00:00
|
|
|
prefix = '{{ '; // If no closer before
|
|
|
|
} else if (data.type === TYPE_VARIABLE && prevChars.match(/{{$/)) {
|
|
|
|
prefix = ' '; // If no space after opener
|
|
|
|
} else if (data.type === TYPE_TAG && prevChars.match(/{%$/)) {
|
|
|
|
prefix = ' '; // If no space after opener
|
2017-03-23 22:10:42 +00:00
|
|
|
} else if (data.type === TYPE_TAG && !prevChars.match(/{%[^%]*$/)) {
|
|
|
|
prefix = '{% '; // If no closer before
|
2017-03-12 00:31:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if (data.type === TYPE_VARIABLE && !nextChars.match(/^\s*}}/)) {
|
|
|
|
suffix = ' }}'; // If no closer after
|
|
|
|
} else if (data.type === TYPE_VARIABLE && nextChars.match(/^}}/)) {
|
|
|
|
suffix = ' '; // If no space before closer
|
|
|
|
} else if (data.type === TYPE_TAG && nextChars.match(/^%}/)) {
|
|
|
|
suffix = ' '; // If no space before closer
|
|
|
|
} else if (data.type === TYPE_TAG && nextChars.match(/^\s*}/)) {
|
|
|
|
// Edge case because "%" doesn't auto-close tags so sometimes you end
|
|
|
|
// up in the scenario of {% foo}
|
|
|
|
suffix = ' %';
|
2017-03-23 22:10:42 +00:00
|
|
|
} else if (data.type === TYPE_TAG && !nextChars.match(/^\s*%}/)) {
|
|
|
|
suffix = ' %}'; // If no closer after
|
2017-03-12 00:31:23 +00:00
|
|
|
}
|
|
|
|
|
2017-03-12 22:44:46 +00:00
|
|
|
cm.replaceRange(`${prefix}${data.text}${suffix}`, from, to);
|
2017-03-12 00:31:23 +00:00
|
|
|
}
|
|
|
|
|
2017-03-12 05:38:44 +00:00
|
|
|
/**
|
|
|
|
* Match against a list of things
|
|
|
|
* @param listOfThings - Can be list of strings or list of {name, value}
|
2017-03-12 23:53:49 +00:00
|
|
|
* @param segment - segment to match against
|
2017-03-12 05:38:44 +00:00
|
|
|
* @param type
|
|
|
|
* @param limit
|
|
|
|
* @returns {Array}
|
|
|
|
*/
|
2017-03-12 23:53:49 +00:00
|
|
|
function matchSegments (listOfThings, segment, type, limit = -1) {
|
2017-03-30 18:52:07 +00:00
|
|
|
if (!Array.isArray(listOfThings)) {
|
|
|
|
console.warn('Autocomplete received items in non-list form', listOfThings);
|
|
|
|
return [];
|
|
|
|
}
|
2017-03-12 22:44:46 +00:00
|
|
|
|
2017-03-30 18:52:07 +00:00
|
|
|
const matches = [];
|
2017-03-12 22:44:46 +00:00
|
|
|
for (const t of listOfThings) {
|
|
|
|
const name = typeof t === 'string' ? t : t.name;
|
|
|
|
const value = typeof t === 'string' ? '' : t.value;
|
2017-05-23 22:05:31 +00:00
|
|
|
const displayName = t.displayName || name;
|
2017-06-15 20:04:50 +00:00
|
|
|
const defaultFill = typeof t === 'string' ? name : getDefaultFill(t.name, t.args);
|
2017-03-12 22:44:46 +00:00
|
|
|
|
2017-03-12 23:53:49 +00:00
|
|
|
const matchSegment = segment.toLowerCase();
|
2017-06-01 02:04:27 +00:00
|
|
|
const matchName = displayName.toLowerCase();
|
2017-03-12 22:44:46 +00:00
|
|
|
|
2017-03-12 23:53:49 +00:00
|
|
|
// Throw away things that don't match
|
|
|
|
if (!matchName.includes(matchSegment)) {
|
|
|
|
continue;
|
2017-03-12 22:44:46 +00:00
|
|
|
}
|
2017-03-12 23:53:49 +00:00
|
|
|
|
|
|
|
matches.push({
|
|
|
|
// Custom Insomnia keys
|
|
|
|
type,
|
|
|
|
segment,
|
|
|
|
comment: value,
|
|
|
|
displayValue: value ? JSON.stringify(value) : '',
|
|
|
|
score: name.length, // In case we want to sort by this
|
|
|
|
|
|
|
|
// CodeMirror
|
2017-05-23 22:05:31 +00:00
|
|
|
text: defaultFill,
|
|
|
|
displayText: displayName,
|
2017-03-12 23:53:49 +00:00
|
|
|
render: renderHintMatch,
|
|
|
|
hint: replaceHintMatch
|
|
|
|
});
|
2017-03-12 22:44:46 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if (limit >= 0) {
|
|
|
|
return matches.slice(0, limit);
|
|
|
|
} else {
|
|
|
|
return matches;
|
|
|
|
}
|
2017-03-12 00:31:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Replace all occurrences of string
|
|
|
|
* @param text
|
|
|
|
* @param find
|
|
|
|
* @param prefix
|
|
|
|
* @param suffix
|
|
|
|
* @returns string
|
|
|
|
*/
|
|
|
|
function replaceWithSurround (text, find, prefix, suffix) {
|
2017-08-04 16:54:11 +00:00
|
|
|
const escapedString = escapeRegex(find);
|
2017-03-12 00:31:23 +00:00
|
|
|
const re = new RegExp(escapedString, 'gi');
|
|
|
|
return text.replace(re, matched => prefix + matched + suffix);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Render the autocomplete list entry
|
|
|
|
* @param li
|
|
|
|
* @param self
|
|
|
|
* @param data
|
|
|
|
*/
|
|
|
|
function renderHintMatch (li, self, data) {
|
|
|
|
// Bold the matched text
|
|
|
|
const {displayText, segment} = data;
|
|
|
|
const markedName = replaceWithSurround(displayText, segment, '<strong>', '</strong>');
|
|
|
|
|
2017-03-29 23:09:28 +00:00
|
|
|
const {char, title} = ICONS[data.type];
|
|
|
|
|
2017-03-31 21:59:12 +00:00
|
|
|
let html = `
|
2017-03-29 23:09:28 +00:00
|
|
|
<label class="label" title="${title}">${char}</label>
|
2017-03-12 00:31:23 +00:00
|
|
|
<div class="name">${markedName}</div>
|
2017-04-04 23:06:43 +00:00
|
|
|
<div class="value" title=${data.displayValue}>
|
|
|
|
${data.displayValue || ''}
|
|
|
|
</div>
|
2017-03-12 00:31:23 +00:00
|
|
|
`;
|
|
|
|
|
2017-03-31 21:59:12 +00:00
|
|
|
li.innerHTML = html;
|
2017-07-25 04:15:24 +00:00
|
|
|
li.className += ` fancy-hint type--${data.type}`;
|
2017-03-12 00:31:23 +00:00
|
|
|
}
|