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
26 changes: 25 additions & 1 deletion web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1126,12 +1126,36 @@ export interface ImportPlan {
functional_relations: number;
}

/** 一次导入记下的账。**键与 `owl_import.rs` 写进 `summary` 的一一对应**;
* 全是可选的——老记录可能缺字段,界面按缺失处理而不是显示 0 */
export interface OntologyImportSummary {
classes_created?: number;
classes_updated?: number;
classes_key_taken?: number;
classes_without_description?: number;
relations_seen?: number;
relations_created?: number;
relations_updated?: number;
functional_relations?: number;
/** 逆属性 / 父属性连上了几条——目标 IRI 不在这个库里时会静默跳过 */
inverse_linked?: number;
sub_property_linked?: number;
attributes_seen?: number;
attributes_created?: number;
/** **按原因分组的计数**,不是一个数:`{no_domain: 3, unknown_domain: 1}`。
* 跳过一个属性的理由不止一种,而理由才是人下一步要处理的东西 */
attributes_skipped?: Record<string, number>;
/** 没投影下来的 IRI 与出现次数(服务端最多记 30 条) */
unprojected?: [string, number][];
triples?: number;
}

export interface OntologyImportView {
id: string;
filename: string;
format: string;
byte_size: number;
summary: Record<string, unknown>;
summary: OntologyImportSummary;
imported_by_name: string | null;
imported_at: string;
}
Expand Down
33 changes: 33 additions & 0 deletions web/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1332,6 +1332,39 @@ export const en = {
importDone: (created: number, updated: number) =>
`Imported — ${created} classes created, ${updated} updated.`,
importHistory: "Previous imports",
importColFile: "File",
importColFormat: "Format",
importColSize: "Size",
importColTriples: "Triples",
importColWhen: "Imported",
importDetail: "Detail",
importCreatedN: (n: number) => `${n} new`,
importUpdatedN: (n: number) => `${n} updated`,
importSkippedN: (n: number) => `${n} skipped`,
importTakenN: (n: number) => `${n} key taken`,
/* 细账的行名。**动词都是过去式**:这一屏说的是那一次导入做过什么 */
statClassesCreated: "Classes created",
statClassesUpdated: "Classes updated",
statClassesTaken: "Classes whose key was taken",
statClassesNoDesc: "Classes without a description",
statTriples: "Triples read",
statRelationsSeen: "Relations in the file",
statRelationsCreated: "Relations created",
statRelationsUpdated: "Relations updated",
statFunctional: "Relations declared functional",
statInverseLinked: "Inverse properties linked",
statSubPropertyLinked: "Sub-properties linked",
statAttributesSeen: "Attributes in the file",
statAttributesCreated: "Attributes created",
statAttributesSkipped: "Attributes skipped",
/* 跳过的理由。**每一条都要说得出下一步动哪里**,不然报了也白报 */
skipReason: {
key_taken: "key already in use",
unusable_range: "range cannot be a value type",
no_domain: "no domain — it never says which class it belongs to",
domain_skipped: "its domain class was skipped too",
unknown_domain: "domain class is not in this base",
} as Record<string, string | undefined>,
importNoHistory: "No imports yet.",
importBy: (who: string, when: string) => `${who} · ${when}`,
importSize: (bytes: number) =>
Expand Down
31 changes: 31 additions & 0 deletions web/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1183,6 +1183,37 @@ export const zh: Strings = {
importDone: (created: number, updated: number) =>
`导入完成——新建 ${created} 个类,更新 ${updated} 个。`,
importHistory: "历次导入",
importColFile: "文件",
importColFormat: "格式",
importColSize: "大小",
importColTriples: "三元组",
importColWhen: "导入",
importDetail: "详情",
importCreatedN: (n: number) => `新增 ${n}`,
importUpdatedN: (n: number) => `更新 ${n}`,
importSkippedN: (n: number) => `跳过 ${n}`,
importTakenN: (n: number) => `键被占 ${n}`,
statClassesCreated: "新建的类",
statClassesUpdated: "更新的类",
statClassesTaken: "键被占住的类",
statClassesNoDesc: "没有描述的类",
statTriples: "读到的三元组",
statRelationsSeen: "文件里的关系",
statRelationsCreated: "新建的关系",
statRelationsUpdated: "更新的关系",
statFunctional: "声明为函数式的关系",
statInverseLinked: "连上的逆属性",
statSubPropertyLinked: "连上的父属性",
statAttributesSeen: "文件里的属性",
statAttributesCreated: "新建的属性",
statAttributesSkipped: "跳过的属性",
skipReason: {
key_taken: "键已被占用",
unusable_range: "值域不能当数据类型",
no_domain: "没有域——没说它属于哪个类",
domain_skipped: "它的域类也被跳过了",
unknown_domain: "域类不在这个库里",
} as Record<string, string | undefined>,
importNoHistory: "还没有导入过。",
importBy: (who: string, when: string) => `${who} · ${when}`,
importSize: (bytes: number) =>
Expand Down
246 changes: 216 additions & 30 deletions web/src/pages/Ontology.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
api,
type EntityTypeView,
type ImportPlan,
type OntologyImportView,
type OntologyMiss,
type PlannedItem,
type OntologyProposals,
Expand Down Expand Up @@ -63,6 +64,15 @@ import {
pageSlice,
GroupLabel,
PageHeader,
Dialog,
LinkButton,
Table,
TBody,
Td,
Th,
THead,
Tr,
localDateTime,
} from "../ui";

/** 左栏行高(py-2 + 13px 文字 + space-y 间隙)与底部预留(新建行 + 分页器) */
Expand Down Expand Up @@ -2332,11 +2342,6 @@ function ImportPanel({
const pick = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient();

const history = useQuery({
queryKey: ["ontology-imports", kbId],
queryFn: () => api.ontologyImports(kbId),
});

const preview = useMutation({
mutationFn: (f: File) => api.previewOntologyImport(kbId, f),
onError: (e) => {
Expand Down Expand Up @@ -2539,36 +2544,217 @@ function ImportPanel({
</div>
)}

{/* 导入历史:谁在什么时候拿哪个文件动过本体。原文按 sha256 存着 */}
<div className="mt-6 border-t border-line pt-3">
<h4 className="text-small font-medium text-ink-2 mb-2">
{S.ontology.importHistory}
</h4>
{!history.data?.imports.length ? (
<p className="text-small text-ink-2">
{S.ontology.importNoHistory}
{/* 谁在什么时候拿哪个文件动过本体,以及那一次到底进来了什么 */}
<ImportHistory kbId={kbId} />
</div>
);
}

/** 导入历史:**服务端每次导入记下的是一整本账**——建了几个类、更新了几个、
* 几个键被占、逆属性连上了几条、属性跳过了几个、多少三元组、哪些 IRI 没投影
* 下来。从前这一段只印「文件名 · 大小 · 谁在哪天」,其余全落在地上:
* 导完之后想知道「到底进来了什么」,界面上没有一个地方说得出。
*
* 所以这里是一张表(每行一次导入的账),细账在行末的详情里——那些数只有
* 出问题时才有人读,不该占着表宽。 */
function ImportHistory({ kbId }: { kbId: string }) {
const [detail, setDetail] = useState<OntologyImportView | null>(null);
const history = useQuery({
queryKey: ["ontology-imports", kbId],
queryFn: () => api.ontologyImports(kbId),
});
const rows = history.data?.imports ?? [];

return (
<div className="mt-6">
<h4 className="mb-2 text-small font-medium text-ink-2">
{S.ontology.importHistory}
</h4>
{/* 还在取的时候不能说「还没有导入过」——那句话是假的,而且它跟真的
没导入过长得一模一样,读的人分不出自己看到的是哪一种 */}
{history.isLoading ? (
<p className="text-small text-ink-2">{S.nav.loading}</p>
) : !rows.length ? (
<p className="text-small text-ink-2">{S.ontology.importNoHistory}</p>
) : (
<div className="glass overflow-hidden rounded-panel">
<Table>
<THead>
<Tr>
<Th>{S.ontology.importColFile}</Th>
<Th>{S.ontology.importColFormat}</Th>
<Th>{S.ontology.importColSize}</Th>
<Th>{S.ontology.importClasses}</Th>
<Th>{S.ontology.importRelations}</Th>
<Th>{S.ontology.importAttributes}</Th>
<Th>{S.ontology.importColTriples}</Th>
<Th>{S.ontology.importColWhen}</Th>
<Th />
</Tr>
</THead>
<TBody>
{rows.map((im) => {
const s = im.summary ?? {};
return (
<Tr key={im.id}>
{/* 一格一件事,一行一条导入。**格式与大小各占一列**,不是
叠在文件名底下——叠着把每一行都撑成两行高,而右边还空着
半张表。文件名不用等宽:等宽是给键、id、代码和 URL 的
(DESIGN.md 1),一个文件名在一列 Geist 里只显得突兀 */}
<Td>
<div className="max-w-64 truncate text-small text-ink" title={im.filename}>
{im.filename}
</div>
</Td>
<Td className="text-small text-ink-2">{im.format}</Td>
<Td className="u-num whitespace-nowrap text-small text-ink-2">
{S.ontology.importSize(im.byte_size)}
</Td>
<Td className="text-small text-ink-2">
<Counts
created={s.classes_created}
updated={s.classes_updated}
taken={s.classes_key_taken}
/>
</Td>
<Td className="text-small text-ink-2">
<Counts created={s.relations_created} updated={s.relations_updated} />
</Td>
<Td className="text-small text-ink-2">
<Counts
created={s.attributes_created}
skipped={sumCounts(s.attributes_skipped)}
/>
</Td>
<Td className="u-num text-small text-ink-2">{s.triples ?? "—"}</Td>
<Td className="text-small text-ink-2 whitespace-nowrap">
{S.ontology.importBy(
im.imported_by_name ?? "—",
localDateTime(im.imported_at).slice(0, 16),
)}
</Td>
<Td className="text-right">
<LinkButton onClick={() => setDetail(im)}>
{S.ontology.importDetail}
</LinkButton>
</Td>
</Tr>
);
})}
</TBody>
</Table>
</div>
)}

{/* 细账:只有出了问题才有人读,所以收在这里,而不是摊在表上 */}
<Dialog
open={!!detail}
onOpenChange={(o) => !o && setDetail(null)}
width="lg"
closeLabel={S.ui.close}
title={detail?.filename ?? ""}
description={
detail
? S.ontology.importBy(
detail.imported_by_name ?? "—",
localDateTime(detail.imported_at),
)
: undefined
}
>
{detail && <ImportDetail im={detail} />}
</Dialog>
</div>
);
}

/** `{no_domain: 3, unknown_domain: 1}` → 4。缺就是 0 */
function sumCounts(m: Record<string, number> | undefined): number {
return Object.values(m ?? {}).reduce((a, b) => a + b, 0);
}

/** 表里一格:建了几个 / 更新了几个 / 占了几个键 / 跳过几个。全零就是一横 */
function Counts({
created,
updated,
taken,
skipped,
}: {
created?: number;
updated?: number;
taken?: number;
skipped?: number;
}) {
const parts = [
created ? S.ontology.importCreatedN(created) : null,
updated ? S.ontology.importUpdatedN(updated) : null,
skipped ? S.ontology.importSkippedN(skipped) : null,
taken ? S.ontology.importTakenN(taken) : null,
].filter(Boolean);
return <>{parts.length ? parts.join(" · ") : "—"}</>;
}

function ImportDetail({ im }: { im: OntologyImportView }) {
const s = im.summary ?? {};
const stat = (label: string, n: number | undefined) =>
n === undefined ? null : (
<div key={label} className="flex items-baseline justify-between gap-3 py-1">
<span className="text-small text-ink-2">{label}</span>
<span className="u-num text-small text-ink">{n}</span>
</div>
);
const L = S.ontology;
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-x-6">
<div className="divide-y divide-line">
{stat(L.statClassesCreated, s.classes_created)}
{stat(L.statClassesUpdated, s.classes_updated)}
{stat(L.statClassesTaken, s.classes_key_taken)}
{stat(L.statClassesNoDesc, s.classes_without_description)}
{stat(L.statTriples, s.triples)}
</div>
<div className="divide-y divide-line">
{stat(L.statRelationsSeen, s.relations_seen)}
{stat(L.statRelationsCreated, s.relations_created)}
{stat(L.statRelationsUpdated, s.relations_updated)}
{stat(L.statFunctional, s.functional_relations)}
{stat(L.statInverseLinked, s.inverse_linked)}
{stat(L.statSubPropertyLinked, s.sub_property_linked)}
{stat(L.statAttributesSeen, s.attributes_seen)}
{stat(L.statAttributesCreated, s.attributes_created)}
{/* 跳过的属性**按理由分**:数字只说"少了几个",理由才说得出下一步
该改本体的哪里(域没写、域不在这个库里、值域用不了…) */}
{Object.entries(s.attributes_skipped ?? {}).map(([reason, n]) =>
stat(
`${L.statAttributesSkipped} · ${S.ontology.skipReason[reason] ?? reason}`,
n,
),
)}
</div>
</div>

{/* 没投影下来的 IRI:引用外部词汇表是常态,可「少连了多少」得说得出来 */}
{!!s.unprojected?.length && (
<div>
<p className="mb-1 text-small font-medium text-ink-2">
{S.ontology.importUnprojected} ({s.unprojected.length})
</p>
) : (
<ul className="space-y-2">
{history.data.imports.map((im) => (
<li key={im.id} className="flex items-baseline gap-2 text-small">
<span className="font-mono text-ink-2 truncate">
{im.filename}
</span>
<span className="u-num text-ink-2 shrink-0">
{S.ontology.importSize(im.byte_size)}
</span>
<span className="ml-auto text-fine text-ink-2 shrink-0">
{S.ontology.importBy(
im.imported_by_name ?? "—",
new Date(im.imported_at).toLocaleDateString(),
)}
<p className="mb-2 text-fine leading-relaxed text-ink-2">
{S.ontology.importUnprojectedBody}
</p>
<ul className="u-scroll max-h-48 space-y-1 overflow-y-auto">
{s.unprojected.map(([iri, n]) => (
<li key={iri} className="flex gap-2 text-fine">
<span className="min-w-0 truncate font-mono text-ink-2" title={iri}>
{shortIri(iri)}
</span>
<span className="u-num shrink-0 text-ink-2">×{n}</span>
</li>
))}
</ul>
)}
</div>
</div>
)}
</div>
);
}
Expand Down