Complete Model Weave V0.4 authoring and validation workflow

This commit is contained in:
nejimakibird 2026-04-23 20:28:47 +09:00
parent dd5340de5f
commit e4dbe83237
31 changed files with 4251 additions and 1157 deletions

View file

@ -1,8 +1,8 @@
# Model Weave: V0.3 Samples and Templates
# Model Weave: V0.4 Samples and Templates
Model Weave is an Obsidian plugin for managing text-first models, relationships, and diagrams in Markdown.
This repository uses the V0.3 modeling file formats below as the formal supported formats.
This repository uses the V0.4 modeling file formats below as the formal supported formats.
- `class`
- `class_diagram`
@ -14,7 +14,7 @@ Legacy formats such as `schema: diagram_v1`, `type: diagram`, and `type: er_rela
## Directory Layout
- [Templates/v03](C:\Users\kamim\Documents\Programing\modeling-tool-obsidian\Templates\v03)
- Formal copy-paste templates for V0.3 files
- Formal copy-paste templates for V0.4 files
- [samples/v03/minimal](C:\Users\kamim\Documents\Programing\modeling-tool-obsidian\samples\v03\minimal)
- Small samples for parser and preview checks
- [samples/v03/rich](C:\Users\kamim\Documents\Programing\modeling-tool-obsidian\samples\v03\rich)
@ -26,7 +26,7 @@ Legacy formats such as `schema: diagram_v1`, `type: diagram`, and `type: er_rela
## Template Usage
Use the files in `Templates/v03` as the starting point for new modeling files.
Use the files in `Templates/v03` as the starting point for new modeling files. The directory name is kept for compatibility with earlier sample organization, but the current content follows the V0.4 rules.
- [Templates/v03/er_entity.md](C:\Users\kamim\Documents\Programing\modeling-tool-obsidian\Templates\v03\er_entity.md)
- [Templates/v03/er_diagram.md](C:\Users\kamim\Documents\Programing\modeling-tool-obsidian\Templates\v03\er_diagram.md)
@ -38,12 +38,24 @@ Guidelines:
- Keep the filename aligned with the `id` field.
- Use Markdown tables exactly as shown in the templates.
- Do not use legacy `diagram_v1` or `type: diagram` in new files.
- For `class`, use Spec04 relations: `id / to / kind / label / from_multiplicity / to_multiplicity / notes`. The `from` side is the current class file's `id`.
- For `class.Relations.to`, use the target class ID, not a wikilink.
- For `er_entity.target_table`, `er_diagram.Objects.ref`, and `class_diagram.Objects.ref`, prefer wikilinks that point to the actual file path.
- For abstract classes, use `kind: class` with `stereotype: abstract`.
## Reference Rules
- ER file references should be clickable wikilinks such as `[[samples/v03/rich/er/ENT-CUSTOMER]]`. Internally, ER relation handling uses the linked entity's `physical_name`.
- Diagram object refs should also be clickable wikilinks to the object files.
- Class single-file relations use ID-based `to` values such as `IF-ORDER-REPOSITORY`.
- Class diagram relations still use explicit `from` / `to` because they describe diagram-level edges.
- The old class relation table with an explicit `from` column is accepted only for compatibility and should not be used for new files.
## Sample Purposes
### Minimal samples
Use these to confirm that parser, single-object view, and diagram view work with the smallest valid V0.3 input.
Use these to confirm that parser, single-object view, and diagram view work with the smallest valid V0.4 input.
- ER
- [samples/v03/minimal/er/ENT-CUSTOMER.md](C:\Users\kamim\Documents\Programing\modeling-tool-obsidian\samples\v03\minimal\er\ENT-CUSTOMER.md)
@ -73,7 +85,7 @@ Use these only for testing warning and note behavior.
- [testdata/v03/warning/class/CLS-BROKEN-TABLE.md](C:\Users\kamim\Documents\Programing\modeling-tool-obsidian\testdata\v03\warning\class\CLS-BROKEN-TABLE.md)
- Unresolved class diagram references:
- [testdata/v03/warning/class/CLASSD-UNRESOLVED-REF.md](C:\Users\kamim\Documents\Programing\modeling-tool-obsidian\testdata\v03\warning\class\CLASSD-UNRESOLVED-REF.md)
- Outside-scope ER notes:
- Outside-scope ER warning behavior:
- [testdata/v03/warning/er/ERD-OUTSIDE-SCOPE.md](C:\Users\kamim\Documents\Programing\modeling-tool-obsidian\testdata\v03\warning\er\ERD-OUTSIDE-SCOPE.md)
### Unsupported samples

View file

@ -139,6 +139,51 @@ AI やツールは下書き生成や整合性チェックを支援するが、
---
## V0.4 現行仕様メモ
### Class と Class Diagram
`class` は Spec04 形式を正規形式として扱います。
`class``## Relations``from` 列を持たず、`from` は常にそのファイル自身の frontmatter `id` とみなします。
`class``## Relations` は以下の列を使います。
- `id`
- `to`
- `kind`
- `label`
- `from_multiplicity`
- `to_multiplicity`
- `notes`
`class.Relations.to` は ID ベースで記述します。
例: `CLS-ORDER-ORDER`, `IF-ORDER-ORDER-REPOSITORY`
一方、`class_diagram` は図全体に対する明示 relation を表すため、`## Relations` では従来どおり `from` / `to` を持ちます。
`class_diagram.Objects.ref` は、Obsidian 上で実ファイルへ移動できる wikilink を推奨します。
例: `[[classes/CLS-ORDER-ORDER]]`
抽象クラスは当面、`kind: class` と `stereotype: abstract` の組み合わせで表現します。
`kind: abstract` は正規運用ではなく、サンプルやテンプレートでは使いません。
`class` relation 形式、つまり `## Relations``from` 列を持つ形式は、当面は読み込み互換のみ残しています。
新規作成・テンプレート・正式サンプルでは Spec04 形式を使ってください。
### ER の参照ルール
ER の `target_table` と diagram の `Objects.ref` は、Obsidian 上で実ファイルへ移動できる wikilink を推奨します。
- `er_entity.Relations.target_table`: `[[entities/m_customer]]`
- `er_diagram.Objects.ref`: `[[entities/t_order]]`
- `class_diagram.Objects.ref`: `[[classes/CLS-ORDER-ORDER]]`
内部解釈では、リンク先ファイルを解決したうえで frontmatter や本文から必要な値を読みます。
ER の SQL 的なテーブル名や relation 解釈では、リンク先 `er_entity``physical_name` を使います。
このため、参照値そのものは「クリックできるリンク」、DB 名や class ID は「リンク先ファイルの属性」として分離して扱います。
---
## 基本的な使い方
1. 設計対象をプレーンテキストで記述する

View file

@ -27,8 +27,8 @@ tags:
## Relations
| id | from | to | kind | label | from_multiplicity | to_multiplicity | notes |
|---|---|---|---|---|---|---|---|
| id | to | kind | label | from_multiplicity | to_multiplicity | notes |
|---|---|---|---|---|---|---|
## Notes

3216
main.js

File diff suppressed because it is too large Load diff

View file

@ -17,8 +17,8 @@ Minimal class diagram sample with empty diagram relations to exercise fallback a
| ref | notes |
|---|---|
| [[CLS-ORDER-SERVICE]] | Service class |
| [[IF-ORDER-REPOSITORY]] | Repository interface |
| [[samples/v03/minimal/class/CLS-ORDER-SERVICE]] | Service class |
| [[samples/v03/minimal/class/IF-ORDER-REPOSITORY]] | Repository interface |
## Relations
@ -28,4 +28,3 @@ Minimal class diagram sample with empty diagram relations to exercise fallback a
## Notes
- This sample intentionally leaves diagram relations empty so the viewer can auto-collect from class files.

View file

@ -29,11 +29,10 @@ Minimal class sample for single-object preview and relation parsing.
## Relations
| id | from | to | kind | label | from_multiplicity | to_multiplicity | notes |
|---|---|---|---|---|---|---|---|
| REL-ORDER-SERVICE-USES-REPOSITORY | [[CLS-ORDER-SERVICE]] | [[IF-ORDER-REPOSITORY]] | dependency | uses | 1 | 1 | Service depends on repository |
| id | to | kind | label | from_multiplicity | to_multiplicity | notes |
|---|---|---|---|---|---|---|
| REL-ORDER-SERVICE-USES-REPOSITORY | IF-ORDER-REPOSITORY | dependency | uses | 1 | 1 | Service depends on repository |
## Notes
- Minimal sample for class relation parsing and Related Objects.

View file

@ -28,10 +28,9 @@ Minimal interface sample referenced by OrderService.
## Relations
| id | from | to | kind | label | from_multiplicity | to_multiplicity | notes |
|---|---|---|---|---|---|---|---|
| id | to | kind | label | from_multiplicity | to_multiplicity | notes |
|---|---|---|---|---|---|---|
## Notes
- Minimal interface sample with no outbound relations.

View file

@ -34,7 +34,7 @@ Minimal ER sample with one outbound relation to Customer.
## Relations
### REL-ORDER-TO-CUSTOMER
- target_table: [[ENT-CUSTOMER]]
- target_table: [[samples/v03/minimal/er/ENT-CUSTOMER]]
- kind: fk
- cardinality: N-1
- notes: Each order belongs to one customer
@ -46,4 +46,3 @@ Minimal ER sample with one outbound relation to Customer.
## Notes
- Minimal relation sample for ER diagram aggregation.

View file

@ -17,10 +17,9 @@ Minimal ER diagram sample for object resolution and relation aggregation.
| ref | notes |
|---|---|
| [[ENT-CUSTOMER]] | Master table |
| [[ENT-ORDER]] | Transaction table |
| [[samples/v03/minimal/er/ENT-CUSTOMER]] | Master table |
| [[samples/v03/minimal/er/ENT-ORDER]] | Transaction table |
## Notes
- Open this file to confirm ER diagram view, zoom, fit, and click navigation.

View file

