Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion backend/workflow/opencli_adapter_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,6 @@ def _load_opencli_catalog() -> tuple[dict[str, Any], ...]:
check=False,
text=True,
encoding="utf-8",
errors="replace",
timeout=_OPENCLI_LIST_TIMEOUT_SECONDS,
)
except Exception as exc:
Expand Down
218 changes: 183 additions & 35 deletions frontend/components/flow/command-palette.tsx

Large diffs are not rendered by default.

192 changes: 168 additions & 24 deletions frontend/components/flow/inspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
GitBranch,
Plus,
PlugZap,
RotateCcw,
Search,
Trash2,
Unplug,
Expand Down Expand Up @@ -68,6 +69,10 @@ import {
isOpenCLISourceSlotArray,
type OpenCLISourceSlot,
} from "@/lib/workflow/node-catalog"
import {
ASHARE_OPENCLI_SOURCES,
OPENCLI_SITUATION_SOURCES,
} from "@/lib/workflow/opencli-business-workflows"
import {
openCLISlotFromDataSource,
SOURCE_ARGUMENT_LABELS,
Expand Down Expand Up @@ -106,6 +111,28 @@ const edgeTypeHints: Record<string, string> = {
routed: "自动绕开中间节点的正交折线,适合密集流程图。",
}

const BUILT_IN_SOURCE_IDS = new Set([
...ASHARE_OPENCLI_SOURCES,
...OPENCLI_SITUATION_SOURCES,
].map((source) => source.id))

const SOURCE_ID_ACRONYMS: Record<string, string> = {
bse: "BSE",
cninfo: "CNInfo",
pdf: "PDF",
sse: "SSE",
szse: "SZSE",
ths: "THS",
}

function sourceCardLabel(source: OpenCLISourceSlot, language: WorkflowLanguage): string {
if (language === "zh-CN" || !BUILT_IN_SOURCE_IDS.has(source.id)) return source.label
return source.id
.split("-")
.map((part) => SOURCE_ID_ACRONYMS[part] ?? `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join(" ")
Comment on lines +128 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve customized built-in source names in English.

The advanced editor permits changing source.label, but this function always replaces built-in labels with an ID-derived value in English. Only derive an English default when the label still matches the preset default.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/components/flow/inspector.tsx` around lines 128 - 133, Update
sourceCardLabel so English output preserves customized source.label values for
built-in sources; derive the ID-based label only when source.label still matches
that source’s preset default. Keep the existing Chinese behavior and
non-built-in label handling unchanged, using the established built-in source
defaults for comparison.

}

const INSPECTOR_COPY = {
"zh-CN": {
switchLanguage: "切换节点语言",
Expand Down Expand Up @@ -173,6 +200,20 @@ const INSPECTOR_COPY = {
collectionTopicPlaceholder: "例如:人工智能、贵州茅台",
collectionTopicHint: "一次设置会同步到所有搜索型来源。",
market: "市场范围",
contentType: "采集内容",
addContent: "添加一类来源",
contentTypes: {
market: "行情",
filings: "公告财报",
macro: "宏观",
news: "新闻",
social: "社区舆情",
video: "视频",
},
configureSource: "设置",
positionalArgument: "搜索词 / 资源 ID",
removedSource: "已移除",
undoRemove: "撤销",
collectionOptions: "采集选项",
items: "项",
opencliMapping: "OpenCLI 映射",
Expand Down Expand Up @@ -251,6 +292,20 @@ const INSPECTOR_COPY = {
collectionTopicPlaceholder: "Example: artificial intelligence, Apple",
collectionTopicHint: "One value is synchronized to every search-based source.",
market: "Market scope",
contentType: "Content",
addContent: "Add a source group",
contentTypes: {
market: "Market",
filings: "Filings",
macro: "Macro",
news: "News",
social: "Social",
video: "Video",
},
configureSource: "Configure",
positionalArgument: "Search term / resource ID",
removedSource: "Removed",
undoRemove: "Undo",
collectionOptions: "Collection options",
items: "items",
opencliMapping: "OpenCLI mapping",
Expand Down Expand Up @@ -1527,6 +1582,7 @@ export function Inspector({ compact = false, onClose }: { compact?: boolean; onC

{openCLISources ? (
<OpenCLISourceEditor
key={configurationNodeId}
sources={openCLISources}
language={language}
onChange={(sources) => updateWorkflowNodeParams(configurationNodeId, { sources })}
Expand Down Expand Up @@ -1886,6 +1942,7 @@ function OpenCLISourceEditor({
const availableSources = registeredSources.filter((source) => !selectedSourceKeys.has(sourceSlotKey(source)))
const businessQuery = sourceBusinessQuery(sources)
const market = sourceMarket(sources)
const [removedSource, setRemovedSource] = useState<{ source: OpenCLISourceSlot; index: number } | null>(null)

const updateSource = (index: number, patch: Partial<OpenCLISourceSlot>) => {
onChange(sources.map((source, sourceIndex) => (
Expand All @@ -1899,6 +1956,34 @@ function OpenCLISourceEditor({
onChange([...sources, source])
}

const addContentSources = (contentType: string | null) => {
if (!contentType) return
const presets = contentType === "video"
? OPENCLI_SITUATION_SOURCES.filter((source) => source.sourceGroup?.startsWith("video-"))
: ASHARE_OPENCLI_SOURCES.filter((source) => source.sourceGroup === contentType)
const selectedKeys = new Set(sources.map(sourceSlotKey))
const additions = presets.filter((source) => !selectedKeys.has(sourceSlotKey(source)))
if (additions.length > 0) onChange([...sources, ...additions])
}

const removeSource = (index: number) => {
setRemovedSource({ source: sources[index], index })
onChange(sources.filter((_, sourceIndex) => sourceIndex !== index))
}

const restoreSource = () => {
if (!removedSource) return
const removedKey = sourceSlotKey(removedSource.source)
if (sources.some((source) => sourceSlotKey(source) === removedKey)) {
setRemovedSource(null)
return
}
const restored = [...sources]
restored.splice(Math.min(removedSource.index, restored.length), 0, removedSource.source)
onChange(restored)
setRemovedSource(null)
}
Comment on lines +1959 to +1985

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Deduplicate preset additions and undo by immutable source ID.

sourceSlotKey() includes mutable args. After changing a preset’s market, re-adding its group treats the original preset as new; undo can likewise restore a second slot with the same id. This duplicates collection and produces duplicate React card keys. Compare source.id for these preset/undo operations.

Proposed fix
-    const selectedKeys = new Set(sources.map(sourceSlotKey))
-    const additions = presets.filter((source) => !selectedKeys.has(sourceSlotKey(source)))
+    const selectedIds = new Set(sources.map((source) => source.id))
+    const additions = presets.filter((source) => !selectedIds.has(source.id))
@@
-    const removedKey = sourceSlotKey(removedSource.source)
-    if (sources.some((source) => sourceSlotKey(source) === removedKey)) {
+    if (sources.some((source) => source.id === removedSource.source.id)) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const addContentSources = (contentType: string | null) => {
if (!contentType) return
const presets = contentType === "video"
? OPENCLI_SITUATION_SOURCES.filter((source) => source.sourceGroup?.startsWith("video-"))
: ASHARE_OPENCLI_SOURCES.filter((source) => source.sourceGroup === contentType)
const selectedKeys = new Set(sources.map(sourceSlotKey))
const additions = presets.filter((source) => !selectedKeys.has(sourceSlotKey(source)))
if (additions.length > 0) onChange([...sources, ...additions])
}
const removeSource = (index: number) => {
setRemovedSource({ source: sources[index], index })
onChange(sources.filter((_, sourceIndex) => sourceIndex !== index))
}
const restoreSource = () => {
if (!removedSource) return
const removedKey = sourceSlotKey(removedSource.source)
if (sources.some((source) => sourceSlotKey(source) === removedKey)) {
setRemovedSource(null)
return
}
const restored = [...sources]
restored.splice(Math.min(removedSource.index, restored.length), 0, removedSource.source)
onChange(restored)
setRemovedSource(null)
}
const addContentSources = (contentType: string | null) => {
if (!contentType) return
const presets = contentType === "video"
? OPENCLI_SITUATION_SOURCES.filter((source) => source.sourceGroup?.startsWith("video-"))
: ASHARE_OPENCLI_SOURCES.filter((source) => source.sourceGroup === contentType)
const selectedIds = new Set(sources.map((source) => source.id))
const additions = presets.filter((source) => !selectedIds.has(source.id))
if (additions.length > 0) onChange([...sources, ...additions])
}
const removeSource = (index: number) => {
setRemovedSource({ source: sources[index], index })
onChange(sources.filter((_, sourceIndex) => sourceIndex !== index))
}
const restoreSource = () => {
if (!removedSource) return
if (sources.some((source) => source.id === removedSource.source.id)) {
setRemovedSource(null)
return
}
const restored = [...sources]
restored.splice(Math.min(removedSource.index, restored.length), 0, removedSource.source)
onChange(restored)
setRemovedSource(null)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/components/flow/inspector.tsx` around lines 1959 - 1985, Update
addContentSources and restoreSource to compare sources by immutable source.id
rather than sourceSlotKey(), so preset additions and undo cannot introduce
duplicate source IDs after mutable args change. Keep the existing ordering,
removal, and restoration behavior unchanged.


return (
<section className="overflow-hidden rounded-[3px] border border-[#20242a] bg-[#101216]/84">
<div className="space-y-3 border-b border-[#24282f] bg-[#171a1f] p-3">
Expand Down Expand Up @@ -1944,6 +2029,28 @@ function OpenCLISourceEditor({
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] font-medium text-foreground">{copy.contentType}</Label>
<Select onValueChange={addContentSources}>
<SelectTrigger
aria-label={copy.addContent}
className="h-8 rounded-[3px] border-[#303640] bg-[#080a0c] text-xs shadow-none focus:ring-0"
>
<Plus className="size-3" />
<SelectValue placeholder={copy.addContent} />
</SelectTrigger>
<SelectContent>
{Object.entries(copy.contentTypes).map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] leading-relaxed text-muted-foreground">
{language === "zh-CN"
? "市场范围只影响行情类卡片;新闻、社区和视频按来源卡片独立配置。"
: "Market scope affects market cards only; news, social, and video are configured per source card."}
</p>
</div>
{sourceCatalog.isError ? (
<p className="text-[10px] leading-relaxed text-[#fca5a5]">
{copy.sourceUnavailable}
Expand Down Expand Up @@ -1998,36 +2105,57 @@ function OpenCLISourceEditor({
) : null}

<div className="space-y-2 p-2">
{removedSource ? (
<div role="status" className="flex items-center justify-between gap-3 rounded-[3px] border border-[#3a3327] bg-[#18140e] px-2.5 py-2 text-[10px] text-[#f7c77d]">
<span className="truncate">{copy.removedSource}: {sourceCardLabel(removedSource.source, language)}</span>
<button
type="button"
onClick={restoreSource}
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-[2px] border border-[#6b5230] px-2 font-medium transition-colors hover:border-[#f7c77d] hover:text-[#ffe4b5]"
>
<RotateCcw className="size-3" />
{copy.undoRemove}
</button>
</div>
) : null}
{sources.map((source, index) => {
const businessArguments = sourceBusinessArguments(source)
const optionCount = businessArguments.length + (source.positionalArgs?.length ? 1 : 0)
const contentType = source.sourceGroup?.startsWith("video-") ? "video" : source.sourceGroup
const contentLabel = contentType && contentType in copy.contentTypes
? copy.contentTypes[contentType as keyof typeof copy.contentTypes]
: (language === "zh-CN" ? "数据采集" : "Data collection")
return (
<div key={source.id} className="rounded-[3px] border border-[#252a31] bg-[#090a0c]/70">
<div className="flex items-center gap-2 p-2.5">
<details key={source.id} className="group rounded-[3px] border border-[#252a31] bg-[#090a0c]/70 open:border-[#3a414c]">
<summary className="flex cursor-pointer list-none items-center gap-2 p-2.5">
<span className="flex size-7 shrink-0 items-center justify-center rounded-[3px] border border-[#343a43] bg-[#15181d] font-mono text-[11px] font-semibold uppercase text-[#ff9a4a]">
{source.site.slice(0, 1)}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium text-foreground">{source.label}</p>
<p className="truncate text-xs font-medium text-foreground">{sourceCardLabel(source, language)}</p>
<p className="truncate text-[10px] text-muted-foreground">
{source.site} · {source.sourceGroup || (language === "zh-CN" ? "数据采集" : "Data collection")}
{source.site} · {contentLabel}
</p>
</div>
<button
type="button"
aria-label={`${copy.removeSource} ${source.label}`}
disabled={sources.length <= 1}
onClick={() => onChange(sources.filter((_, sourceIndex) => sourceIndex !== index))}
className="inline-flex size-7 shrink-0 items-center justify-center rounded-[2px] border border-[#2c3036] text-muted-foreground transition-colors hover:border-[#7f1d1d] hover:text-[#f87171] disabled:cursor-not-allowed disabled:opacity-30"
>
<Trash2 className="size-3" />
</button>
</div>
{businessArguments.length > 0 ? (
<details className="border-t border-[#20242a]">
<summary className="cursor-pointer list-none px-2.5 py-2 text-[10px] text-muted-foreground transition-colors hover:text-foreground">
{copy.collectionOptions} · {businessArguments.length} {copy.items}
</summary>
<div className="grid gap-2 border-t border-[#20242a] p-2.5">
<span className="shrink-0 text-[10px] text-muted-foreground">{copy.configureSource}</span>
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground transition-transform group-open:rotate-90" />
</summary>
<div className="grid gap-2 border-t border-[#20242a] p-2.5">
{optionCount > 0 ? (
<>
<p className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground">
{copy.collectionOptions} · {optionCount} {copy.items}
</p>
{source.positionalArgs?.length ? (
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">{copy.positionalArgument}</Label>
<Input
value={source.positionalArgs[0] ?? ""}
onChange={(event) => updateSource(index, { positionalArgs: [event.target.value] })}
className={houdiniInputClass}
/>
</div>
) : null}
{businessArguments.map(([key, value]) => (
<SourceBusinessArgument
key={key}
Expand All @@ -2037,10 +2165,26 @@ function OpenCLISourceEditor({
onChange={(nextValue) => updateSource(index, { args: { ...source.args, [key]: nextValue } })}
/>
))}
</div>
</details>
) : null}
</div>
</>
) : (
<p className="text-[10px] text-muted-foreground">
{language === "zh-CN" ? "此来源没有必须填写的业务参数。" : "This source has no required business parameters."}
</p>
)}
<div className="flex justify-end border-t border-[#20242a] pt-2">
<button
type="button"
aria-label={`${copy.removeSource} ${source.label}`}
disabled={sources.length <= 1}
onClick={() => removeSource(index)}
className="inline-flex h-7 items-center gap-1.5 rounded-[2px] border border-[#4a2525] px-2 text-[10px] text-[#f87171] transition-colors hover:border-[#f87171] disabled:cursor-not-allowed disabled:opacity-30"
>
<Trash2 className="size-3" />
{copy.removeSource}
</button>
</div>
</div>
</details>
)
})}
</div>
Expand Down
34 changes: 24 additions & 10 deletions frontend/lib/plugins/opencli-adapter-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,16 @@ export type OpenCLIAdapterPlugin = {
}

export const OPENCLI_SITE_CATEGORIES = [
{ key: "ai", label: "AI 工具" },
{ key: "social", label: "社交与内容" },
{ key: "news", label: "新闻资讯" },
{ key: "finance", label: "金融数据" },
{ key: "academic", label: "学术研究" },
{ key: "developer", label: "开发者工具" },
{ key: "commerce", label: "电商与生活" },
{ key: "government", label: "政务与行业" },
{ key: "local-app", label: "本地应用" },
{ key: "general", label: "工具与数据" },
{ key: "ai", label: "AI 工具", labelEn: "AI tools" },
{ key: "social", label: "社交与内容", labelEn: "Social & content" },
{ key: "news", label: "新闻资讯", labelEn: "News" },
{ key: "finance", label: "金融数据", labelEn: "Finance" },
{ key: "academic", label: "学术研究", labelEn: "Research" },
{ key: "developer", label: "开发者工具", labelEn: "Developer tools" },
{ key: "commerce", label: "电商与生活", labelEn: "Commerce & life" },
{ key: "government", label: "政务与行业", labelEn: "Government & industry" },
{ key: "local-app", label: "本地应用", labelEn: "Local apps" },
{ key: "general", label: "工具与数据", labelEn: "Tools & data" },
] as const

export type OpenCLISiteCategory = (typeof OPENCLI_SITE_CATEGORIES)[number]["key"]
Expand Down Expand Up @@ -77,6 +77,10 @@ const SITE_PRESENTATION_OVERRIDES: Record<
chatgpt: { label: "ChatGPT 网页版" },
"chatgpt-app": { label: "ChatGPT 桌面应用" },
cnki: { label: "中国知网" },
"cninfo-reports": {
label: "巨潮财报 CLI",
introduction: "自研 CLI 提供巨潮公告查询、全市场财报分片采集、PDF 下载与完整性审计。",
},
coingecko: { label: "CoinGecko" },
coinglass: { label: "CoinGlass" },
ctrip: { label: "携程" },
Expand Down Expand Up @@ -239,6 +243,7 @@ const SITE_CATEGORY_MEMBERS: Record<
"chinamoney",
"cls",
"cninfo",
"cninfo-reports",
"cnstock",
"coingecko",
"coinglass",
Expand Down Expand Up @@ -467,6 +472,15 @@ export function groupOpenCLIAdapterPlugins(
}).sort((left, right) => left.label.localeCompare(right.label))
}

export function openCLIKeyboardCandidates(
queryText: string,
selectedSite: OpenCLIAdapterPlugin | null,
matchingNodes: WorkflowOpenCLIAdapterNode[],
): WorkflowOpenCLIAdapterNode[] {
if (!queryText && !selectedSite) return []
return selectedSite?.commands ?? matchingNodes
}

Comment on lines +475 to +483

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect featuredOpenCLIAdapterGroups to check whether matchingNodes order
# already matches the featured-first rendering order used by command-palette.tsx.
rg -nP -C8 'function featuredOpenCLIAdapterGroups' frontend/lib/plugins/opencli-adapter-catalog.ts

Repository: 2233admin/opencli-admin

Length of output: 161


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== matching files =="
fd -a 'command-palette\.tsx$|opencli-adapter-catalog\.ts$' .

echo "== symbol searches =="
rg -n "openCLIKeyboardCandidates|featuredOpenCLIAdapterGroups|matchingNodes|OpenCLIKeyboardCandidates" frontend || true

echo "== catalog outline around relevant area =="
ast-grep outline frontend/lib/plugins/opencli-adapter-catalog.ts --view expanded 2>/dev/null | sed -n '1,220p' || true

echo "== catalog lines 430-510 =="
sed -n '430,510p' frontend/lib/plugins/opencli-adapter-catalog.ts

echo "== palette candidates references =="
file="$(fd 'command-palette\.tsx$' frontend | head -n1 || true)"
if [ -n "$file" ]; then
  echo "$file"
  sed -n '1,260p' "$file"
fi

Repository: 2233admin/opencli-admin

Length of output: 15842


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== featuredOpenCLIAdapterGroups definition =="
sed -n '240,320p' frontend/lib/workflow/backend-opencli-adapter-nodes.ts

echo "== command palette relevant lines =="
sed -n '700,790p' frontend/components/flow/command-palette.tsx

echo "== workflow catalog opencli adapter candidates definitions =="
rg -n -C6 "openCLIKeyboardCandidates|featuredOpenCLIAdapterGroups|searchText|sortOpenCLIAdapterNodes|findMatching" frontend/lib/workflow

echo "== focused search for matching/flattening in backend catalog =="
rg -n -C8 "function .*matching|matchingOpenCLINodes|OPENCLI_SEARCH_RESULT_LIMIT|selectedSite|firstOpenCLI" frontend/components/flow/command-palette.tsx backend-opencli-adapter-nodes.ts workflow/use-opencli-adapter-catalog.ts

Repository: 2233admin/opencli-admin

Length of output: 28529


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== opencliNodes source definitions in palette =="
rg -n -C5 "opencliNodes|useOpenCLI|fetchWorkflowOpenCLIAdapterNodes|openCLIAdapterNodeSort|sort" frontend/components/flow/command-palette.tsx

echo "== use opencli catalog =="
sed -n '1,220p' frontend/lib/workflow/use-opencli-adapter-catalog.ts

echo "== relevant palette around state/top =="
sed -n '1,180p' frontend/components/flow/command-palette.tsx

echo "== deterministic ordering probe: compare featured-first array vs input array for same query =="
python3 - <<'PY'
from pathlib import Path
src = Path('frontend/components/flow/command-palette.tsx').read_text()
print(src[src.find('opencliNodes'):src.find('opencliNodes')+1200])
PY

echo "== catalog tests around openCLIKeyboardCandidates =="
sed -n '530,600p' frontend/scripts/check-node-capability-catalog-regressions.mjs

Repository: 2233admin/opencli-admin

Length of output: 15244


Align keyboard candidates with the featured-first OpenCLI results.

When searching without a selected site, matchingOpenCLINodes are rendered as featured groups first (commonOpenCLINodes), but openCLIKeyboardCandidates() still returns the unfiltered matchingNodes order, so Enter can select the first matching source from the raw catalog order rather than the first featured matching group. Dedupe and order candidates the same way, for example by starting from featuredOpenCLIAdapterNodes(matchingNodes) and appending the remaining matching nodes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/lib/plugins/opencli-adapter-catalog.ts` around lines 475 - 483,
Update openCLIKeyboardCandidates to match the featured-first ordering used by
matchingOpenCLINodes: when no selectedSite is provided, derive candidates from
featuredOpenCLIAdapterNodes(matchingNodes), then append remaining matching nodes
while deduplicating them. Preserve the selectedSite?.commands result and the
empty-query/no-selection behavior.

export function summarizeOpenCLIAdapterPlugins(
plugins: OpenCLIAdapterPlugin[],
): OpenCLIAdapterRegistrySummary {
Expand Down
Loading