Merge pull request #136 from h-sphere/feat/text-cell-parser

fix: plenty fixes
This commit is contained in:
Kacper Kula 2025-04-02 16:59:06 +01:00 committed by GitHub
commit 97ec36218f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 312 additions and 93 deletions

View file

@ -1,3 +1,11 @@
# 0.31.0 (2025-04-02)
- feat: added text rendering for links, images and checkboxes (for MARKDOWN renderer)
- fix: fixed issue with rendering numbers
- fix: fixed how grid renders columns - now they automatically match content and don't truncate the text
- fix: grid resizing now works better when switching tabs, resizing obsidian, etc.
- fix: columns with dots in the name render properly in grid view
- chore: now external plugins can use cellRenderer from the main plugin
# 0.31.0 (2025-03-31)
- feat: TEMPLATE renderer can now use checkboxes, links and images like other views.
# 0.30.1 (2025-03-27)

View file

@ -1,7 +1,7 @@
{
"id": "sqlseal",
"name": "SQLSeal",
"version": "0.31.0",
"version": "0.32.0",
"minAppVersion": "0.15.0",
"description": "Use SQL in your notes to query your vault files and CSV content.",
"author": "hypersphere",

View file

@ -1,6 +1,6 @@
{
"name": "sqlseal",
"version": "0.31.0",
"version": "0.32.0",
"description": "A plugin for Obsidian that allows you to run SQL queries on your notes.",
"main": "main.js",
"scripts": {

View file

@ -4,4 +4,5 @@ export interface CellFunction<T = unknown> {
get name(): string;
get sqlFunctionArgumentsCount(): number;
prepare(content: T): CellParserResult;
renderAsString(content: T): string;
}

View file

@ -1,7 +1,7 @@
import { CellFunction } from "./CellFunction";
import { parse } from 'json5'
import { isStringifiedArray, renderStringifiedArray } from '../utils/ui'
import { SqlSealDatabase } from "src/database/database";
import { SqlSealDatabase } from "../database/database";
export type UnregisterCallback = () => void;
@ -34,12 +34,40 @@ export class ModernCellParser {
// If it's an array, decode it and render each individual elements
if (isStringifiedArray(content)) {
try {
return renderStringifiedArray(content)
const parsed: string[] = parse(content)
const container = createSpan()
parsed.forEach((p, i) => {
const rendered = this.render(p)
if (rendered instanceof HTMLElement) {
container.appendChild(rendered)
if (i < parsed.length -1) {
container.appendChild(document.createTextNode(', '))
}
}
})
return container
} catch (e) {
return content
}
}
// If it is a wikilink or markdown link, use "a" renderer
if (content.startsWith('[[') && content.endsWith(']]')) {
const renderer = this.functions.get('a');
if (renderer) {
const [link, name] = content.slice(2, -2).split('|');
return renderer.prepare([link, name]);
}
} else if (content.startsWith('[') && content.includes('](') && content.endsWith(')')) {
const renderer = this.functions.get('a');
if (renderer) {
const closingBracketIndex = content.indexOf('](');
const name = content.slice(1, closingBracketIndex);
const href = content.slice(closingBracketIndex + 2, -1);
return renderer.prepare([href, name]);
}
}
return content
} catch (e) {
console.error('Error parsing cell with data:', content, e)
@ -64,6 +92,56 @@ export class ModernCellParser {
}
}
renderAsString(content: string): string {
try {
if (!content) {
return content
}
if (typeof content !== 'string') {
return content
}
if (content.startsWith('SQLSEALCUSTOM')) {
const parsedData = parse(content.slice('SQLSEALCUSTOM('.length, -1))
const renderer = this.getRenderer(parsedData)
return renderer!.renderAsString(parsedData.values)
}
// FIXME: bring this one back
// If it's an array, decode it and render each individual elements
if (isStringifiedArray(content)) {
try {
const parsed: string[] = parse(content)
return parsed.map(p => this.renderAsString(p)).join(', ')
} catch (e) {
return content.toString()
}
}
// If it is a wikilink or markdown link, use "a" renderer
if (content.startsWith('[[') && content.endsWith(']]')) {
const renderer = this.functions.get('a');
if (renderer) {
const [link, name] = content.slice(2, -2).split('|');
return renderer.renderAsString([link, name]);
}
} else if (content.startsWith('[') && content.includes('](') && content.endsWith(')')) {
const renderer = this.functions.get('a');
if (renderer) {
const closingBracketIndex = content.indexOf('](');
const name = content.slice(1, closingBracketIndex);
const href = content.slice(closingBracketIndex + 2, -1);
return renderer.renderAsString([href, name]);
}
}
return content
} catch (e) {
console.error('Error parsing cell with data:', content, e)
return 'Parsing Error'
}
}
register(fn: CellFunction) {
this.functions.set(fn.name, fn)
}
@ -77,8 +155,13 @@ export class ModernCellParser {
}
private renderCustomElement(el: any) {
const renderer = this.getRenderer(el)
return renderer?.prepare(el.values)
}
private getRenderer(el: any) {
if (this.functions.has(el.type)) {
return this.functions.get(el.type)!.prepare(el.values)
return this.functions.get(el.type)!
} else {
throw new Error(`Custom function processor ${el.type} is not registered`)
}

View file

@ -57,6 +57,16 @@ export class ParseResults {
})
}
renderAsString(data: Record<string, any>[], columns: string[]) {
return data.map(d => {
const res: Record<string, string> = {}
for (const col of columns) {
res[col] = this.cellParser.renderAsString(d[col])
}
return res
})
}
initialise(parentEl: HTMLElement) {
this.functions.forEach(fn => fn(parentEl))
}

View file

@ -90,4 +90,19 @@ export class CheckboxParser implements CellFunction<Args> {
}
}
}
renderAsString(values: Args): string {
if (!isCheckboxProp(values)) {
if (!!values[0]) {
return '[x]'
}
return '[ ]'
} else {
if (values.checked) {
return '[x]'
} else {
return '[ ] '
}
}
}
}

View file

@ -1,7 +1,7 @@
import { isLinkLocal } from "../../utils/ui/helperFunctions";
import { CellFunction } from "../CellFunction";
import { CellParserResult } from "../ModernCellParser";
import { App } from "obsidian";
import { isLinkLocal } from "src/utils/ui/helperFunctions";
type Args = [string] | [string, string]
@ -17,10 +17,7 @@ export class ImageParser implements CellFunction<Args> {
return 2 // FIXME: should it be 1 or 2?
}
prepare([href, path]: Args): CellParserResult {
if (!isLinkLocal(href)) {
return createEl('img', { attr: { src: href } });
}
getResourcePath(href: string, path?: string) {
href = (href ?? '').trim()
if (href.startsWith('![[')) {
href = href.slice(3, -2)
@ -35,8 +32,35 @@ export class ImageParser implements CellFunction<Args> {
path = (path ? parentPath + '/' : '') + href
const file = this.app.vault.getFileByPath(path);
if (!file) {
return 'File does not exist'
throw new Error('File does not exist')
}
return this.create('img', { attr: { src: this.app.vault.getResourcePath(file) } });
return this.app.vault.getResourcePath(file)
}
prepare([href, path]: Args): CellParserResult {
if (!isLinkLocal(href)) {
return createEl('img', { attr: { src: href } });
}
try {
let resourcePath = this.getResourcePath(href, path)
return this.create('img', { attr: { src: resourcePath } });
} catch (e) {
return e
}
}
renderAsString([href, path]: Args): string {
if (!isLinkLocal(href)) {
return `![](${href})`
}
try {
const resourcePath = this.getResourcePath(href, path)
return `![[${resourcePath}]]`
} catch (e) {
return e.toString()
}
}
}

View file

@ -1,8 +1,7 @@
import { isStringifiedArray, renderStringifiedArray } from "../../utils/ui";
import { isLinkLocal, isLinktext, removeExtension } from "../../utils/ui/helperFunctions";
import { CellFunction } from "../CellFunction";
import { CellParserResult, Result } from "../ModernCellParser";
import { App } from "obsidian";
import { isLinkLocal, isLinktext, removeExtension } from "src/utils/ui/helperFunctions";
type Args = [string] | [string, string]
@ -18,6 +17,44 @@ export class LinkParser implements CellFunction<Args> {
return 2 // FIXME: should it be 1 or 2?
}
parseLink(href: string, name?: string) {
if (!name) {
name = href
}
href = href.trim()
let cls = ''
if (isLinktext(href)) {
const [path, linkName] = href.slice(2, -2).split('|')
const link = this.app.metadataCache.getFirstLinkpathDest(path, '')
if (name.trim() === href) {
name = linkName ?? path
}
if (!link) {
href = path
} else {
href = link.path
}
cls = 'internal-link'
} else if (isLinkLocal(href)) {
let fileHref = href
if (fileHref.endsWith('.md')) {
fileHref = fileHref.slice(0, -3)
}
if (name === href) {
const components = href.split('/')
name = removeExtension(components[components.length - 1])
}
cls = 'internal-link'
}
return {
name, href, cls
}
}
prepare([href, name]: Args) {
if (!href) {
return ''
@ -25,44 +62,29 @@ export class LinkParser implements CellFunction<Args> {
if (isStringifiedArray(href)) {
try {
// FIXME: this return should work differently
renderStringifiedArray(href, href => this.prepare([href]))
} catch (e) {
return href
}
}
const res = this.parseLink(href, name)
const el = this.create('a', { text: res.name, href: res.href, cls: res.cls })
return el
}
if (!name) {
name = href
renderAsString([href, name]: Args): string {
if (!href) {
return ''
}
href = href.trim()
let cls = ''
if (isLinktext(href)) {
const [path, linkName] = href.slice(2, -2).split('|')
const link = this.app.metadataCache.getFirstLinkpathDest(path, '')
if (name.trim() === href) {
name = linkName ?? path
}
if (!link) {
href = path
} else {
href = link.path
}
cls = 'internal-link'
} else if (isLinkLocal(href)) {
let fileHref = href
if (fileHref.endsWith('.md')) {
fileHref = fileHref.slice(0, -3)
}
if (name === href) {
const components = href.split('/')
name = removeExtension(components[components.length - 1])
}
cls = 'internal-link'
const res = this.parseLink(href, name)
if (res.cls == 'internal-link') {
if (!name) {
return `[[${res.href}]]`
}
const el = this.create('a', { text: name, href, cls })
return el
return `[[${res.href}|${res.name}]]`
} else {
return `[${res.name}](${res.href})`
}
}
}

View file

@ -69,7 +69,12 @@ export class CodeblockProcessor extends MarkdownRenderChild {
rendererEl = this.el.createDiv({ cls: 'sqlseal-renderer-container' })
}
this.renderer = this.rendererRegistry.prepareRender(results.renderer.type.toLowerCase(), results.renderer.options)(rendererEl)
this.renderer = this.rendererRegistry
.prepareRender(
results.renderer.type.toLowerCase(), results.renderer.options
)(rendererEl, {
cellParser: this.plugin.cellParser
})
// FIXME: probably should save the one before transform and perform transform every time we execute it.
this.query = results.query
@ -81,6 +86,9 @@ export class CodeblockProcessor extends MarkdownRenderChild {
onunload() {
this.registrator.offAll()
if (this.renderer?.cleanup) {
this.renderer.cleanup()
}
}
async render() {

View file

@ -56,7 +56,7 @@ export class CsvFileSyncStrategy extends ISyncStrategy {
// TODO: PROBABLY SHOULD BE EXTRACTED SOMEWHERE FROM HERE later.
const parsed = parse<Record<string, string>>(data, {
header: true,
dynamicTyping: true,
dynamicTyping: false,
skipEmptyLines: true,
transformHeader: sanitise
})

View file

@ -58,11 +58,11 @@ export default class SqlSealPlugin extends Plugin {
// REGISTERING VIEWS
this.rendererRegistry.register('sql-seal-internal-table', new TableRenderer(this.app, this.cellParser))
this.rendererRegistry.register('sql-seal-internal-grid', new GridRenderer(this.app, this, this.cellParser))
this.rendererRegistry.register('sql-seal-internal-table', new TableRenderer(this.app))
this.rendererRegistry.register('sql-seal-internal-grid', new GridRenderer(this.app, this))
this.rendererRegistry.register('sql-seal-internal-markdown', new MarkdownRenderer(this.app))
this.rendererRegistry.register('sql-seal-internal-list', new ListRenderer(this.app, this.cellParser))
this.rendererRegistry.register('sql-seal-internal-template', new TemplateRenderer(this.app, this.cellParser))
this.rendererRegistry.register('sql-seal-internal-list', new ListRenderer(this.app))
this.rendererRegistry.register('sql-seal-internal-template', new TemplateRenderer(this.app))
// start syncing when files are loaded
this.app.workspace.onLayoutReady(async () => {

View file

@ -4,7 +4,7 @@ import { version } from '../../package.json'
import { RendererConfig } from "../renderer/rendererRegistry";
import { CellFunction } from "../cellParser/CellFunction";
const API_VERSION = 3;
const API_VERSION = 4;
export class SQLSealRegisterApi {
registeredApis: Array<SQLSealApi> = []

View file

@ -1,11 +1,12 @@
import { createGrid, GridApi, GridOptions, themeQuartz } from "ag-grid-community";
import { merge } from "lodash";
import { App } from "obsidian";
import { RendererConfig } from "./rendererRegistry";
import { RendererConfig, RendererContext } from "./rendererRegistry";
import { parse } from 'json5'
import { ViewDefinition } from "../grammar/parser";
import SqlSealPlugin from "../main";
import { ModernCellParser } from "src/cellParser/ModernCellParser";
import { ModernCellParser } from "../cellParser/ModernCellParser";
import { EventRef } from "obsidian";
const getCurrentTheme = () => {
return document.body.classList.contains('theme-dark') ? 'dark' : 'light';
@ -34,11 +35,44 @@ class GridRendererCommunicator {
private cellParser: ModernCellParser
) {
this.initialize()
this.setupLayoutObservers()
}
private gridApi: GridApi<any>
private _gridApi: GridApi<any>
private errorEl: HTMLElement
private errorOverlay: HTMLElement
private resizeObserver: ResizeObserver
private unregisterLeafChange: EventRef | null = null
get gridApi(): GridApi<any> {
return this._gridApi
}
private setupLayoutObservers() {
// Observe resize changes
this.resizeObserver = new ResizeObserver(() => {
if (this._gridApi) {
this._gridApi.autoSizeAllColumns()
}
})
this.resizeObserver.observe(this.el)
// Observe tab switches
this.unregisterLeafChange = this.app.workspace.on('active-leaf-change', () => {
if (this._gridApi) {
this._gridApi.autoSizeAllColumns()
}
})
}
cleanup() {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
if (this.unregisterLeafChange) {
this.app.workspace.offref(this.unregisterLeafChange)
}
}
private showError(message: string) {
this.gridApi.setGridOption('loading', false)
@ -76,8 +110,9 @@ class GridRendererCommunicator {
autoHeight: true
},
autoSizeStrategy: {
// make sure to fit content
type: 'fitGridWidth',
defaultMinWidth: 150,
// defaultMinWidth: 150,
},
pagination: true,
suppressMovableColumns: true,
@ -89,7 +124,7 @@ class GridRendererCommunicator {
paginationPageSize: this.plugin? this.plugin.settings.gridItemsPerPage : undefined,
// ensureDomOrder: true
}, this.config)
this.gridApi = createGrid(
this._gridApi = createGrid(
grid,
gridOptions,
);
@ -99,7 +134,10 @@ class GridRendererCommunicator {
if (!this.gridApi) {
throw new Error('Grid has not been initiated')
}
this.gridApi.setGridOption('columnDefs', columns.map((c: any) => ({ field: c })))
this.gridApi.setGridOption('columnDefs', columns.map((c: any) => ({
headerName: c,
valueGetter: (params) => params.data[c]
})))
this.gridApi.setGridOption('rowData', data)
this.gridApi.setGridOption('loading', false)
}
@ -118,7 +156,7 @@ class GridRendererCommunicator {
}
export class GridRenderer implements RendererConfig {
constructor(private app: App, private readonly plugin: SqlSealPlugin | null, private readonly cellParser: ModernCellParser) { }
constructor(private app: App, private readonly plugin: SqlSealPlugin | null) { }
get viewDefinition(): ViewDefinition {
return {
name: this.rendererKey,
@ -140,15 +178,19 @@ export class GridRenderer implements RendererConfig {
}
render(config: Partial<GridOptions>, el: HTMLElement) {
const communicator = new GridRendererCommunicator(el, config, this.plugin, this.app, this.cellParser)
render(config: Partial<GridOptions>, el: HTMLElement, { cellParser }: RendererContext) {
const communicator = new GridRendererCommunicator(el, config, this.plugin, this.app, cellParser)
return {
render: (data: any) => {
// FIXME: we need to update that.
communicator.setData(data.columns, data.data)
communicator.gridApi.autoSizeAllColumns()
},
error: (message: string) => {
communicator.showInfo('error', message)
},
cleanup: () => {
communicator.cleanup()
communicator.gridApi.destroy()
}
}
}

View file

@ -1,9 +1,8 @@
// This is renderer for a very basic List view.
import { App } from "obsidian";
import { RendererConfig } from "../renderer/rendererRegistry";
import { RendererConfig, RendererContext } from "../renderer/rendererRegistry";
import { displayError } from "../utils/ui";
import { ViewDefinition } from "../grammar/parser";
import { ModernCellParser } from "src/cellParser/ModernCellParser";
interface ListRendererConfig {
classNames: string[]
@ -11,7 +10,7 @@ interface ListRendererConfig {
export class ListRenderer implements RendererConfig {
constructor(private readonly app: App, private readonly cellParser: ModernCellParser) { }
constructor(private readonly app: App) { }
get rendererKey() {
return 'list'
@ -37,7 +36,7 @@ export class ListRenderer implements RendererConfig {
}
}
render(config: ListRendererConfig, el: HTMLElement) {
render(config: ListRendererConfig, el: HTMLElement, { cellParser }: RendererContext) {
return {
render: ({ columns, data }: any) => {
el.empty()
@ -58,7 +57,7 @@ export class ListRenderer implements RendererConfig {
text: createEl('span', { text: c, cls: 'sqlseal-column-name' }) as any, // FIXME: this should be properly typed
cls: singleCol ? ['sqlseal-list-element', 'sqlseal-list-element-single'] : ['sqlseal-list-element-single']
})
const val = this.cellParser.render(d[c])
const val = cellParser.render(d[c])
el.append(val)
el.dataset.sqlsealColumn = c
})

View file

@ -1,17 +1,19 @@
// This is renderer for a very basic Table view.
import { getMarkdownTable } from "markdown-table-ts";
import { App } from "obsidian";
import { RendererConfig } from "../renderer/rendererRegistry";
import { RendererConfig, RendererContext } from "../renderer/rendererRegistry";
import { displayError } from "../utils/ui";
import { ViewDefinition } from "../grammar/parser";
import { ParseResults } from "../cellParser/parseResults";
import { ModernCellParser } from "../cellParser/ModernCellParser";
const mapDataFromHeaders = (columns: string[], data: Record<string, any>[]) => {
return data.map(d => columns.map(c => String(d[c])))
}
export class MarkdownRenderer implements RendererConfig {
constructor(private readonly app: App) { }
constructor(private readonly app: App) {
}
get rendererKey() {
return 'markdown'
@ -28,13 +30,14 @@ export class MarkdownRenderer implements RendererConfig {
}
}
render(config: ReturnType<typeof this.validateConfig>, el: HTMLElement) {
render(config: ReturnType<typeof this.validateConfig>, el: HTMLElement, { cellParser } : RendererContext) {
const parseResult = new ParseResults(cellParser)
return {
render: ({ columns, data }: any) => {
const tab = getMarkdownTable({
table: {
head: columns,
body: mapDataFromHeaders(columns, data)
body: mapDataFromHeaders(columns, parseResult.renderAsString(data, columns))
}
})

View file

@ -1,6 +1,6 @@
// This is renderer for a very basic Table view.
import { App } from "obsidian";
import { RendererConfig } from "../renderer/rendererRegistry";
import { RendererConfig, RendererContext } from "../renderer/rendererRegistry";
import { displayError } from "../utils/ui";
import { ViewDefinition } from "../grammar/parser";
import { ModernCellParser } from "../cellParser/ModernCellParser";
@ -11,7 +11,7 @@ interface HTMLRendererConfig {
export class TableRenderer implements RendererConfig {
constructor(private readonly app: App, private cellParser: ModernCellParser) { }
constructor(private readonly app: App) { }
get rendererKey() {
return 'html'
@ -36,7 +36,7 @@ export class TableRenderer implements RendererConfig {
}
}
render(config: HTMLRendererConfig, el: HTMLElement) {
render(config: HTMLRendererConfig, el: HTMLElement, { cellParser }: RendererContext) {
return {
render: ({ columns, data }: any) => {
el.empty()
@ -60,7 +60,7 @@ export class TableRenderer implements RendererConfig {
data.forEach((d: any) => {
const row = body.createEl("tr")
columns.forEach((c: any) => {
row.createEl("td", { text: this.cellParser.render(d[c]) as string })
row.createEl("td", { text: cellParser.render(d[c]) as string })
})
})

View file

@ -1,22 +1,17 @@
// This is renderer for a very basic List view.
import { App } from "obsidian";
import { RendererConfig } from "./rendererRegistry";
import { RendererConfig, RendererContext } from "./rendererRegistry";
import { displayError } from "../utils/ui";
import { ViewDefinition } from "../grammar/parser";
import Handlebars from "handlebars";
import { ParseResults } from "src/cellParser/parseResults";
import { ModernCellParser } from "src/cellParser/ModernCellParser";
import { ParseResults } from "../cellParser/parseResults";
interface TemplateRendererConfig {
template: HandlebarsTemplateDelegate
}
export class TemplateRenderer implements RendererConfig {
parseResult: ParseResults;
constructor(private readonly app: App, private readonly cellParser: ModernCellParser) {
this.parseResult = new ParseResults(cellParser, (el) => new Handlebars.SafeString(el.outerHTML))
constructor(private readonly app: App) {
}
get rendererKey() {
@ -42,18 +37,20 @@ export class TemplateRenderer implements RendererConfig {
}
}
render(config: TemplateRendererConfig, el: HTMLElement) {
render(config: TemplateRendererConfig, el: HTMLElement, { cellParser }: RendererContext) {
return {
render: ({ columns, data, frontmatter }: any) => {
el.empty()
const parser = new ParseResults(cellParser, (el) => new Handlebars.SafeString(el.outerHTML))
// Seems to be the only way to render handlebars into DOM. Don't like it but what can we do.
el.innerHTML = config.template({
data: this.parseResult.parse(data, columns),
data: parser.parse(data, columns),
columns,
properties: frontmatter
})
this.parseResult.initialise(el)
parser.initialise(el)
},
error: (error: string) => {
displayError(el, error)

View file

@ -1,3 +1,4 @@
import { ModernCellParser } from "../cellParser/ModernCellParser";
import { ViewDefinition } from "../grammar/parser";
export interface DataFormat {
@ -5,14 +6,19 @@ export interface DataFormat {
columns: string[]
}
export interface RendererContext {
cellParser: ModernCellParser
}
export interface RenderReturn {
render: (data: any) => void;
error: (errorMessage: string) => void;
cleanup?: () => void;
}
export interface RendererConfig<T extends Record<string, any> = Record<string, any>> {
rendererKey: string;
validateConfig: (config: string) => T,
render: (config: T, el: HTMLElement) => RenderReturn,
render: (config: T, el: HTMLElement, context: RendererContext) => RenderReturn,
viewDefinition: ViewDefinition
}
@ -66,8 +72,8 @@ export class RendererRegistry {
}
const rendererConfig = this.renderersByKey.get(type.toLowerCase())!
const elConfig = rendererConfig.validateConfig(config)
return (el: HTMLElement) => {
return rendererConfig.render(elConfig, el)
return (el: HTMLElement, context: RendererContext) => {
return rendererConfig.render(elConfig, el, context)
}
}

View file

@ -201,7 +201,7 @@ export class CSVView extends TextFileView {
})
const grid = new GridRenderer(this.app, null, this.cellParser)
const grid = new GridRenderer(this.app, null)
const csvView = this;
const api = grid.render({
defaultColDef: {
@ -276,7 +276,7 @@ export class CSVView extends TextFileView {
})
menu.showAtMouseEvent(e.event as any)
}
}, gridEl)
}, gridEl, { cellParser: this.cellParser })
this.api = api;
this.loadDataIntoGrid()

View file

@ -57,5 +57,6 @@
"0.29.0": "0.15.0",
"0.30.0": "0.15.0",
"0.30.1": "0.15.0",
"0.31.0": "0.15.0"
"0.31.0": "0.15.0",
"0.32.0": "0.15.0"
}