@ -2,7 +2,8 @@
type: class
id: ABS-BASE-SERVICE
name: BaseService
kind: abstract
kind: class
stereotype: abstract
package: app.shared
tags:
- sample
@ -29,10 +30,9 @@ Reference abstract base class sample.
## Relations
| id | from | to | kind | label | from_multiplicity | to_multiplicity | notes |
|---|---|---|---|---|---|---|---|
| id | to | kind | label | from_multiplicity | to_multiplicity | notes |
|---|---|---|---|---|---|---|
## Notes
- Rich sample target for inheritance edges.

View file

@ -17,12 +17,12 @@ Rich class diagram sample for fallback auto-collect, dependency, inheritance, an
| ref | notes |
|---|---|
| [[CLS-ORDER-APPLICATION-SERVICE]] | Application service |
| [[CLS-ORDER-DOMAIN-SERVICE]] | Domain service |
| [[IF-ORDER-REPOSITORY]] | Repository interface |
| [[ABS-BASE-SERVICE]] | Shared base class |
| [[CLS-ORDER]] | Aggregate root |
| [[CLS-ORDER-ITEM]] | Aggregate child |
| [[samples/v03/rich/class/CLS-ORDER-APPLICATION-SERVICE]] | Application service |
| [[samples/v03/rich/class/CLS-ORDER-DOMAIN-SERVICE]] | Domain service |
| [[samples/v03/rich/class/IF-ORDER-REPOSITORY]] | Repository interface |
| [[samples/v03/rich/class/ABS-BASE-SERVICE]] | Shared base class |
| [[samples/v03/rich/class/CLS-ORDER]] | Aggregate root |
| [[samples/v03/rich/class/CLS-ORDER-ITEM]] | Aggregate child |
## Relations
@ -32,4 +32,3 @@ Rich class diagram sample for fallback auto-collect, dependency, inheritance, an
## Notes
- This sample intentionally uses empty diagram relations so fallback auto-collect can be verified.

View file

@ -31,13 +31,12 @@ Reference class sample for service-oriented relations and dependency visualizati
## Relations
| id | from | to | kind | label | from_multiplicity | to_multiplicity | notes |
|---|---|---|---|---|---|---|---|
| REL-APP-USES-DOMAIN-SERVICE | [[CLS-ORDER-APPLICATION-SERVICE]] | [[CLS-ORDER-DOMAIN-SERVICE]] | dependency | uses | 1 | 1 | Uses domain service |
| REL-APP-USES-REPOSITORY | [[CLS-ORDER-APPLICATION-SERVICE]] | [[IF-ORDER-REPOSITORY]] | dependency | uses | 1 | 1 | Uses repository |
| REL-APP-EXTENDS-BASE | [[CLS-ORDER-APPLICATION-SERVICE]] | [[ABS-BASE-SERVICE]] | inheritance | extends | 1 | 1 | Extends base service |
| id | to | kind | label | from_multiplicity | to_multiplicity | notes |
|---|---|---|---|---|---|---|
| REL-APP-USES-DOMAIN-SERVICE | CLS-ORDER-DOMAIN-SERVICE | dependency | uses | 1 | 1 | Uses domain service |
| REL-APP-USES-REPOSITORY | IF-ORDER-REPOSITORY | dependency | uses | 1 | 1 | Uses repository |
| REL-APP-EXTENDS-BASE | ABS-BASE-SERVICE | inheritance | extends | 1 | 1 | Extends base service |
## Notes
- Rich sample for dependency and inheritance rendering.

View file

@ -30,11 +30,10 @@ Reference domain service sample.
## Relations
| id | from | to | kind | label | from_multiplicity | to_multiplicity | notes |
|---|---|---|---|---|---|---|---|
| REL-DOMAIN-CREATES-ORDER | [[CLS-ORDER-DOMAIN-SERVICE]] | [[CLS-ORDER]] | dependency | creates | 1 | * | Builds order aggregate |
| id | to | kind | label | from_multiplicity | to_multiplicity | notes |
|---|---|---|---|---|---|---|
| REL-DOMAIN-CREATES-ORDER | CLS-ORDER | dependency | creates | 1 | * | Builds order aggregate |
## Notes
- Rich sample for class single-view related graph.

View file

@ -30,10 +30,9 @@ Reference value object style sample.
## Relations
| id | from | to | kind | label | from_multiplicity | to_multiplicity | notes |
|---|---|---|---|---|---|---|---|
| id | to | kind | label | from_multiplicity | to_multiplicity | notes |
|---|---|---|---|---|---|---|
## Notes
- Rich sample target for composition.

View file

@ -32,11 +32,10 @@ Reference aggregate root sample.
## Relations
| id | from | to | kind | label | from_multiplicity | to_multiplicity | notes |
|---|---|---|---|---|---|---|---|
| REL-ORDER-HAS-ITEM | [[CLS-ORDER]] | [[CLS-ORDER-ITEM]] | composition | has | 1 | * | Aggregate owns order items |
| id | to | kind | label | from_multiplicity | to_multiplicity | notes |
|---|---|---|---|---|---|---|
| REL-ORDER-HAS-ITEM | CLS-ORDER-ITEM | composition | has | 1 | * | Aggregate owns order items |
## Notes
- Rich sample for composition rendering.

View file

@ -29,10 +29,9 @@ Reference interface sample used by application services.
## Relations
| id | from | to | kind | label | from_multiplicity | to_multiplicity | notes |
|---|---|---|---|---|---|---|---|
| id | to | kind | label | from_multiplicity | to_multiplicity | notes |
|---|---|---|---|---|---|---|
## Notes
- Rich sample target for dependency edges.

View file

@ -35,7 +35,7 @@ Rich ER sample with composite foreign key mapping.
## Relations
### REL-ORDER-ITEM-TO-ORDER
- target_table: [[ENT-ORDER]]
- target_table: [[samples/v03/rich/er/ENT-ORDER]]
- kind: fk
- cardinality: N-1
- notes: Composite FK to order
@ -46,7 +46,7 @@ Rich ER sample with composite foreign key mapping.
| order_branch_no | order_branch_no | Composite key part 2 |
### REL-ORDER-ITEM-TO-PRODUCT
- target_table: [[ENT-PRODUCT]]
- target_table: [[samples/v03/rich/er/ENT-PRODUCT]]
- kind: fk
- cardinality: N-1
- notes: Product reference
@ -58,4 +58,3 @@ Rich ER sample with composite foreign key mapping.
## Notes
- Rich sample for composite FK and mapping summary checks.

View file

@ -35,7 +35,7 @@ Rich ER sample for FK aggregation and single-object subgraph rendering.
## Relations
### REL-ORDER-TO-CUSTOMER
- target_table: [[ENT-CUSTOMER]]
- target_table: [[samples/v03/rich/er/ENT-CUSTOMER]]
- kind: fk
- cardinality: N-1
- notes: Order belongs to customer
@ -47,4 +47,3 @@ Rich ER sample for FK aggregation and single-object subgraph rendering.
## Notes
- Rich sample source for inbound and outbound relation checks.

View file

@ -17,12 +17,11 @@ Rich ER diagram sample for layout, mapping summary, and related-object verificat
| ref | notes |
|---|---|
| [[ENT-CUSTOMER]] | Customer master |
| [[ENT-ORDER]] | Order header |
| [[ENT-ORDER-ITEM]] | Order line |
| [[ENT-PRODUCT]] | Product master |
| [[samples/v03/rich/er/ENT-CUSTOMER]] | Customer master |
| [[samples/v03/rich/er/ENT-ORDER]] | Order header |
| [[samples/v03/rich/er/ENT-ORDER-ITEM]] | Order line |
| [[samples/v03/rich/er/ENT-PRODUCT]] | Product master |
## Notes
- Open this file to verify dense ER diagram rendering and composite FK handling.

View file

@ -0,0 +1,233 @@
import {
resolveErEntityReference,
resolveObjectModelReference
} from "./reference-resolver";
import type { ModelingVaultIndex } from "./vault-index";
import type { ResolvedObjectContext } from "./object-context-resolver";
import type {
ErEntity,
ObjectModel,
ResolvedDiagram,
ValidationWarning
} from "../types/models";
const CLASS_RELATION_KINDS = new Set([
"association",
"dependency",
"inheritance",
"implementation",
"aggregation",
"composition"
]);
export function buildCurrentObjectDiagnostics(
model: ObjectModel | ErEntity,
index: ModelingVaultIndex,
context: ResolvedObjectContext | null,
warnings: ValidationWarning[]
): ValidationWarning[] {
const diagnostics = warnings.map((warning: ValidationWarning) =>
normalizeDiagnosticSeverity(warning)
);
if (model.fileType === "object") {
diagnostics.push(...buildClassDiagnostics(model, index));
} else {
diagnostics.push(...buildErEntityDiagnostics(model, index));
}
if (context) {
diagnostics.push(...context.warnings.map((warning) => normalizeDiagnosticSeverity(warning)));
}
return dedupeDiagnostics(diagnostics);
}
export function buildCurrentDiagramDiagnostics(
diagram: ResolvedDiagram,
warnings: ValidationWarning[]
): ValidationWarning[] {
return dedupeDiagnostics(warnings.map((warning) => normalizeDiagnosticSeverity(warning)));
}
function buildClassDiagnostics(
model: ObjectModel,
index: ModelingVaultIndex
): ValidationWarning[] {
const diagnostics: ValidationWarning[] = [];
for (const relation of model.relations) {
if (!resolveObjectModelReference(relation.targetClass, index)) {
diagnostics.push({
code: "unresolved-reference",
message: `unresolved class relation target "${relation.targetClass}"`,
severity: "warning",
path: model.path,
field: "Relations",
context: {
relatedId: relation.id,
section: "Relations"
}
});
}
if (!CLASS_RELATION_KINDS.has(relation.kind)) {
diagnostics.push({
code: "invalid-kind",
message: `invalid class relation kind "${relation.kind}"`,
severity: "warning",
path: model.path,
field: "Relations",
context: {
relatedId: relation.id,
section: "Relations"
}
});
}
}
return diagnostics;
}
function buildErEntityDiagnostics(
entity: ErEntity,
index: ModelingVaultIndex
): ValidationWarning[] {
const diagnostics: ValidationWarning[] = [];
const localColumnNames = new Set(entity.columns.map((column) => column.physicalName));
if (entity.relationBlocks.length === 0) {
diagnostics.push({
code: "section-missing",
message: 'No relations are defined in "## Relations".',
severity: "info",
path: entity.path,
field: "Relations",
context: {
section: "Relations"
}
});
}
for (const relationBlock of entity.relationBlocks) {
if (!relationBlock.cardinality) {
diagnostics.push({
code: "section-missing",
message: `relation "${relationBlock.id}" does not specify cardinality`,
severity: "info",
path: entity.path,
field: "Relations",
context: {
relatedId: relationBlock.id,
section: "Relations"
}
});
}
if (!relationBlock.targetTable) {
diagnostics.push({
code: "unresolved-reference",
message: `relation "${relationBlock.id}" does not resolve target_table`,
severity: "warning",
path: entity.path,
field: "Relations",
context: {
relatedId: relationBlock.id,
section: "Relations"
}
});
continue;
}
const targetEntity = resolveErEntityReference(relationBlock.targetTable, index);
if (!targetEntity) {
diagnostics.push({
code: "unresolved-reference",
message: `relation "${relationBlock.id}" target_table "${relationBlock.targetTable}" could not be resolved`,
severity: "warning",
path: entity.path,
field: "Relations",
context: {
relatedId: relationBlock.id,
section: "Relations"
}
});
continue;
}
const targetColumnNames = new Set(
targetEntity.columns.map((column) => column.physicalName)
);
for (const mapping of relationBlock.mappings) {
if (mapping.localColumn && !localColumnNames.has(mapping.localColumn)) {
diagnostics.push({
code: "unresolved-reference",
message: `relation "${relationBlock.id}" local column "${mapping.localColumn}" does not exist in the current entity`,
severity: "warning",
path: entity.path,
field: "Relations",
context: {
relatedId: relationBlock.id,
section: "Relations"
}
});
}
if (mapping.targetColumn && !targetColumnNames.has(mapping.targetColumn)) {
diagnostics.push({
code: "unresolved-reference",
message: `relation "${relationBlock.id}" target column "${mapping.targetColumn}" does not exist in "${targetEntity.physicalName}"`,
severity: "warning",
path: entity.path,
field: "Relations",
context: {
relatedId: relationBlock.id,
section: "Relations"
}
});
}
}
}
return diagnostics;
}
function normalizeDiagnosticSeverity(warning: ValidationWarning): ValidationWarning {
if (warning.severity === "info" || warning.severity === "error") {
return warning;
}
if (
warning.code === "frontmatter-parse-error" ||
warning.code === "unknown-schema" ||
warning.code === "invalid-table-column" ||
warning.code === "invalid-table-row" ||
warning.code === "missing-name" ||
warning.code === "missing-kind"
) {
return { ...warning, severity: "error" };
}
if (
warning.code === "invalid-structure" &&
typeof warning.field === "string" &&
["type", "id", "name", "logical_name", "physical_name", "kind"].includes(warning.field)
) {
return { ...warning, severity: "error" };
}
return warning;
}
function dedupeDiagnostics(warnings: ValidationWarning[]): ValidationWarning[] {
return warnings.filter((warning, index) =>
warnings.findIndex((entry) =>
entry.code === warning.code &&
entry.message === warning.message &&
entry.severity === warning.severity &&
entry.path === warning.path &&
entry.field === warning.field
) === index
);
}

View file

