mirror of
https://github.com/nejimakibird/model-weave.git
synced 2026-07-22 06:51:24 +00:00
Complete Model Weave V0.4 authoring and validation workflow
This commit is contained in:
parent
dd5340de5f
commit
e4dbe83237
31 changed files with 4251 additions and 1157 deletions
24
README.md
24
README.md
|
|
@ -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
|
||||
|
|
|
|||
45
README_JP.md
45
README_JP.md
|
|
@ -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. 設計対象をプレーンテキストで記述する
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
233
src/core/current-file-diagnostics.ts
Normal file
233
src/core/current-file-diagnostics.ts
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
720
src/editor/model-weave-editor-suggest.ts
Normal file
720
src/editor/model-weave-editor-suggest.ts
Normal 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
|
||||
};
|
||||
}
|
||||
419
src/main.ts
419
src/main.ts
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
146
src/templates/model-weave-templates.ts
Normal file
146
src/templates/model-weave-templates.ts
Normal 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;
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue