feat: support preview text response & download

This commit is contained in:
buqiyuan 2022-09-14 09:37:38 +08:00
parent 122fd25dc7
commit cc0fac7e6f
9 changed files with 149 additions and 145 deletions

View File

@ -20,7 +20,7 @@
"dependencies": {
"@angular-cli/base-href-webpack": "1.0.16",
"@angular/animations": "14.0.3",
"@angular/cdk": "13.3.9",
"@angular/cdk": "14.0.3",
"@angular/common": "14.0.3",
"@angular/compiler": "14.0.3",
"@angular/core": "14.0.3",
@ -37,6 +37,7 @@
"angular": "1.8.2",
"brace": "0.11.1",
"js-beautify": "1.14.4",
"lodash-es": "4.17.21",
"markdown-it": "13.0.1",
"monaco-editor": "0.33.0",
"ng-zorro-antd": "13.3.2",
@ -62,6 +63,7 @@
"@ngx-translate/http-loader": "7.0.0",
"@types/jasmine": "4.0.3",
"@types/jasminewd2": "2.0.10",
"@types/lodash-es": "4.17.6",
"@types/markdown-it": "12.2.3",
"@types/node": "18.0.0",
"@typescript-eslint/eslint-plugin": "5.29.0",

View File

@ -9,33 +9,15 @@
<nz-select class="!w-[106px] flex-none" [(ngModel)]="model.request.method" formControlName="method">
<nz-option *ngFor="let item of REQUEST_METHOD" [nzLabel]="item.key" [nzValue]="item.value"></nz-option>
</nz-select>
<div
*ngIf="env.hostUri"
nz-typography
nzEllipsis
class="env_front_uri"
nzTooltipTitle="{{ env.hostUri }}"
nzTooltipPlacement="bottom"
nz-tooltip
>
<div *ngIf="env.hostUri" nz-typography nzEllipsis class="env_front_uri" nzTooltipTitle="{{ env.hostUri }}"
nzTooltipPlacement="bottom" nz-tooltip>
{{ env.hostUri }}
</div>
<nz-form-item nz-col class="fg1">
<nz-form-control
[nzValidateStatus]="this.validateForm.controls.uri"
i18n-nzErrorTip
nzErrorTip="Please enter URL"
>
<input
type="text"
i18n-placeholder
placeholder="Enter URL"
name="uri"
nz-input
formControlName="uri"
[(ngModel)]="model.request.uri"
(change)="changeUri()"
/>
<nz-form-control [nzValidateStatus]="this.validateForm.controls.uri" i18n-nzErrorTip
nzErrorTip="Please enter URL">
<input type="text" i18n-placeholder placeholder="Enter URL" name="uri" nz-input formControlName="uri"
[(ngModel)]="model.request.uri" (change)="changeUri()" />
</nz-form-control>
</nz-form-item>
<button type="submit" nz-button nzType="primary" class="ml10 w_100" (click)="clickTest()">
@ -43,128 +25,88 @@
<span *ngIf="status === 'testing'" i18n>Abort</span>
<span *ngIf="status === 'testing' && waitSeconds" class="ml-1">{{ waitSeconds }}</span>
</button>
<button
type="button"
*ngIf="!route.snapshot.queryParams.uuid || route.snapshot.queryParams.uuid.includes('history_')"
nz-button
nzType="default"
(click)="saveApi()"
class="ml10"
i18n
>
<button type="button"
*ngIf="!route.snapshot.queryParams.uuid || route.snapshot.queryParams.uuid.includes('history_')" nz-button
nzType="default" (click)="saveApi()" class="ml10" i18n>
Save as API
</button>
</nz-input-group>
</form>
<!-- Request Info -->
<nz-tabset
[nzTabBarStyle]="{ 'padding-left': '10px' }"
[nzAnimated]="false"
[(nzSelectedIndex)]="model.requestTabIndex"
>
<nz-tabset [nzTabBarStyle]="{ 'padding-left': '10px' }" [nzAnimated]="false"
[(nzSelectedIndex)]="model.requestTabIndex">
<!-- Request Headers -->
<nz-tab [nzTitle]="headerTitleTmp" [nzForceRender]="true">
<ng-template #headerTitleTmp>
<span i18n="@@RequestHeaders">Headers</span>
<span class="eo-tab-icon ml-[4px]" *ngIf="bindGetApiParamNum(model.request.requestHeaders)">{{
model.request.requestHeaders | apiParamsNum
}}</span>
}}</span>
</ng-template>
<eo-api-test-header
class="eo_theme_iblock bbd"
[(model)]="model.request.requestHeaders"
(modelChange)="emitChangeFun('requestHeaders')"
></eo-api-test-header>
<eo-api-test-header class="eo_theme_iblock bbd" [(model)]="model.request.requestHeaders"
(modelChange)="emitChangeFun('requestHeaders')"></eo-api-test-header>
</nz-tab>
<!--Request Info -->
<nz-tab [nzTitle]="bodyTitleTmp" [nzForceRender]="true">
<ng-template #bodyTitleTmp>
<span i18n>Body</span>
<span
class="eo-tab-theme-icon"
*ngIf="
<span class="eo-tab-theme-icon" *ngIf="
['formData', 'json', 'xml'].includes(model.request.requestBodyType)
? bindGetApiParamNum(model.request.requestBody)
: model.request.requestBody?.length
"
></span>
"></span>
</ng-template>
<eo-api-test-body
class="eo_theme_iblock bbd"
[(contentType)]="model.contentType"
(contentTypeChange)="changeContentType($event)"
[(bodyType)]="model.request.requestBodyType"
(bodyTypeChange)="changeBodyType($event)"
[(model)]="model.request.requestBody"
(modelChange)="emitChangeFun('requestBody')"
[supportType]="['formData', 'raw', 'binary']"
></eo-api-test-body>
<eo-api-test-body class="eo_theme_iblock bbd" [(contentType)]="model.contentType"
(contentTypeChange)="changeContentType($event)" [(bodyType)]="model.request.requestBodyType"
(bodyTypeChange)="changeBodyType($event)" [(model)]="model.request.requestBody"
(modelChange)="emitChangeFun('requestBody')" [supportType]="['formData', 'raw', 'binary']">
</eo-api-test-body>
</nz-tab>
<nz-tab [nzTitle]="queryTitleTmp" [nzForceRender]="true">
<ng-template #queryTitleTmp>
<span i18n>Query</span>
<span class="eo-tab-icon ml-[4px]" *ngIf="bindGetApiParamNum(model.request.queryParams)">{{
model.request.queryParams | apiParamsNum
}}</span>
}}</span>
</ng-template>
<eo-api-test-query
class="eo_theme_iblock bbd"
[model]="model.request.queryParams"
(modelChange)="emitChangeFun('queryParams')"
></eo-api-test-query>
<eo-api-test-query class="eo_theme_iblock bbd" [model]="model.request.queryParams"
(modelChange)="emitChangeFun('queryParams')"></eo-api-test-query>
</nz-tab>
<nz-tab [nzTitle]="restTitleTmp" [nzForceRender]="true">
<ng-template #restTitleTmp>
<span i18n>REST</span>
<span class="eo-tab-icon ml-[4px]" *ngIf="bindGetApiParamNum(model.request.restParams)">{{
model.request.restParams | apiParamsNum
}}</span>
}}</span>
</ng-template>
<eo-api-test-rest
class="eo_theme_iblock bbd"
[(model)]="model.request.restParams"
(modelChange)="emitChangeFun('restParams')"
></eo-api-test-rest>
<eo-api-test-rest class="eo_theme_iblock bbd" [(model)]="model.request.restParams"
(modelChange)="emitChangeFun('restParams')"></eo-api-test-rest>
</nz-tab>
<nz-tab [nzTitle]="preScriptTitleTmp" [nzForceRender]="true">
<ng-template #preScriptTitleTmp>
<span i18n>Pre-request Script</span>
<span class="eo-tab-theme-icon" *ngIf="model.beforeScript?.trim()"></span>
</ng-template>
<eo-api-script
*ngIf="model.requestTabIndex === 4"
[(code)]="model.beforeScript"
(codeChange)="emitChangeFun('beforeScript')"
[treeData]="BEFORE_DATA"
[completions]="beforeScriptCompletions"
class="eo_theme_iblock bbd"
></eo-api-script>
<eo-api-script *ngIf="model.requestTabIndex === 4" [(code)]="model.beforeScript"
(codeChange)="emitChangeFun('beforeScript')" [treeData]="BEFORE_DATA"
[completions]="beforeScriptCompletions" class="eo_theme_iblock bbd"></eo-api-script>
</nz-tab>
<nz-tab [nzTitle]="suffixScriptTitleTmp" [nzForceRender]="true">
<ng-template #suffixScriptTitleTmp>
<span i18n>After-response Script</span>
<span class="eo-tab-theme-icon" *ngIf="model.afterScript?.trim()"></span>
</ng-template>
<eo-api-script
*ngIf="model.requestTabIndex === 5"
[(code)]="model.afterScript"
(codeChange)="emitChangeFun('afterScript')"
[treeData]="AFTER_DATA"
[completions]="afterScriptCompletions"
class="eo_theme_iblock bbd"
></eo-api-script>
<eo-api-script *ngIf="model.requestTabIndex === 5" [(code)]="model.afterScript"
(codeChange)="emitChangeFun('afterScript')" [treeData]="AFTER_DATA" [completions]="afterScriptCompletions"
class="eo_theme_iblock bbd"></eo-api-script>
</nz-tab>
</nz-tabset>
</div>
<div class="bottom_container scroll_container" bottom>
<!-- Response -->
<nz-tabset
[nzTabBarStyle]="{ 'padding-left': '10px' }"
[(nzSelectedIndex)]="model.responseTabIndex"
[nzAnimated]="false"
class="mt-2.5 response_container"
(nzSelectChange)="handleBottomTabSelect($event)"
>
<nz-tabset [nzTabBarStyle]="{ 'padding-left': '10px' }" [(nzSelectedIndex)]="model.responseTabIndex"
[nzTabBarExtraContent]="extraTemplate" [nzAnimated]="false" class="mt-2.5 response_container"
(nzSelectChange)="handleBottomTabSelect($event)">
<nz-tab i18n-nzTitle nzTitle="Response">
<eo-api-test-result-response [model]="model.testResult.response"></eo-api-test-result-response>
</nz-tab>
@ -175,16 +117,21 @@
</div>
<nz-tab i18n-nzTitle nzTitle="Body" [nzForceRender]="true">
<!-- TODO use isRequestBodyLoaded -->
<eo-api-test-result-request-body
*ngIf="model.responseTabIndex === 2"
[model]="model.testResult.request.requestBody || ''"
>
<eo-api-test-result-request-body *ngIf="model.responseTabIndex === 2"
[model]="model.testResult.request.requestBody || ''">
</eo-api-test-result-request-body>
</nz-tab>
<nz-tab i18n-nzTitle nzTitle="Request Headers">
<eo-api-test-result-header [model]="model.testResult.request.requestHeaders"> </eo-api-test-result-header>
</nz-tab>
</nz-tabset>
<ng-template #extraTemplate>
<div *ngIf="model.responseTabIndex === 0" class="px-[10px]">
<!-- <span nz-icon nzType="download" nzTheme="outline">下载</span> -->
<a nz-button nzType="link" [disabled]="isEmpty(model.testResult?.response)" (click)="downloadFile()"
i18n>Download</a>
</div>
</ng-template>
<div id="test-response"></div>
</div>
</eo-split-panel>

View File

@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, ChangeDetectorRef, Input, Output, EventEmitter } from '@angular/core';
import { Component, OnInit, OnDestroy, ChangeDetectorRef, Input, Output, EventEmitter, ViewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Select } from '@ngxs/store';
@ -33,6 +33,8 @@ import { LanguageService } from 'eo/workbench/browser/src/app/core/services/lang
import { ContentTypeByAbridge } from 'eo/workbench/browser/src/app/shared/services/api-test/api-test.model';
import { transferUrlAndQuery } from 'eo/workbench/browser/src/app/utils/api';
import { getGlobals, setGlobals } from 'eo/workbench/browser/src/app/shared/services/api-test/api-test.utils';
import { ApiTestResultResponseComponent } from 'eo/workbench/browser/src/app/pages/api/http/test/result-response/api-test-result-response.component';
import { isEmpty } from 'lodash-es';
const API_TEST_DRAG_TOP_HEIGHT_KEY = 'API_TEST_DRAG_TOP_HEIGHT';
interface testViewModel {
@ -63,6 +65,7 @@ export class ApiTestComponent implements OnInit, OnDestroy {
@Output() modelChange = new EventEmitter<testViewModel>();
@Output() afterTested = new EventEmitter<any>();
@Output() eoOnInit = new EventEmitter<testViewModel>();
@ViewChild(ApiTestResultResponseComponent) apiTestResultResponseComponent: ApiTestResultResponseComponent; // 通过组件类型获取
@Select(EnvState) env$: Observable<any>;
validateForm!: FormGroup;
env: any = {
@ -83,6 +86,7 @@ export class ApiTestComponent implements OnInit, OnDestroy {
REQUEST_METHOD = objectToArray(RequestMethod);
REQUEST_PROTOCOL = objectToArray(RequestProtocol);
MAX_TEST_SECONDS = 60;
isEmpty = isEmpty;
private initTimes = 0;
private status$: Subject<string> = new Subject<string>();
@ -343,11 +347,11 @@ export class ApiTestComponent implements OnInit, OnDestroy {
//* Other tab test finish,support multiple tab test same time
this.afterTested.emit({
id: queryParams.pageID,
url:'/home/api/http/test',
url: '/home/api/http/test',
model: {
testStartTime: 0,
testResult: tmpHistory,
}
},
});
} else {
this.model.testResult = tmpHistory;
@ -384,6 +388,9 @@ export class ApiTestComponent implements OnInit, OnDestroy {
},
});
}
downloadFile() {
this.apiTestResultResponseComponent.downloadResponseText();
}
/**
* Change test status
*

View File

@ -19,36 +19,38 @@
<nz-alert class="eo_alert_bar" *ngFor="let item of model.reportList"
[nzType]="item.type === 'interrupt' ? 'error' : 'info'" [nzMessage]="item.content.toString()" nzShowIcon></nz-alert>
<!-- Response -->
<div *ngIf="model.responseType" [ngSwitch]="model.responseType">
<div class="text-center" *ngSwitchCase="'stream'">
<div *ngIf="!responseIsImg">
<span i18n>Unable to preview non-text type data, you can</span>
<button class="eo_theme_btn_default mlr5" type="button" (click)="downloadResponseText()"
i18n="@@downloadResponse">
download response
</button>
<span i18n>and open it with other programs.</span>
</div>
<div class="mt20" *ngIf="responseIsImg" (contextmenu)="contextMenu($event, menu)">
<img class="maw_100percent" [src]="imgBlobUrl" />
</div>
<nz-dropdown-menu #menu="nzDropdownMenu">
<ul nz-menu>
<li nz-menu-item i18n="@@downloadResponse" (click)="downloadResponseText()">download response</li>
</ul>
</nz-dropdown-menu>
</div>
<div class="text-center" *ngSwitchCase="'longText'">
<span i18n>The response result exceeds the previewable size, you can</span>
<button i18n="@@downloadResponse" class="eo_theme_btn_default mlr5" type="button"
(click)="downloadResponseText()">
download response
</button>
<!-- or
<button class="eo_theme_btn_default" type="button" (click)="newTabResponseText()">在新标签页中显示返回结果</button>
and open it with other programs. -->
</div>
<eo-monaco-editor *ngSwitchDefault class="mt20" [autoFormat]="true" [(code)]="model.body"
[config]="{ readOnly: true }" [eventList]="['type', 'format', 'copy', 'search']"></eo-monaco-editor>
<div *ngIf="model.responseType">
<ng-container *ngIf="responseIsImg; else stream">
<img class="maw_100percent" [src]="imgBlobUrl" />
</ng-container>
<ng-template #stream>
<ng-container *ngIf="model.responseType === 'stream' && model.responseLength > 500000; else longText">
<div class="text-center">
<span i18n>Unable to preview non-text type data, you can</span>
<button class="eo_theme_btn_default mlr5" type="button" (click)="downloadResponseText()"
i18n="@@downloadResponse">
download response
</button>
<span i18n>and open it with other programs.</span>
</div>
</ng-container>
</ng-template>
<ng-template #longText>
<ng-container *ngIf="model.responseType === 'longText' && model.responseLength > 500000; else other">
<div class="text-center">
<span i18n>The response result exceeds the previewable size, you can</span>
<button i18n="@@downloadResponse" class="eo_theme_btn_default mlr5" type="button"
(click)="downloadResponseText()">
download response
</button>
<!-- or
<button class="eo_theme_btn_default" type="button" (click)="newTabResponseText()">在新标签页中显示返回结果</button>
and open it with other programs. -->
</div>
</ng-container>
</ng-template>
<ng-template #other>
<eo-monaco-editor class="mt20" [autoFormat]="true" [(code)]="model.body" [config]="{ readOnly: true }"
[eventList]="['type', 'format', 'copy', 'search']"></eo-monaco-editor>
</ng-template>
</div>
</div>

View File

@ -41,7 +41,11 @@ export class ApiTestResultResponseComponent implements OnInit, OnChanges {
}
}
}
ngOnInit(): void {}
ngOnInit(): void {
setTimeout(() => {
console.log('this.model', this.model);
}, 5000);
}
contextMenu($event: MouseEvent, menu: NzDropdownMenuComponent): void {
this.nzContextMenuService.create($event, menu);

View File

@ -10,7 +10,7 @@ import {
ElementRef,
} from '@angular/core';
import { EoMessageService } from 'eo/workbench/browser/src/app/eoui/message/eo-message.service';
import { debounce, whatTextType } from '../../../utils';
import { debounce, isBase64, whatTextType } from '../../../utils';
import { ElectronService } from 'eo/workbench/browser/src/app/core/services/electron/electron.service';
import { editor } from 'monaco-editor';
import * as monaco from 'monaco-editor';
@ -171,11 +171,14 @@ export class EoMonacoEditorComponent implements AfterViewInit, OnInit, OnChanges
if (val === this.$$code) {
return;
}
// console.log('val', val);
let code = '';
try {
code = JSON.stringify(typeof val === 'string' ? JSON.parse(val) : val, null, 4);
if (isBase64(val)) {
code = window.atob(val);
} else {
code = JSON.stringify(typeof val === 'string' ? JSON.parse(val) : val, null, 4);
}
} catch {
code = String(val);
}

View File

@ -27,6 +27,7 @@ import { NzResizableModule } from 'ng-zorro-antd/resizable';
import { NzDividerModule } from 'ng-zorro-antd/divider';
import { NzModalModule } from 'ng-zorro-antd/modal';
import { NzTypographyModule } from 'ng-zorro-antd/typography';
import { NzIconModule } from 'ng-zorro-antd/icon';
import { NzNotificationModule } from 'ng-zorro-antd/notification';
import { NzMessageModule } from 'ng-zorro-antd/message';
@ -93,6 +94,7 @@ const SHARED_MODULE = [
NzDividerModule,
NzModalModule,
NzTypographyModule,
NzIconModule,
] as const;
@NgModule({

View File

@ -166,7 +166,7 @@ export function debounce(fn, wait = 50) {
let timer = null;
// 将 debounce 处理结果当作函数返回
// 触发事件回调时执行这个返回函数
return function(...args) {
return function (...args) {
// this保存给context
const context = this;
// 如果已经设定过定时器就清空上一次的定时器
@ -183,7 +183,7 @@ export function debounce(fn, wait = 50) {
export function throttle(fn, gap) {
let timerId = null;
return function(...rest) {
return function (...rest) {
if (timerId === null) {
fn(...rest); // 立即执行
timerId = setTimeout(() => {
@ -232,5 +232,16 @@ export const eoDeepCopy = (obj) => {
return copy;
}
throw new Error('Unable to copy obj! Its type isn\'t supported.');
throw new Error("Unable to copy obj! Its type isn't supported.");
};
export function isBase64(str) {
if (str === '' || str.trim() === '') {
return false;
}
try {
return window.btoa(window.atob(str)) === str;
} catch (err) {
return false;
}
}

View File

@ -302,7 +302,16 @@
dependencies:
tslib "^2.3.0"
"@angular/cdk@13.3.9", "@angular/cdk@^13.0.1":
"@angular/cdk@14.0.3":
version "14.0.3"
resolved "https://registry.npmmirror.com/@angular/cdk/-/cdk-14.0.3.tgz#eaa0b0736481bc9c1d24ad88e19dd20874506032"
integrity sha512-XN5+WVUFx13lW2x9gnzJprHGqcvSpKQaoXxFvlcn16i0P6Iy1jldVZm6q6chEhgX9rEi7P31nfE88OJzHmkEyw==
dependencies:
tslib "^2.3.0"
optionalDependencies:
parse5 "^5.0.0"
"@angular/cdk@^13.0.1":
version "13.3.9"
resolved "https://registry.npmmirror.com/@angular/cdk/-/cdk-13.3.9.tgz#a177196e872e29be3f84d3a50f778d361c689ff7"
integrity sha512-XCuCbeuxWFyo3EYrgEYx7eHzwl76vaWcxtWXl00ka8d+WAOtMQ6Tf1D98ybYT5uwF9889fFpXAPw98mVnlo3MA==
@ -417,9 +426,9 @@
tslib "^2.3.0"
"@angular/upgrade@^14.0.3":
version "14.1.0"
resolved "https://registry.npmmirror.com/@angular/upgrade/-/upgrade-14.1.0.tgz#e2e4ba33b8dce009e96fc9ece5e4d36061c6ce73"
integrity sha512-EOFSpcdncQiQ7A5WktKe4r5uFkEDB325ddqZsuHesMbEQFeFu+93JwJWeRtU9gEyyuVCIDN6DyKaizUw/M4yGA==
version "14.2.1"
resolved "https://registry.npmmirror.com/@angular/upgrade/-/upgrade-14.2.1.tgz#7e154fb721a1764d0c226ac751fb04912725a78c"
integrity sha512-UxmqUBoaSFM28UBDA+heTQwNYrhq0mDdmcMt7Pg1gj0kPaQY3f6NgbrNH2UMnps+b4aZEbD/jnoJ3+I2F3VuhA==
dependencies:
tslib "^2.3.0"
@ -2300,6 +2309,18 @@
resolved "https://registry.npmmirror.com/@types/linkify-it/-/linkify-it-3.0.2.tgz#fd2cd2edbaa7eaac7e7f3c1748b52a19143846c9"
integrity sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==
"@types/lodash-es@4.17.6":
version "4.17.6"
resolved "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.6.tgz#c2ed4c8320ffa6f11b43eb89e9eaeec65966a0a0"
integrity sha512-R+zTeVUKDdfoRxpAryaQNRKk3105Rrgx2CFRClIgRGaqDTdjsm8h6IYA8ir584W3ePzkZfst5xIgDwYrlh9HLg==
dependencies:
"@types/lodash" "*"
"@types/lodash@*":
version "4.14.185"
resolved "https://registry.npmmirror.com/@types/lodash/-/lodash-4.14.185.tgz#c9843f5a40703a8f5edfd53358a58ae729816908"
integrity sha512-evMDG1bC4rgQg4ku9tKpuMh5iBNEwNa3tf9zRHdP1qlv+1WUg44xat4IxCE14gIpZRGUUWAx2VhItCZc25NfMA==
"@types/markdown-it@12.2.3":
version "12.2.3"
resolved "https://registry.npmmirror.com/@types/markdown-it/-/markdown-it-12.2.3.tgz#0d6f6e5e413f8daaa26522904597be3d6cd93b51"
@ -8542,6 +8563,11 @@ lockfile@^1.0.4:
dependencies:
signal-exit "^3.0.2"
lodash-es@4.17.21:
version "4.17.21"
resolved "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
lodash._baseuniq@~4.6.0:
version "4.6.0"
resolved "https://registry.npmmirror.com/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz#0ebb44e456814af7905c6212fa2c9b2d51b841e8"