@ -25,42 +25,29 @@ export function resolveDiagramRelations(
}
const warnings: ValidationWarning[] = [];
const resolvedNodes: Array<DiagramNode & { object?: ObjectModel }> = [];
const presentObjectIds = new Set<string>();
const deduped = dedupeDiagramNodes(
diagram,
(objectRef) => resolveObjectModelReference(objectRef, index) ?? undefined,
(object, objectRef) => (object ? getObjectId(object) : objectRef),
(object, objectRef) => (object ? getObjectId(object) : `ref:${objectRef}`),
(objectRef) => `unresolved object ref "${objectRef}"`
);
for (const objectRef of diagram.objectRefs) {
const object = resolveObjectModelReference(objectRef, index) ?? undefined;
const resolvedId = object ? getObjectId(object) : objectRef;
if (!object) {
warnings.push({
code: "unresolved-reference",
message: `unresolved object ref "${objectRef}"`,
severity: "warning",
path: diagram.path,
field: "objectRefs"
});
} else {
presentObjectIds.add(resolvedId);
for (const node of deduped.nodes) {
if (node.object) {
presentObjectIds.add(getObjectId(node.object));
}
resolvedNodes.push({
id: resolvedId,
ref: objectRef,
object
});
}
const edges = resolveEdges(diagram, index, presentObjectIds, warnings);
return {
diagram,
nodes: resolvedNodes,
nodes: deduped.nodes,
edges,
missingObjects: diagram.objectRefs.filter(
(ref) => !resolveObjectModelReference(ref, index)
),
warnings
missingObjects: deduped.missingObjects,
warnings: [...warnings, ...deduped.warnings]
};
}
@ -69,39 +56,102 @@ function resolveErDiagramRelations(
index: ModelingVaultIndex
): ResolvedDiagram {
const warnings: ValidationWarning[] = [];
const resolvedNodes: Array<DiagramNode & { object?: ErEntity }> = [];
const presentEntityPhysicalNames = new Set<string>();
const deduped = dedupeDiagramNodes(
diagram,
(objectRef) => resolveErEntityReference(objectRef, index) ?? undefined,
(entity, objectRef) => entity?.id ?? objectRef,
(entity, objectRef) => entity?.id ?? `ref:${objectRef}`,
(objectRef) => `unresolved ER entity ref "${objectRef}"`
);
for (const objectRef of diagram.objectRefs) {
const entity = resolveErEntityReference(objectRef, index) ?? undefined;
const resolvedId = entity?.id ?? objectRef;
if (!entity) {
warnings.push({
code: "unresolved-reference",
message: `unresolved ER entity ref "${objectRef}"`,
severity: "warning",
path: diagram.path,
field: "objectRefs"
});
} else {
presentEntityPhysicalNames.add(entity.physicalName);
for (const node of deduped.nodes) {
if (node.object) {
presentEntityPhysicalNames.add(node.object.physicalName);
}
resolvedNodes.push({
id: resolvedId,
ref: objectRef,
object: entity
});
}
return {
diagram,
nodes: resolvedNodes,
nodes: deduped.nodes,
edges: resolveErEdges(diagram, index, presentEntityPhysicalNames, warnings),
missingObjects: diagram.objectRefs.filter(
(ref) => !resolveErEntityReference(ref, index)
),
missingObjects: deduped.missingObjects,
warnings: [...warnings, ...deduped.warnings]
};
}
function dedupeDiagramNodes<TObject extends ObjectModel | ErEntity>(
diagram: DiagramModel,
resolveObject: (objectRef: string) => TObject | undefined,
buildResolvedId: (object: TObject | undefined, objectRef: string) => string,
buildCanonicalKey: (object: TObject | undefined, objectRef: string) => string,
buildUnresolvedMessage: (objectRef: string) => string
): {
nodes: Array<DiagramNode & { object?: TObject }>;
missingObjects: string[];
warnings: ValidationWarning[];
} {
const nodes: Array<DiagramNode & { object?: TObject }> = [];
const missingObjects: string[] = [];
const warnings: ValidationWarning[] = [];
const seenKeys = new Set<string>();
const seenMissingRefs = new Set<string>();
const duplicateCounts = new Map<string, { displayRef: string; count: number }>();
for (const objectRef of diagram.objectRefs) {
const object = resolveObject(objectRef);
const canonicalKey = buildCanonicalKey(object, objectRef);
if (seenKeys.has(canonicalKey)) {
const existing = duplicateCounts.get(canonicalKey);
if (existing) {
existing.count += 1;
} else {
duplicateCounts.set(canonicalKey, {
displayRef: objectRef,
count: 2
});
}
continue;
}
seenKeys.add(canonicalKey);
if (!object && !seenMissingRefs.has(objectRef)) {
seenMissingRefs.add(objectRef);
missingObjects.push(objectRef);
warnings.push({
code: "unresolved-reference",
message: buildUnresolvedMessage(objectRef),
severity: "warning",
path: diagram.path,
field: "objectRefs"
});
}
nodes.push({
id: buildResolvedId(object, objectRef),
ref: objectRef,
object
});
}
if (duplicateCounts.size > 0) {
const summary = Array.from(duplicateCounts.values())
.map((entry) => `${entry.displayRef} x${entry.count}`)
.join(", ");
warnings.push({
code: "invalid-structure",
message: `Duplicate object refs were merged: ${summary}`,
severity: "info",
path: diagram.path,
field: "objectRefs"
});
}
return {
nodes,
missingObjects,
warnings
};
}
@ -244,13 +294,6 @@ function resolveClassDiagramEdgesFromObjects(
const sourceId = getObjectId(sourceObject);
const targetId = getObjectId(targetObject);
if (!presentObjectIds.has(sourceId) || !presentObjectIds.has(targetId)) {
warnings.push({
code: "unresolved-reference",
message: `class relation "${relation.id ?? relationKey}" is outside diagram scope`,
severity: "info",
path: diagram.path,
field: "relations"
});
continue;
}
@ -355,13 +398,6 @@ function resolveErEdges(
}
if (!presentEntityIds.has(targetEntity.id)) {
warnings.push({
code: "unresolved-reference",
message: `ER relation "${relation.id ?? relationId}" target is outside diagram scope`,
severity: "info",
path: diagram.path,
field: "relations"
});
continue;
}

View file

@ -0,0 +1,720 @@
import {
FuzzySuggestModal,
MarkdownView,
Notice,
type App,
type Editor,
type EditorPosition,
type FuzzyMatch,
type TFile
} from "obsidian";
import { normalizeReferenceTarget, resolveErEntityReference } from "../core/reference-resolver";
import type { ModelingVaultIndex } from "../core/vault-index";
import { parseFrontmatter } from "../parsers/frontmatter-parser";
import { parseErEntityFile } from "../parsers/er-entity-parser";
const NO_COMPLETION_NOTICE =
"No Model Weave completion is available at the current cursor position.";
const MARKDOWN_ONLY_NOTICE =
"Model Weave completion is available only in Markdown editors.";
const TARGET_TABLE_NOT_RESOLVED_NOTICE =
"Target table is not resolved for the current relation block.";
const CLASS_RELATION_KIND_OPTIONS = [
"association",
"dependency",
"inheritance",
"implementation",
"aggregation",
"composition"
] as const;
type CompletionKind =
| "er-target-table"
| "er-local-column"
| "er-target-column"
| "er-diagram-object"
| "class-diagram-object"
| "class-relation-to"
| "class-relation-kind";
interface CompletionSuggestion {
label: string;
insertText: string;
resolveKey?: string;
kind?: "er_entity" | "class" | "column" | "kind";
detail?: string;
}
interface CompletionRequest {
kind: CompletionKind;
replaceFrom: EditorPosition;
replaceTo: EditorPosition;
suggestions: CompletionSuggestion[];
placeholder: string;
initialQuery?: string;
tableColumnIndex?: number;
}
interface TableCellContext {
columnIndex: number;
replaceFrom: EditorPosition;
replaceTo: EditorPosition;
}
export function openModelWeaveCompletion(
app: App,
getIndex: () => ModelingVaultIndex | null
): void {
const activeView = app.workspace.getActiveViewOfType(MarkdownView);
const file = activeView?.file ?? null;
const editor = activeView?.editor;
if (!file || file.extension !== "md" || !editor) {
new Notice(MARKDOWN_ONLY_NOTICE);
return;
}
const request = resolveCompletionRequest(file, editor, getIndex());
if ("notice" in request) {
new Notice(request.notice);
return;
}
if (request.suggestions.length === 0) {
new Notice(NO_COMPLETION_NOTICE);
return;
}
const modal = new ModelWeaveCompletionModal(app, editor, request);
modal.open();
modal.applyInitialQuery();
}
class ModelWeaveCompletionModal extends FuzzySuggestModal<CompletionSuggestion> {
constructor(
app: App,
private readonly editor: Editor,
private readonly request: CompletionRequest
) {
super(app);
this.setPlaceholder(request.placeholder);
this.emptyStateText = "No matching Model Weave candidates.";
}
getItems(): CompletionSuggestion[] {
return this.request.suggestions;
}
getItemText(item: CompletionSuggestion): string {
return [item.label, item.insertText, item.resolveKey, item.detail]
.filter((value): value is string => Boolean(value))
.join(" ");
}
renderSuggestion(item: FuzzyMatch<CompletionSuggestion>, el: HTMLElement): void {
const suggestion = item.item;
el.createDiv({ text: suggestion.label });
if (suggestion.detail) {
const detail = el.createDiv({ text: suggestion.detail });
detail.style.fontSize = "12px";
detail.style.color = "var(--text-muted)";
}
}
onChooseItem(item: CompletionSuggestion): void {
const liveEditor =
this.app.workspace.getActiveViewOfType(MarkdownView)?.editor ?? this.editor;
const cursor = replaceSuggestionText(liveEditor, this.request, item.insertText);
liveEditor.setCursor(cursor);
}
applyInitialQuery(): void {
if (!this.request.initialQuery) {
return;
}
this.inputEl.value = this.request.initialQuery;
this.inputEl.dispatchEvent(new Event("input"));
}
}
function resolveCompletionRequest(
file: TFile,
editor: Editor,
index: ModelingVaultIndex | null
): CompletionRequest | { notice: string } {
const content = editor.getValue();
const type = getFrontmatterType(content);
if (!type) {
return { notice: NO_COMPLETION_NOTICE };
}
const cursor = editor.getCursor();
const lines = content.split(/\r?\n/);
const line = lines[cursor.line] ?? "";
if (type === "er_entity") {
const targetTableRequest = getTargetTableCompletion(cursor, line, index);
if (targetTableRequest) {
return targetTableRequest;
}
const mappingRequest = getErMappingCompletion(file, content, lines, cursor, line, index);
if (mappingRequest) {
return mappingRequest;
}
}
if (type === "er_diagram" || type === "class_diagram") {
const request = getDiagramObjectsRefCompletion(lines, cursor, line, type, index);
if (request) {
return request;
}
}
if (type === "class") {
const request = getClassRelationsCompletion(lines, cursor, line, index);
if (request) {
return request;
}
}
return { notice: NO_COMPLETION_NOTICE };
}
function getTargetTableCompletion(
cursor: EditorPosition,
line: string,
index: ModelingVaultIndex | null
): CompletionRequest | null {
const match = line.match(/^(\s*-\s*target_table\s*:\s*)(.*)$/);
if (!match) {
return null;
}
const prefixLength = match[1].length;
if (cursor.ch < prefixLength || !index) {
return null;
}
const suggestions = Object.values(index.erEntitiesById)
.sort((left, right) => left.physicalName.localeCompare(right.physicalName))
.map((entity) => toErEntitySuggestion(entity));
return {
kind: "er-target-table",
replaceFrom: { line: cursor.line, ch: prefixLength },
replaceTo: { line: cursor.line, ch: line.length },
suggestions,
placeholder: "Complete ER target_table",
initialQuery: normalizeCompletionQuery(line.slice(prefixLength))
};
}
function getErMappingCompletion(
file: TFile,
content: string,
lines: string[],
cursor: EditorPosition,
line: string,
index: ModelingVaultIndex | null
): CompletionRequest | { notice: string } | null {
if (!line.trim().startsWith("|")) {
return null;
}
if (getSectionNameAtLine(lines, cursor.line) !== "Relations") {
return null;
}
const tableHeaderIndex = findNearestLine(lines, cursor.line, (candidate) => {
const row = parseMarkdownTableRow(candidate);
return (
row !== null &&
row.length >= 3 &&
row[0] === "local_column" &&
row[1] === "target_column" &&
row[2] === "notes"
);
});
if (tableHeaderIndex < 0 || cursor.line <= tableHeaderIndex + 1) {
return null;
}
const cell = getTableCellContext(line, cursor.line, cursor.ch);
if (!cell || cell.columnIndex > 1) {
return null;
}
const currentEntity = parseErEntityFile(content, file.path).file;
if (!currentEntity) {
return { notice: NO_COMPLETION_NOTICE };
}
if (cell.columnIndex === 0) {
return {
kind: "er-local-column",
replaceFrom: cell.replaceFrom,
replaceTo: cell.replaceTo,
suggestions: currentEntity.columns
.map((column) => column.physicalName)
.filter((value): value is string => Boolean(value))
.filter(onlyUnique)
.sort()
.map((physicalName) => ({
label: physicalName,
insertText: physicalName,
resolveKey: physicalName,
kind: "column"
})),
placeholder: "Complete local column",
initialQuery: extractLineText(line, cell.replaceFrom.ch, cell.replaceTo.ch)
};
}
const targetTableRef = findCurrentRelationTargetTable(lines, cursor.line);
if (!targetTableRef || !index) {
return { notice: TARGET_TABLE_NOT_RESOLVED_NOTICE };
}
const targetEntity = resolveErEntityReference(targetTableRef, index);
if (!targetEntity) {
return { notice: TARGET_TABLE_NOT_RESOLVED_NOTICE };
}
return {
kind: "er-target-column",
replaceFrom: cell.replaceFrom,
replaceTo: cell.replaceTo,
suggestions: targetEntity.columns
.map((column) => column.physicalName)
.filter((value): value is string => Boolean(value))
.filter(onlyUnique)
.sort()
.map((physicalName) => ({
label: physicalName,
insertText: physicalName,
resolveKey: physicalName,
kind: "column"
})),
placeholder: "Complete target column",
initialQuery: extractLineText(line, cell.replaceFrom.ch, cell.replaceTo.ch)
};
}
function getDiagramObjectsRefCompletion(
lines: string[],
cursor: EditorPosition,
line: string,
type: "er_diagram" | "class_diagram",
index: ModelingVaultIndex | null
): CompletionRequest | null {
if (!line.trim().startsWith("|") || !index) {
return null;
}
if (getSectionNameAtLine(lines, cursor.line) !== "Objects") {
return null;
}
const tableHeaderIndex = findNearestLine(lines, cursor.line, (candidate) => {
const row = parseMarkdownTableRow(candidate);
return row !== null && row.length >= 2 && row[0] === "ref" && row[1] === "notes";
});
if (tableHeaderIndex < 0 || cursor.line <= tableHeaderIndex + 1) {
return null;
}
if (isMarkdownTableSeparator(line)) {
return null;
}
const cell = getTableCellContext(line, cursor.line, cursor.ch);
if (!cell || cell.columnIndex !== 0) {
return null;
}
if (type === "er_diagram") {
return {
kind: "er-diagram-object",
replaceFrom: cell.replaceFrom,
replaceTo: cell.replaceTo,
suggestions: Object.values(index.erEntitiesById)
.sort((left, right) => left.physicalName.localeCompare(right.physicalName))
.map((entity) => toErEntitySuggestion(entity)),
placeholder: "Complete ER diagram object",
initialQuery: normalizeCompletionQuery(
extractLineText(line, cell.replaceFrom.ch, cell.replaceTo.ch)
),
tableColumnIndex: 0
};
}
return {
kind: "class-diagram-object",
replaceFrom: cell.replaceFrom,
replaceTo: cell.replaceTo,
suggestions: Object.values(index.objectsById)
.sort((left, right) => getObjectId(left).localeCompare(getObjectId(right)))
.map((object) => toClassObjectSuggestion(object)),
placeholder: "Complete class diagram object",
initialQuery: normalizeCompletionQuery(
extractLineText(line, cell.replaceFrom.ch, cell.replaceTo.ch)
),
tableColumnIndex: 0
};
}
function getClassRelationsCompletion(
lines: string[],
cursor: EditorPosition,
line: string,
index: ModelingVaultIndex | null
): CompletionRequest | null {
if (!line.trim().startsWith("|") || !index) {
return null;
}
if (getSectionNameAtLine(lines, cursor.line) !== "Relations") {
return null;
}
const tableHeaderIndex = findNearestLine(lines, cursor.line, (candidate) => {
const row = parseMarkdownTableRow(candidate);
return (
row !== null &&
row.length >= 7 &&
row[0] === "id" &&
row[1] === "to" &&
row[2] === "kind"
);
});
if (tableHeaderIndex < 0 || cursor.line <= tableHeaderIndex + 1) {
return null;
}
if (isMarkdownTableSeparator(line)) {
return null;
}
const cell = getTableCellContext(line, cursor.line, cursor.ch);
if (!cell) {
return null;
}
if (cell.columnIndex === 1) {
const suggestions = Object.values(index.objectsById)
.sort((left, right) => getObjectId(left).localeCompare(getObjectId(right)))
.map((object) => ({
label: `${getObjectId(object)}${object.name}`,
insertText: getObjectId(object),
resolveKey: getObjectId(object),
detail: object.kind,
kind: "class" as const
}));
return {
kind: "class-relation-to",
replaceFrom: cell.replaceFrom,
replaceTo: cell.replaceTo,
suggestions,
placeholder: "Complete class relation to",
initialQuery: extractLineText(line, cell.replaceFrom.ch, cell.replaceTo.ch),
tableColumnIndex: 1
};
}
if (cell.columnIndex === 2) {
return {
kind: "class-relation-kind",
replaceFrom: cell.replaceFrom,
replaceTo: cell.replaceTo,
suggestions: CLASS_RELATION_KIND_OPTIONS.map((kind) => ({
label: kind,
insertText: kind,
resolveKey: kind,
kind: "kind"
})),
placeholder: "Complete class relation kind",
initialQuery: extractLineText(line, cell.replaceFrom.ch, cell.replaceTo.ch),
tableColumnIndex: 2
};
}
return null;
}
function getFrontmatterType(content: string): string | undefined {
const parsed = parseFrontmatter(content);
const type = parsed.file.frontmatter?.type;
return typeof type === "string" && type.trim() ? type.trim() : undefined;
}
function getSectionNameAtLine(lines: string[], lineIndex: number): string | null {
for (let index = lineIndex; index >= 0; index -= 1) {
const trimmed = lines[index].trim();
const match = trimmed.match(/^##\s+(.+)$/);
if (match) {
return match[1].trim();
}
}
return null;
}
function findNearestLine(
lines: string[],
startIndex: number,
predicate: (line: string) => boolean
): number {
for (let index = startIndex; index >= 0; index -= 1) {
const candidate = lines[index] ?? "";
if (predicate(candidate)) {
return index;
}
if (index !== startIndex && /^##\s+/.test(candidate.trim())) {
break;
}
}
return -1;
}
function findCurrentRelationTargetTable(lines: string[], startIndex: number): string | null {
const blockStartIndex = findCurrentRelationBlockStart(lines, startIndex);
if (blockStartIndex < 0) {
return null;
}
for (let index = blockStartIndex + 1; index <= startIndex; index += 1) {
const trimmed = (lines[index] ?? "").trim();
if (/^###\s+/.test(trimmed)) {
break;
}
const match = trimmed.match(/^-\s*target_table\s*:\s*(.*)$/);
if (match) {
const value = match[1].trim();
return value ? normalizeReferenceTarget(value) : null;
}
}
return null;
}
function findCurrentRelationBlockStart(lines: string[], startIndex: number): number {
for (let index = startIndex; index >= 0; index -= 1) {
const trimmed = (lines[index] ?? "").trim();
if (/^###\s+/.test(trimmed)) {
return index;
}
if (/^##\s+/.test(trimmed) && index !== startIndex) {
break;
}
}
return -1;
}
function getTableCellContext(
line: string,
lineNumber: number,
cursorCh: number
): TableCellContext | null {
const pipeIndexes: number[] = [];
for (let index = 0; index < line.length; index += 1) {
if (line[index] === "|") {
pipeIndexes.push(index);
}
}
if (pipeIndexes.length > 0 && pipeIndexes[pipeIndexes.length - 1] < line.length) {
pipeIndexes.push(line.length);
}
if (pipeIndexes.length < 2) {
return null;
}
for (let columnIndex = 0; columnIndex < pipeIndexes.length - 1; columnIndex += 1) {
const rawStart = pipeIndexes[columnIndex] + 1;
const rawEnd = pipeIndexes[columnIndex + 1];
const inCell =
(cursorCh >= rawStart && cursorCh < rawEnd) ||
cursorCh === rawEnd ||
(cursorCh === pipeIndexes[columnIndex] && columnIndex > 0);
if (!inCell) {
continue;
}
let contentStart = rawStart;
let contentEnd = rawEnd;
while (contentStart < rawEnd && /\s/.test(line[contentStart] ?? "")) {
contentStart += 1;
}
while (contentEnd > rawStart && /\s/.test(line[contentEnd - 1] ?? "")) {
contentEnd -= 1;
}
if (contentStart > contentEnd) {
contentStart = rawStart;
contentEnd = rawStart;
}
return {
columnIndex,
replaceFrom: { line: lineNumber, ch: contentStart },
replaceTo: { line: lineNumber, ch: contentEnd }
};
}
return null;
}
function parseMarkdownTableRow(line: string): string[] | null {
const trimmed = line.trim();
if (!trimmed.startsWith("|") || !trimmed.endsWith("|")) {
return null;
}
return trimmed
.slice(1, -1)
.split("|")
.map((cell) => cell.trim());
}
function isMarkdownTableSeparator(line: string): boolean {
const cells = parseMarkdownTableRow(line);
if (!cells || cells.length === 0) {
return false;
}
return cells.every((cell) => /^:?-{3,}:?$/.test(cell));
}
function getObjectId(object: { frontmatter: Record<string, unknown>; name: string }): string {
const explicitId = object.frontmatter.id;
if (typeof explicitId === "string" && explicitId.trim()) {
return explicitId.trim();
}
return object.name;
}
function onlyUnique(value: string, index: number, array: string[]): boolean {
return array.indexOf(value) === index;
}
function toErEntitySuggestion(entity: {
id: string;
logicalName: string;
physicalName: string;
path: string;
}): CompletionSuggestion {
const linkTarget = toFileLinkTarget(entity.path);
return {
label: `${entity.logicalName} / ${entity.physicalName}`,
insertText: `[[${linkTarget}]]`,
resolveKey: linkTarget,
detail: `${entity.id} · ${entity.path}`,
kind: "er_entity"
};
}
function toClassObjectSuggestion(object: {
frontmatter: Record<string, unknown>;
name: string;
kind: string;
path: string;
}): CompletionSuggestion {
const linkTarget = toFileLinkTarget(object.path);
return {
label: `${getObjectId(object)} / ${object.name}`,
insertText: `[[${linkTarget}]]`,
resolveKey: linkTarget,
detail: object.kind,
kind: "class"
};
}
function toFileLinkTarget(path: string): string {
return path.replace(/\\/g, "/").replace(/\.md$/i, "");
}
function normalizeCompletionQuery(value: string): string {
const trimmed = value.trim();
const withoutOpening = trimmed.startsWith("[[") ? trimmed.slice(2) : trimmed;
const withoutClosing = withoutOpening.endsWith("]]")
? withoutOpening.slice(0, -2)
: withoutOpening;
return withoutClosing.split("|", 1)[0].trim();
}
function extractLineText(line: string, from: number, to: number): string {
return line.slice(from, to).trim();
}
function replaceSuggestionText(
editor: Editor,
request: CompletionRequest,
insertText: string
): EditorPosition {
if (
request.tableColumnIndex !== undefined &&
(
request.kind === "er-diagram-object" ||
request.kind === "class-diagram-object" ||
request.kind === "class-relation-to" ||
request.kind === "class-relation-kind"
)
) {
return replaceMarkdownTableCell(editor, request, insertText);
}
editor.replaceRange(insertText, request.replaceFrom, request.replaceTo);
return {
line: request.replaceFrom.line,
ch: request.replaceFrom.ch + insertText.length
};
}
function replaceMarkdownTableCell(
editor: Editor,
request: CompletionRequest,
insertText: string
): EditorPosition {
const lineNumber = request.replaceFrom.line;
const line = editor.getLine(lineNumber);
const pipeIndexes: number[] = [];
for (let index = 0; index < line.length; index += 1) {
if (line[index] === "|") {
pipeIndexes.push(index);
}
}
const columnIndex = request.tableColumnIndex ?? 0;
if (pipeIndexes.length < columnIndex + 2) {
editor.replaceRange(insertText, request.replaceFrom, request.replaceTo);
return {
line: request.replaceFrom.line,
ch: request.replaceFrom.ch + insertText.length
};
}
const rawStart = pipeIndexes[columnIndex] + 1;
const rawEnd = pipeIndexes[columnIndex + 1];
const nextLine = `${line.slice(0, rawStart)} ${insertText} ${line.slice(rawEnd)}`;
editor.replaceRange(
nextLine,
{ line: lineNumber, ch: 0 },
{ line: lineNumber, ch: line.length }
);
return {
line: lineNumber,
ch: rawStart + 1 + insertText.length
};
}

View file

@ -1,7 +1,17 @@
import { Notice, Plugin, TFile, WorkspaceLeaf } from "obsidian";
import { MarkdownView, Notice, Plugin, TFile, WorkspaceLeaf } from "obsidian";
import { resolveObjectContext } from "./core/object-context-resolver";
import {
buildCurrentDiagramDiagnostics,
buildCurrentObjectDiagnostics
} from "./core/current-file-diagnostics";
import { resolveDiagramRelations } from "./core/relation-resolver";
import { detectFileType } from "./core/schema-detector";
import { openModelWeaveCompletion } from "./editor/model-weave-editor-suggest";
import {
MODEL_WEAVE_TEMPLATES,
MODEL_WEAVE_RELATION_TEMPLATES,
type ModelWeaveTemplateKey
} from "./templates/model-weave-templates";
import { buildVaultIndex, type ModelingVaultIndex } from "./core/vault-index";
import type { ValidationWarning } from "./types/models";
import { openModelObjectNote } from "./utils/model-navigation";
@ -23,6 +33,12 @@ const DEPRECATED_ER_RELATION_MESSAGE =
"This file format is not supported. Use er_entity with ## Relations instead of the legacy er_relation format.";
const DEPRECATED_DIAGRAM_MESSAGE =
"This file format is not supported. Migrate legacy diagram_v1 files to class_diagram or er_diagram.";
const MARKDOWN_ONLY_NOTICE =
"Template insertion is available only for Markdown files.";
const NON_EMPTY_FILE_NOTICE =
"Current file is not empty. Template insertion is available only for empty files.";
const ER_RELATION_TYPE_NOTICE =
"ER relation block insertion is available only for er_entity files.";
export default class ModelingToolPlugin extends Plugin {
private index: ModelingVaultIndex | null = null;
@ -52,6 +68,54 @@ export default class ModelingToolPlugin extends Plugin {
}
});
this.addCommand({
id: "insert-class-template",
name: "Insert Class Template",
callback: async () => {
await this.insertTemplateIntoActiveFile("class");
}
});
this.addCommand({
id: "insert-class-diagram-template",
name: "Insert Class Diagram Template",
callback: async () => {
await this.insertTemplateIntoActiveFile("classDiagram");
}
});
this.addCommand({
id: "insert-er-entity-template",
name: "Insert ER Entity Template",
callback: async () => {
await this.insertTemplateIntoActiveFile("erEntity");
}
});
this.addCommand({
id: "insert-er-diagram-template",
name: "Insert ER Diagram Template",
callback: async () => {
await this.insertTemplateIntoActiveFile("erDiagram");
}
});
this.addCommand({
id: "insert-er-relation-block",
name: "Insert ER Relation Block",
callback: async () => {
await this.insertErRelationBlock();
}
});
this.addCommand({
id: "complete-current-field",
name: "Complete Current Field",
callback: () => {
openModelWeaveCompletion(this.app, () => this.index);
}
});
this.registerEvent(
this.app.workspace.on("file-open", async () => {
await this.syncPreviewToActiveFile(false, "external-file-open");
@ -135,6 +199,151 @@ export default class ModelingToolPlugin extends Plugin {
await this.showPreviewForFile(file, undefined, true, "external-file-open");
}
private async insertTemplateIntoActiveFile(
templateKey: ModelWeaveTemplateKey
): Promise<void> {
const target = await this.getActiveMarkdownTarget();
if (!target) {
new Notice(MARKDOWN_ONLY_NOTICE);
return;
}
const currentContent = target.getContent();
if (currentContent.trim().length > 0) {
new Notice(NON_EMPTY_FILE_NOTICE);
return;
}
await target.setContent(MODEL_WEAVE_TEMPLATES[templateKey]);
}
private async insertErRelationBlock(): Promise<void> {
const target = await this.getActiveMarkdownTarget();
if (!target) {
new Notice(MARKDOWN_ONLY_NOTICE);
return;
}
if (this.getActiveFileType(target.file) !== "er_entity") {
new Notice(ER_RELATION_TYPE_NOTICE);
return;
}
const lineEnding = this.detectLineEnding(target.getContent());
const block = MODEL_WEAVE_RELATION_TEMPLATES.erRelationBlock.join(lineEnding);
const nextContent = this.appendErRelationBlock(target.getContent(), block, lineEnding);
await target.setContent(nextContent);
}
private async getActiveMarkdownTarget():
Promise<
| {
file: TFile;
getContent: () => string;
setContent: (content: string) => Promise<void>;
}
| null
> {
const file = this.app.workspace.getActiveFile();
if (!file || file.extension !== "md") {
return null;
}
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (activeView?.file?.path === file.path) {
return {
file,
getContent: () => activeView.editor.getValue(),
setContent: async (content: string) => {
activeView.editor.setValue(content);
await this.app.vault.modify(file, content);
}
};
}
const cachedContent = await this.app.vault.cachedRead(file);
return {
file,
getContent: () => cachedContent,
setContent: async (content: string) => {
await this.app.vault.modify(file, content);
}
};
}
private getActiveFileType(file: TFile): string | undefined {
const frontmatterType = this.app.metadataCache.getFileCache(file)?.frontmatter?.type;
if (typeof frontmatterType === "string" && frontmatterType.trim()) {
return frontmatterType.trim();
}
return undefined;
}
private detectLineEnding(content: string): string {
return content.includes("\r\n") ? "\r\n" : "\n";
}
private appendErRelationBlock(
content: string,
block: string,
lineEnding: string
): string {
const section = this.findSection(content, "Relations");
if (section) {
const after = content.slice(section.end);
const sectionText = content.slice(section.start, section.end).replace(/\s*$/u, "");
const updatedSection = `${sectionText}${lineEnding}${lineEnding}${block}${lineEnding}`;
return `${content.slice(0, section.start)}${updatedSection}${after.replace(/^\s*/u, "")}`;
}
const relationsSection = `## Relations${lineEnding}${lineEnding}${block}${lineEnding}`;
return this.insertSectionBeforeNotesOrEnd(content, relationsSection, lineEnding);
}
private insertSectionBeforeNotesOrEnd(
content: string,
sectionContent: string,
lineEnding: string
): string {
const notesSection = this.findSection(content, "Notes");
const trimmedSection = sectionContent.replace(/\s*$/u, "");
if (notesSection) {
const before = content.slice(0, notesSection.start).replace(/\s*$/u, "");
const after = content.slice(notesSection.start).replace(/^\s*/u, "");
return `${before}${lineEnding}${lineEnding}${trimmedSection}${lineEnding}${lineEnding}${after}`;
}
const trimmedContent = content.replace(/\s*$/u, "");
if (!trimmedContent) {
return `${trimmedSection}${lineEnding}`;
}
return `${trimmedContent}${lineEnding}${lineEnding}${trimmedSection}${lineEnding}`;
}
private findSection(
content: string,
sectionName: string
): { start: number; end: number } | null {
const headingRegex = new RegExp(`^##\\s+${sectionName}\\s*$`, "m");
const headingMatch = headingRegex.exec(content);
if (!headingMatch || headingMatch.index === undefined) {
return null;
}
const start = headingMatch.index;
const searchStart = start + headingMatch[0].length;
const remainder = content.slice(searchStart);
const nextHeadingMatch = /^##\s+/m.exec(remainder);
const end = nextHeadingMatch && nextHeadingMatch.index !== undefined
? searchStart + nextHeadingMatch.index
: content.length;
return { start, end };
}
private async syncPreviewToActiveFile(
openIfSupported = false,
reason: PreviewUpdateReason = "rerender"
@ -228,11 +437,20 @@ export default class ModelingToolPlugin extends Plugin {
];
if (objectModel) {
const diagnostics = buildCurrentObjectDiagnostics(
objectModel,
this.index,
context,
warnings
);
view.updateContent({
mode: "object",
model: objectModel,
context,
warnings,
warnings: diagnostics,
onOpenDiagnostic: (diagnostic) => {
void this.openDiagnosticLocation(file.path, diagnostic);
},
onOpenObject: (objectId, navigation) => {
void this.openObjectNote(objectId, file.path, navigation);
}
@ -255,12 +473,18 @@ export default class ModelingToolPlugin extends Plugin {
...(this.index.warningsByFilePath[file.path] ?? []),
...(resolved?.warnings ?? [])
];
const diagnostics = resolved
? buildCurrentDiagramDiagnostics(resolved, warnings)
: warnings;
view.updateContent(
resolved
? {
mode: "diagram",
diagram: resolved,
warnings,
warnings: diagnostics,
onOpenDiagnostic: (diagnostic) => {
void this.openDiagnosticLocation(file.path, diagnostic);
},
onOpenObject: (objectId, navigation) => {
void this.openObjectNote(objectId, file.path, navigation);
}
@ -354,6 +578,71 @@ export default class ModelingToolPlugin extends Plugin {
await this.syncPreviewToActiveFile(false, "viewer-node-navigation");
}
private async openDiagnosticLocation(
filePath: string,
diagnostic: ValidationWarning
): Promise<void> {
const targetPath = diagnostic.filePath ?? diagnostic.path ?? filePath;
const abstractFile = this.app.vault.getAbstractFileByPath(targetPath);
if (!(abstractFile instanceof TFile)) {
return;
}
const activeMarkdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
let targetLeaf: WorkspaceLeaf | null =
activeMarkdownView?.file?.path === targetPath
? activeMarkdownView.leaf
: this.findMarkdownLeafForPath(targetPath);
if (!targetLeaf) {
targetLeaf = this.app.workspace.getMostRecentLeaf();
if (targetLeaf && this.isPreviewLeaf(targetLeaf)) {
targetLeaf = this.app.workspace.getLeaf(true);
}
}
if (!targetLeaf) {
return;
}
if ((targetLeaf.view as { file?: TFile | null }).file?.path !== targetPath) {
await targetLeaf.openFile(abstractFile);
}
this.app.workspace.setActiveLeaf(targetLeaf, { focus: true });
const markdownView =
targetLeaf.view instanceof MarkdownView
? targetLeaf.view
: this.app.workspace.getActiveViewOfType(MarkdownView);
const editor = markdownView?.editor;
if (!editor) {
return;
}
const content = editor.getValue();
const targetLine = resolveDiagnosticLine(content, diagnostic);
editor.setCursor({ line: targetLine, ch: 0 });
editor.scrollIntoView(
{
from: { line: targetLine, ch: 0 },
to: { line: targetLine, ch: 0 }
},
true
);
}
private findMarkdownLeafForPath(filePath: string): WorkspaceLeaf | null {
for (const leaf of this.app.workspace.getLeavesOfType("markdown")) {
const viewFile = (leaf.view as { file?: TFile | null }).file ?? null;
if (viewFile?.path === filePath) {
return leaf;
}
}
return null;
}
private async ensurePreviewLeaf(
preferredLeaf?: WorkspaceLeaf,
activate = true
@ -443,3 +732,127 @@ export default class ModelingToolPlugin extends Plugin {
await this.closeDuplicatePreviewLeaves(keepLeaf);
}
}
function resolveDiagnosticLine(content: string, diagnostic: ValidationWarning): number {
if (typeof diagnostic.line === "number" && diagnostic.line >= 0) {
return diagnostic.line;
}
if (typeof diagnostic.fromLine === "number" && diagnostic.fromLine >= 0) {
return diagnostic.fromLine;
}
if (typeof diagnostic.toLine === "number" && diagnostic.toLine >= 0) {
return diagnostic.toLine;
}
const lines = content.split(/\r?\n/);
const frontmatterField = typeof diagnostic.field === "string" ? diagnostic.field : "";
const section = resolveDiagnosticSection(diagnostic);
if (frontmatterField && isFrontmatterField(frontmatterField)) {
const frontmatterLine = findFrontmatterFieldLine(lines, frontmatterField);
if (frontmatterLine >= 0) {
return frontmatterLine;
}
}
const relatedId =
typeof diagnostic.context?.relatedId === "string" ? diagnostic.context.relatedId : null;
if (section === "Relations" && relatedId) {
const relationBlockLine = findLineIndex(lines, (line) => line.trim() === `### ${relatedId}`);
if (relationBlockLine >= 0) {
return relationBlockLine;
}
const relationRowLine = findLineIndex(lines, (line) => line.includes(`| ${relatedId} |`));
if (relationRowLine >= 0) {
return relationRowLine;
}
}
if (section) {
const sectionLine = findLineIndex(lines, (line) => line.trim() === `## ${section}`);
if (sectionLine >= 0) {
return sectionLine;
}
}
return 0;
}
function resolveDiagnosticSection(diagnostic: ValidationWarning): string | null {
if (typeof diagnostic.section === "string" && diagnostic.section.trim()) {
return diagnostic.section.trim();
}
const contextSection =
typeof diagnostic.context?.section === "string" ? diagnostic.context.section : null;
if (contextSection) {
return contextSection;
}
const field = typeof diagnostic.field === "string" ? diagnostic.field : "";
if (field.startsWith("Relations:")) {
return "Relations";
}
const fieldToSection: Record<string, string> = {
objectRefs: "Objects",
relations: "Relations",
relatedObjects: "Relations",
Attributes: "Attributes",
Methods: "Methods",
Relations: "Relations",
Objects: "Objects",
Columns: "Columns",
Indexes: "Indexes",
Notes: "Notes",
Summary: "Summary",
Overview: "Overview"
};
return fieldToSection[field] ?? null;
}
function isFrontmatterField(field: string): boolean {
return [
"type",
"id",
"name",
"kind",
"logical_name",
"physical_name",
"schema_name",
"dbms",
"package",
"stereotype"
].includes(field);
}
function findFrontmatterFieldLine(lines: string[], field: string): number {
if ((lines[0] ?? "").trim() !== "---") {
return -1;
}
for (let index = 1; index < lines.length; index += 1) {
const trimmed = lines[index].trim();
if (trimmed === "---") {
break;
}
if (trimmed.startsWith(`${field}:`)) {
return index;
}
}
return -1;
}
function findLineIndex(lines: string[], predicate: (line: string) => boolean): number {
for (let index = 0; index < lines.length; index += 1) {
if (predicate(lines[index] ?? "")) {
return index;
}
}
return -1;
}

View file

@ -196,6 +196,10 @@ function parseClassDiagramRelations(
warnings: ValidationWarning[],
path: string
): ClassRelationEdge[] {
if (!hasNonEmptyTableDataRows(lines)) {
return [];
}
const table = parseMarkdownTable(
lines,
[...CLASS_DIAGRAM_RELATION_HEADERS],
@ -241,6 +245,25 @@ function parseClassDiagramRelations(
return relations;
}
function hasNonEmptyTableDataRows(lines: string[] | undefined): boolean {
if (!lines) {
return false;
}
const tableLines = lines.map((line) => line.trim()).filter((line) => line.startsWith("|"));
if (tableLines.length <= 2) {
return false;
}
return tableLines.slice(2).some((line) =>
line
.replace(/^\|/, "")
.replace(/\|$/, "")
.split("|")
.some((cell) => cell.trim().length > 0)
);
}
function classRelationToDiagramEdge(relation: ClassRelationEdge): DiagramEdge {
return {
id: relation.id,

View file

@ -36,7 +36,7 @@ const METHOD_TABLE_HEADERS = [
"notes"
] as const;
const RELATION_TABLE_HEADERS = [
const LEGACY_RELATION_TABLE_HEADERS = [
"id",
"from",
"to",
@ -47,6 +47,16 @@ const RELATION_TABLE_HEADERS = [
"notes"
] as const;
const SPEC04_RELATION_TABLE_HEADERS = [
"id",
"to",
"kind",
"label",
"from_multiplicity",
"to_multiplicity",
"notes"
] as const;
export function parseObjectFile(
markdown: string,
path: string
@ -87,7 +97,12 @@ export function parseObjectFile(
const methods = acceptsClassType
? parseMethodTable(sections.Methods, warnings, path)
: parseMethods(sections.Methods, warnings, path);
const relations = parseRelationsTable(sections.Relations, warnings, path);
const relations = parseRelationsTable(
sections.Relations,
warnings,
path,
getClassObjectId(frontmatter, name)
);
if (!name) {
warnings.push(
@ -299,23 +314,20 @@ function parseMethodTable(
function parseRelationsTable(
lines: string[] | undefined,
warnings: ValidationWarning[],
path: string
path: string,
currentClassId: string
): ClassRelationEdge[] {
const table = parseMarkdownTable(
lines,
[...RELATION_TABLE_HEADERS],
path,
"Relations"
);
warnings.push(...table.warnings);
const relations: ClassRelationEdge[] = [];
const table = parseClassRelationsTable(lines, path);
warnings.push(...table.warnings);
for (const row of table.rows) {
const id = getTableValue(row, "id");
const from = normalizeReferenceTarget(getTableValue(row, "from"));
const to = normalizeReferenceTarget(getTableValue(row, "to"));
const kind = getTableValue(row, "kind");
const from = table.format === "legacy"
? normalizeReferenceTarget(getTableValue(row, "from"))
: normalizeReferenceTarget(currentClassId);
if (!id || !from || !to || !kind) {
warnings.push(
@ -329,6 +341,28 @@ function parseRelationsTable(
continue;
}
if (table.format === "legacy") {
if (from === normalizeReferenceTarget(currentClassId)) {
warnings.push(
createInfoWarning(
"legacy-class-relation-format",
`Legacy class relation format with explicit "from" was accepted for relation "${id}".`,
path,
"Relations"
)
);
} else {
warnings.push(
createWarning(
"legacy-class-relation-from-mismatch",
`Legacy class relation "from" does not match the current class id for relation "${id}".`,
path,
"Relations"
)
);
}
}
relations.push({
domain: "class",
id,
@ -347,6 +381,104 @@ function parseRelationsTable(
return relations;
}
function parseClassRelationsTable(
lines: string[] | undefined,
path: string
): {
rows: Array<Record<string, string>>;
warnings: ValidationWarning[];
format: "spec04" | "legacy";
} {
if (!lines) {
return { rows: [], warnings: [], format: "spec04" };
}
const normalizedLines = lines
.map((line) => line.trim())
.filter((line) => line.startsWith("|"));
if (normalizedLines.length < 2) {
return {
rows: [],
warnings: normalizedLines.length === 0
? []
: [
createWarning(
"invalid-table-row",
'table in section "Relations" is incomplete',
path,
"Relations"
)
],
format: "spec04"
};
}
const headers = splitTableRow(normalizedLines[0]);
const format = sameHeaders(headers, [...SPEC04_RELATION_TABLE_HEADERS])
? "spec04"
: sameHeaders(headers, [...LEGACY_RELATION_TABLE_HEADERS])
? "legacy"
: "spec04";
const warnings: ValidationWarning[] = [];
if (
!sameHeaders(headers, [...SPEC04_RELATION_TABLE_HEADERS]) &&
!sameHeaders(headers, [...LEGACY_RELATION_TABLE_HEADERS])
) {
warnings.push(
createWarning(
"invalid-table-column",
'table columns in section "Relations" do not match supported class relation headers',
path,
"Relations"
)
);
}
const rows: Array<Record<string, string>> = [];
for (const rowLine of normalizedLines.slice(2)) {
const values = splitTableRow(rowLine);
if (values.length !== headers.length) {
warnings.push(
createWarning(
"invalid-table-row",
`table row in section "Relations" has ${values.length} columns, expected ${headers.length}`,
path,
"Relations"
)
);
continue;
}
const row: Record<string, string> = {};
for (const [index, header] of headers.entries()) {
row[header] = values[index] ?? "";
}
rows.push(row);
}
return { rows, warnings, format };
}
function splitTableRow(line: string): string[] {
return line
.trim()
.replace(/^\|/, "")
.replace(/\|$/, "")
.split("|")
.map((cell) => cell.trim());
}
function sameHeaders(actual: string[], expected: string[]): boolean {
if (actual.length !== expected.length) {
return false;
}
return actual.every((header, index) => header === expected[index]);
}
function getTableValue(row: Record<string, string>, key: string): string {
return row[key]?.trim() ?? "";
}
@ -414,6 +546,10 @@ function normalizeObjectKind(kind?: string): ObjectKind {
return "class";
}
function getClassObjectId(frontmatter: GenericFrontmatter, name?: string): string {
return getString(frontmatter, "id") ?? name ?? "unknown";
}
function isCoreObjectKind(kind: string): kind is ObjectKind {
return (CORE_OBJECT_KINDS as readonly string[]).includes(kind);
}
@ -431,7 +567,8 @@ function createWarning(
| "invalid-method-line"
| "invalid-table-column"
| "invalid-table-row"
| "unknown-schema",
| "unknown-schema"
| "legacy-class-relation-from-mismatch",
message: string,
path: string,
field?: string
@ -446,7 +583,7 @@ function createWarning(
}
function createInfoWarning(
code: "reserved-kind-used",
code: "reserved-kind-used" | "legacy-class-relation-format",
message: string,
path: string,
field?: string

View file

@ -586,7 +586,7 @@ function createConnectionsTable(diagram: ResolvedDiagram): HTMLElement {
section.open = false;
const summary = document.createElement("summary");
summary.textContent = `Resolved relations (${diagram.edges.length})`;
summary.textContent = `Displayed relations (${diagram.edges.length})`;
summary.style.cursor = "pointer";
summary.style.fontWeight = "600";
summary.style.padding = "4px 0";
@ -594,7 +594,7 @@ function createConnectionsTable(diagram: ResolvedDiagram): HTMLElement {
if (diagram.edges.length === 0) {
const empty = document.createElement("p");
empty.textContent = "表示対象の relation はありません。";
empty.textContent = "No relations are currently used for rendering.";
empty.style.margin = "8px 0 0";
empty.style.color = "var(--text-muted)";
section.appendChild(empty);

View file

@ -51,15 +51,6 @@ export function renderObjectContext(
titleRow.appendChild(count);
root.appendChild(titleRow);
if (context.relatedObjects.length === 0) {
const empty = document.createElement("p");
empty.textContent = "直接関係するオブジェクトはありません。";
empty.style.marginTop = "10px";
empty.style.color = "var(--text-muted)";
root.appendChild(empty);
return root;
}
root.appendChild(createMiniGraph(context, options));
root.appendChild(createRelatedList(context, options));
return root;
@ -119,6 +110,15 @@ function createRelatedList(
tableWrap.style.maxHeight = "180px";
tableWrap.style.overflow = "auto";
if (sortedEntries.length === 0) {
const empty = document.createElement("p");
empty.textContent = "直接関係するオブジェクトはありません。";
empty.style.margin = "8px 0 0";
empty.style.color = "var(--text-muted)";
details.appendChild(empty);
return details;
}
const table = document.createElement("table");
table.style.width = "100%";
table.style.borderCollapse = "collapse";

View file

@ -0,0 +1,146 @@
export const MODEL_WEAVE_TEMPLATES = {
class: `---
type: class
id: CLS-
name:
kind: class
package:
stereotype:
tags:
- Class
---
#
## Summary
## Attributes
| name | type | visibility | static | notes |
|---|---|---|---|---|
## Methods
| name | parameters | returns | visibility | static | notes |
|---|---|---|---|---|---|
## Relations
| id | to | kind | label | from_multiplicity | to_multiplicity | notes |
|---|---|---|---|---|---|---|
## Notes
- `,
classDiagram: `---
type: class_diagram
id: CLASSD-
name:
tags:
- Class
- Diagram
---
#
## Summary
## Objects
| ref | notes |
|---|---|
## Relations
| id | from | to | kind | label | from_multiplicity | to_multiplicity | notes |
|---|---|---|---|---|---|---|---|
## Notes
- `,
erEntity: `---
type: er_entity
id: ENT-
logical_name:
physical_name:
schema_name:
dbms:
tags:
- ER
- Entity
---
# /
## Overview
- purpose:
- notes:
## Columns
| logical_name | physical_name | data_type | length | scale | not_null | pk | encrypted | default_value | notes |
|---|---|---|---:|---:|---|---|---|---|---|
## Indexes
| index_name | index_type | unique | columns | notes |
|---|---|---|---|---|
## Relations
### REL-
- target_table: [[]]
- kind: fk
- cardinality:
- notes:
| local_column | target_column | notes |
|---|---|---|
## Notes
- `,
erDiagram: `---
type: er_diagram
id: ERD-
name:
tags:
- ER
- Diagram
---
#
## Summary
## Objects
| ref | notes |
|---|---|
## Notes
- `
} as const;
export type ModelWeaveTemplateKey = keyof typeof MODEL_WEAVE_TEMPLATES;
export const MODEL_WEAVE_RELATION_TEMPLATES = {
erRelationBlock: [
"### REL-",
"- target_table: [[]]",
"- kind: fk",
"- cardinality:",
"- notes:",
"",
"| local_column | target_column | notes |",
"|---|---|---|"
]
} as const;

View file

@ -230,7 +230,11 @@ export interface ValidationWarning {
message: string;
severity: ValidationWarningSeverity;
path?: string;
filePath?: string;
line?: number;
fromLine?: number;
toLine?: number;
section?: string;
field?: string;
context?: Record<string, unknown>;
}

View file

@ -1,6 +1,7 @@
export const VALIDATION_WARNING_SEVERITIES = [
"info",
"warning"
"warning",
"error"
] as const;
export const VALIDATION_WARNING_CODES = [
@ -25,7 +26,9 @@ export const VALIDATION_WARNING_CODES = [
"invalid-object-ref",
"invalid-table-column",
"invalid-table-row",
"invalid-numeric-value"
"invalid-numeric-value",
"legacy-class-relation-format",
"legacy-class-relation-from-mismatch"
] as const;
export type ValidationWarningSeverity =

View file

@ -36,6 +36,7 @@ type PreviewState =
model: ObjectModel | ErEntity;
context: ResolvedObjectContext | null;
warnings: ValidationWarning[];
onOpenDiagnostic?: ((diagnostic: ValidationWarning) => void) | null;
onOpenObject?:
| ((objectId: string, navigation?: { openInNewLeaf?: boolean }) => void)
| null;
@ -49,6 +50,7 @@ type PreviewState =
mode: "diagram";
diagram: ResolvedDiagram;
warnings: ValidationWarning[];
onOpenDiagnostic?: ((diagnostic: ValidationWarning) => void) | null;
onOpenObject?:
| ((objectId: string, navigation?: { openInNewLeaf?: boolean }) => void)
| null;
@ -139,16 +141,26 @@ export class ModelingPreviewView extends ItemView {
private renderCurrentState(): void {
this.clearView();
renderWarningBar(this.contentEl, this.state.warnings);
switch (this.state.mode) {
case "object":
renderDiagnostics(
this.contentEl,
this.state.warnings,
this.state.onOpenDiagnostic ?? undefined
);
this.renderObjectState(this.state);
return;
case "relations":
renderDiagnostics(this.contentEl, this.state.warnings);
this.renderRelationsState(this.state);
return;
case "diagram":
renderDiagnostics(
this.contentEl,
this.state.warnings,
this.state.onOpenDiagnostic ?? undefined
);
this.renderDiagramState(this.state);
return;
case "empty":
@ -235,42 +247,88 @@ export class ModelingPreviewView extends ItemView {
}
}
function renderWarningBar(container: HTMLElement, warnings: ValidationWarning[]): void {
const actionableWarnings = warnings.filter(
(warning) => warning.severity === "warning"
);
const notes = warnings.filter((warning) => warning.severity === "info");
function renderDiagnostics(
container: HTMLElement,
diagnostics: ValidationWarning[],
onOpenDiagnostic?: (diagnostic: ValidationWarning) => void
): void {
const notes = diagnostics.filter((diagnostic) => diagnostic.severity === "info");
const warnings = diagnostics.filter((diagnostic) => diagnostic.severity === "warning");
const errors = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
if (actionableWarnings.length === 0 && notes.length === 0) {
if (notes.length === 0 && warnings.length === 0 && errors.length === 0) {
return;
}
if (actionableWarnings.length > 0) {
const bar = container.createDiv({ cls: "mdspec-warning-bar" });
bar.createEl("strong", {
text: `Warnings (${actionableWarnings.length})`
});
const list = bar.createEl("ul");
for (const warning of actionableWarnings) {
list.createEl("li", { text: warning.message });
}
if (notes.length > 0) {
renderDiagnosticSection(container, "Notes", notes, onOpenDiagnostic, "var(--text-muted)");
}
if (notes.length > 0) {
const details = container.createEl("details");
details.className = "mdspec-warning-notes";
details.style.fontSize = "12px";
details.style.color = "var(--text-muted)";
if (warnings.length > 0) {
renderDiagnosticSection(
container,
"Warnings",
warnings,
onOpenDiagnostic,
"var(--text-warning)"
);
}
const summary = details.createEl("summary", {
text: `Notes (${notes.length})`
});
summary.style.cursor = "pointer";
if (errors.length > 0) {
renderDiagnosticSection(
container,
"Errors",
errors,
onOpenDiagnostic,
"var(--text-error)"
);
}
}
const list = details.createEl("ul");
for (const note of notes) {
list.createEl("li", { text: note.message });
function renderDiagnosticSection(
container: HTMLElement,
title: string,
diagnostics: ValidationWarning[],
onOpenDiagnostic: ((diagnostic: ValidationWarning) => void) | undefined,
color: string
): void {
const details = container.createEl("details");
details.className = "mdspec-diagnostic-section";
details.open = title !== "Notes";
details.style.fontSize = "12px";
const summary = details.createEl("summary", {
text: `${title} (${diagnostics.length})`
});
summary.style.cursor = "pointer";
summary.style.color = color;
const list = details.createEl("ul");
list.style.margin = "8px 0 0";
list.style.paddingLeft = "18px";
for (const diagnostic of diagnostics) {
const item = list.createEl("li");
item.textContent = diagnostic.message;
if (onOpenDiagnostic) {
item.style.cursor = "pointer";
item.style.borderRadius = "4px";
item.style.padding = "2px 4px";
item.title = "Open this diagnostic in the editor";
item.tabIndex = 0;
item.onmouseenter = () => {
item.style.background = "var(--background-modifier-hover)";
};
item.onmouseleave = () => {
item.style.background = "";
};
item.onclick = () => onOpenDiagnostic(diagnostic);
item.onkeydown = (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpenDiagnostic(diagnostic);
}
};
}
}
}