@cratis/scene.engine 4.3.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/dist/cjs/editing/collectIconUsages.js +86 -0
  2. package/dist/cjs/editing/collectIconUsages.js.map +1 -0
  3. package/dist/cjs/editing/diagnoseIcons.js +67 -0
  4. package/dist/cjs/editing/diagnoseIcons.js.map +1 -0
  5. package/dist/cjs/editing/iconDiagnostics.js +103 -0
  6. package/dist/cjs/editing/iconDiagnostics.js.map +1 -0
  7. package/dist/cjs/editing/inspect.js +28 -1
  8. package/dist/cjs/editing/inspect.js.map +1 -1
  9. package/dist/cjs/editing/instanceEdits.js +15 -3
  10. package/dist/cjs/editing/instanceEdits.js.map +1 -1
  11. package/dist/cjs/editing/propertyEdits.js +7 -0
  12. package/dist/cjs/editing/propertyEdits.js.map +1 -1
  13. package/dist/cjs/icons/EffectiveIconCatalog.js +212 -0
  14. package/dist/cjs/icons/EffectiveIconCatalog.js.map +1 -0
  15. package/dist/cjs/icons/EffectiveIconEntry.js +22 -0
  16. package/dist/cjs/icons/EffectiveIconEntry.js.map +1 -0
  17. package/dist/cjs/icons/analyzeIconImpact.js +48 -0
  18. package/dist/cjs/icons/analyzeIconImpact.js.map +1 -0
  19. package/dist/cjs/icons/matchesIconQuery.js +22 -0
  20. package/dist/cjs/icons/matchesIconQuery.js.map +1 -0
  21. package/dist/cjs/icons/resolveIconLibraries.js +45 -0
  22. package/dist/cjs/icons/resolveIconLibraries.js.map +1 -0
  23. package/dist/cjs/index.js +16 -0
  24. package/dist/cjs/index.js.map +1 -1
  25. package/dist/esm/editing/EditingContext.d.ts +2 -0
  26. package/dist/esm/editing/EditingContext.d.ts.map +1 -1
  27. package/dist/esm/editing/collectIconUsages.d.ts +5 -0
  28. package/dist/esm/editing/collectIconUsages.d.ts.map +1 -0
  29. package/dist/esm/editing/collectIconUsages.js +84 -0
  30. package/dist/esm/editing/collectIconUsages.js.map +1 -0
  31. package/dist/esm/editing/diagnoseIcons.d.ts +4 -0
  32. package/dist/esm/editing/diagnoseIcons.d.ts.map +1 -0
  33. package/dist/esm/editing/diagnoseIcons.js +65 -0
  34. package/dist/esm/editing/diagnoseIcons.js.map +1 -0
  35. package/dist/esm/editing/iconDiagnostics.d.ts +11 -0
  36. package/dist/esm/editing/iconDiagnostics.d.ts.map +1 -0
  37. package/dist/esm/editing/iconDiagnostics.js +99 -0
  38. package/dist/esm/editing/iconDiagnostics.js.map +1 -0
  39. package/dist/esm/editing/index.d.ts +3 -0
  40. package/dist/esm/editing/index.d.ts.map +1 -1
  41. package/dist/esm/editing/index.js +3 -0
  42. package/dist/esm/editing/index.js.map +1 -1
  43. package/dist/esm/editing/inspect.d.ts.map +1 -1
  44. package/dist/esm/editing/inspect.js +29 -2
  45. package/dist/esm/editing/inspect.js.map +1 -1
  46. package/dist/esm/editing/instanceEdits.d.ts.map +1 -1
  47. package/dist/esm/editing/instanceEdits.js +17 -5
  48. package/dist/esm/editing/instanceEdits.js.map +1 -1
  49. package/dist/esm/editing/propertyEdits.d.ts.map +1 -1
  50. package/dist/esm/editing/propertyEdits.js +9 -2
  51. package/dist/esm/editing/propertyEdits.js.map +1 -1
  52. package/dist/esm/icons/EffectiveIconCatalog.d.ts +25 -0
  53. package/dist/esm/icons/EffectiveIconCatalog.d.ts.map +1 -0
  54. package/dist/esm/icons/EffectiveIconCatalog.js +210 -0
  55. package/dist/esm/icons/EffectiveIconCatalog.js.map +1 -0
  56. package/dist/esm/icons/EffectiveIconEntry.d.ts +9 -0
  57. package/dist/esm/icons/EffectiveIconEntry.d.ts.map +1 -0
  58. package/dist/esm/icons/EffectiveIconEntry.js +20 -0
  59. package/dist/esm/icons/EffectiveIconEntry.js.map +1 -0
  60. package/dist/esm/icons/IconCatalogSource.d.ts +6 -0
  61. package/dist/esm/icons/IconCatalogSource.d.ts.map +1 -0
  62. package/dist/esm/icons/IconCatalogSource.js +2 -0
  63. package/dist/esm/icons/IconCatalogSource.js.map +1 -0
  64. package/dist/esm/icons/IconDiagnostic.d.ts +9 -0
  65. package/dist/esm/icons/IconDiagnostic.d.ts.map +1 -0
  66. package/dist/esm/icons/IconDiagnostic.js +2 -0
  67. package/dist/esm/icons/IconDiagnostic.js.map +1 -0
  68. package/dist/esm/icons/IconImpactReport.d.ts +18 -0
  69. package/dist/esm/icons/IconImpactReport.d.ts.map +1 -0
  70. package/dist/esm/icons/IconImpactReport.js +2 -0
  71. package/dist/esm/icons/IconImpactReport.js.map +1 -0
  72. package/dist/esm/icons/IconLibraryResolution.d.ts +7 -0
  73. package/dist/esm/icons/IconLibraryResolution.d.ts.map +1 -0
  74. package/dist/esm/icons/IconLibraryResolution.js +2 -0
  75. package/dist/esm/icons/IconLibraryResolution.js.map +1 -0
  76. package/dist/esm/icons/IconQuery.d.ts +7 -0
  77. package/dist/esm/icons/IconQuery.d.ts.map +1 -0
  78. package/dist/esm/icons/IconQuery.js +2 -0
  79. package/dist/esm/icons/IconQuery.js.map +1 -0
  80. package/dist/esm/icons/IconResolution.d.ts +14 -0
  81. package/dist/esm/icons/IconResolution.d.ts.map +1 -0
  82. package/dist/esm/icons/IconResolution.js +2 -0
  83. package/dist/esm/icons/IconResolution.js.map +1 -0
  84. package/dist/esm/icons/IconSearchResult.d.ts +7 -0
  85. package/dist/esm/icons/IconSearchResult.d.ts.map +1 -0
  86. package/dist/esm/icons/IconSearchResult.js +2 -0
  87. package/dist/esm/icons/IconSearchResult.js.map +1 -0
  88. package/dist/esm/icons/IconUsage.d.ts +6 -0
  89. package/dist/esm/icons/IconUsage.d.ts.map +1 -0
  90. package/dist/esm/icons/IconUsage.js +2 -0
  91. package/dist/esm/icons/IconUsage.js.map +1 -0
  92. package/dist/esm/icons/ResolvedIconLibrary.d.ts +8 -0
  93. package/dist/esm/icons/ResolvedIconLibrary.d.ts.map +1 -0
  94. package/dist/esm/icons/ResolvedIconLibrary.js +2 -0
  95. package/dist/esm/icons/ResolvedIconLibrary.js.map +1 -0
  96. package/dist/esm/icons/analyzeIconImpact.d.ts +5 -0
  97. package/dist/esm/icons/analyzeIconImpact.d.ts.map +1 -0
  98. package/dist/esm/icons/analyzeIconImpact.js +46 -0
  99. package/dist/esm/icons/analyzeIconImpact.js.map +1 -0
  100. package/dist/esm/icons/index.d.ts +14 -0
  101. package/dist/esm/icons/index.d.ts.map +1 -0
  102. package/dist/esm/icons/index.js +14 -0
  103. package/dist/esm/icons/index.js.map +1 -0
  104. package/dist/esm/icons/matchesIconQuery.d.ts +4 -0
  105. package/dist/esm/icons/matchesIconQuery.d.ts.map +1 -0
  106. package/dist/esm/icons/matchesIconQuery.js +20 -0
  107. package/dist/esm/icons/matchesIconQuery.js.map +1 -0
  108. package/dist/esm/icons/resolveIconLibraries.d.ts +4 -0
  109. package/dist/esm/icons/resolveIconLibraries.d.ts.map +1 -0
  110. package/dist/esm/icons/resolveIconLibraries.js +43 -0
  111. package/dist/esm/icons/resolveIconLibraries.js.map +1 -0
  112. package/dist/esm/index.d.ts +1 -0
  113. package/dist/esm/index.d.ts.map +1 -1
  114. package/dist/esm/index.js +7 -0
  115. package/dist/esm/index.js.map +1 -1
  116. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  117. package/editing/EditingContext.ts +11 -0
  118. package/editing/collectIconUsages.ts +78 -0
  119. package/editing/diagnoseIcons.ts +60 -0
  120. package/editing/iconDiagnostics.ts +90 -0
  121. package/editing/index.ts +3 -0
  122. package/editing/inspect.ts +25 -2
  123. package/editing/instanceEdits.ts +13 -5
  124. package/editing/propertyEdits.ts +10 -2
  125. package/icons/EffectiveIconCatalog.ts +199 -0
  126. package/icons/EffectiveIconEntry.ts +36 -0
  127. package/icons/IconCatalogSource.ts +23 -0
  128. package/icons/IconDiagnostic.ts +45 -0
  129. package/icons/IconImpactReport.ts +63 -0
  130. package/icons/IconLibraryResolution.ts +22 -0
  131. package/icons/IconQuery.ts +27 -0
  132. package/icons/IconResolution.ts +25 -0
  133. package/icons/IconSearchResult.ts +21 -0
  134. package/icons/IconUsage.ts +21 -0
  135. package/icons/ResolvedIconLibrary.ts +32 -0
  136. package/icons/analyzeIconImpact.ts +41 -0
  137. package/icons/index.ts +16 -0
  138. package/icons/matchesIconQuery.ts +19 -0
  139. package/icons/resolveIconLibraries.ts +47 -0
  140. package/index.ts +1 -0
  141. package/package.json +3 -3
@@ -2,6 +2,7 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  import { EditingScope, QueryCandidate } from '@cratis/scene.model';
5
+ import { EffectiveIconCatalog } from '../icons/EffectiveIconCatalog';
5
6
  import { DescriptorCatalog } from './DescriptorCatalog';
6
7
 
7
8
  /**
@@ -20,4 +21,14 @@ export interface EditingContext {
20
21
  * edited it is checked against these; without them it is checked for shape only.
21
22
  */
22
23
  queryCandidates?: QueryCandidate[];
24
+
25
+ /**
26
+ * The icons the profile can use. When present, an icon value that is edited is checked against it - a
27
+ * library the profile lacks, an icon the library no longer has, a library at an incompatible version -
28
+ * and inspection reports the same problems for values already stored, which are kept either way.
29
+ * Without it an icon value is checked for shape only. An edit is applied synchronously, so
30
+ * `await iconCatalog.load()` first for definite answers about icons and variants; until a library's
31
+ * catalog is loaded its icons are reported as not verified.
32
+ */
33
+ iconCatalog?: EffectiveIconCatalog;
23
34
  }
@@ -0,0 +1,78 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { InstanceContribution, PropertyDescriptor, PropertyValueType, SceneDocument } from '@cratis/scene.model';
5
+ import { IconUsage } from '../icons/IconUsage';
6
+ import { collectComponents } from './collectComponents';
7
+ import { DescriptorCatalog, findComponentDescriptor } from './DescriptorCatalog';
8
+ import { getValueAtPath } from './pathAccess';
9
+
10
+ function iconFields(property: PropertyDescriptor): string[] {
11
+ return (property.item?.properties ?? []).filter((field) => field.valueType === PropertyValueType.Icon).map((field) => field.path);
12
+ }
13
+
14
+ function usagesOf(property: PropertyDescriptor, value: unknown, items: { id: string; values: Record<string, unknown> }[] | undefined, location: string): IconUsage[] {
15
+ if (property.valueType === PropertyValueType.Icon) {
16
+ return value === undefined ? [] : [{ value: value as IconUsage['value'], location: `${location}:${property.path}` }];
17
+ }
18
+
19
+ return property.valueType === PropertyValueType.Collection
20
+ ? (items ?? []).flatMap((item) =>
21
+ iconFields(property)
22
+ .filter((field) => item.values[field] !== undefined)
23
+ .map((field) => ({ value: item.values[field] as IconUsage['value'], location: `${location}:${property.path}:${item.id}:${field}` })))
24
+ : [];
25
+ }
26
+
27
+ function ownerItems(value: unknown): { id: string; values: Record<string, unknown> }[] {
28
+ return (Array.isArray(value) ? value : [])
29
+ .filter((item): item is Record<string, unknown> => item !== null && typeof item === 'object')
30
+ .map(({ id, ...values }) => ({ id: String(id), values }));
31
+ }
32
+
33
+ /**
34
+ * Finds every icon value stored anywhere in a document - the values components carry themselves, and the values
35
+ * and collection-item icon fields that template instances contributed - as the `IconUsage`s that
36
+ * `analyzeIconImpact` takes, so "what would removing this library break" can be asked of a whole document.
37
+ *
38
+ * Each usage's `location` reads `<owner or instance>:<component>:<path>[:<itemId>:<field>]`, where the first part is
39
+ * the contributing instance id (`screen:Orders`) for a contributed value and the name of the owning layout or
40
+ * template for a value the component carries. Values are read as stored; none are changed.
41
+ *
42
+ * @param document The document to read.
43
+ * @param catalog The descriptors saying which properties are icons.
44
+ */
45
+ export function collectIconUsages(document: SceneDocument, catalog: DescriptorCatalog): IconUsage[] {
46
+ const usages: IconUsage[] = [];
47
+ const componentNames = new Map<string, string>();
48
+
49
+ const owners: { name: string; value: unknown }[] = [
50
+ ...document.layouts.map((layout) => ({ name: layout.name, value: layout })),
51
+ ...document.screenTemplates.map((template) => ({ name: template.name, value: template })),
52
+ ...document.dialogTemplates.map((template) => ({ name: template.name, value: template })),
53
+ ...document.screens.map((screen) => ({ name: screen.name, value: screen })),
54
+ ];
55
+
56
+ for (const owner of owners) {
57
+ for (const component of collectComponents(owner.value)) {
58
+ if (!componentNames.has(component.id)) componentNames.set(component.id, component.componentName);
59
+
60
+ for (const property of findComponentDescriptor(catalog, component.componentName)?.properties ?? []) {
61
+ const value = getValueAtPath(component.properties, property.path);
62
+ usages.push(...usagesOf(property, value, ownerItems(value), `${owner.name}:${component.id}`));
63
+ }
64
+ }
65
+ }
66
+
67
+ for (const contribution of document.instanceContributions) {
68
+ const componentName = componentNames.get(contribution.component);
69
+ const property = componentName === undefined ? undefined : findComponentDescriptor(catalog, componentName)?.properties.find((candidate) => candidate.path === contribution.path);
70
+ if (property) usages.push(...contributedUsages(property, contribution));
71
+ }
72
+
73
+ return usages;
74
+ }
75
+
76
+ function contributedUsages(property: PropertyDescriptor, contribution: InstanceContribution): IconUsage[] {
77
+ return usagesOf(property, contribution.value, contribution.items, `${contribution.instance}:${contribution.component}`);
78
+ }
@@ -0,0 +1,60 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { DiagnosticSeverity, PropertyDescriptor, PropertyValueType, SceneDiagnostic, SceneDocument } from '@cratis/scene.model';
5
+ import { findComponentDescriptor } from './DescriptorCatalog';
6
+ import { EditingContext } from './EditingContext';
7
+ import { iconProblemsOfItems, iconProblemsOfProperty } from './iconDiagnostics';
8
+ import { getValueAtPath } from './pathAccess';
9
+ import { resolveEffectiveConfiguration } from './resolveEffectiveConfiguration';
10
+ import { resolveTemplateChain } from './resolveTemplateChain';
11
+
12
+ function isIconBearing(property: PropertyDescriptor): boolean {
13
+ return property.valueType === PropertyValueType.Icon
14
+ || (property.valueType === PropertyValueType.Collection && (property.item?.properties ?? []).some((field) => field.valueType === PropertyValueType.Icon));
15
+ }
16
+
17
+ /**
18
+ * Reports every icon in what the editing scope renders - the template chain's own values and what each instance
19
+ * contributed - that the profile's icon catalog cannot supply: a library the profile lacks, an icon or variant a
20
+ * library no longer has, a library at an incompatible version.
21
+ *
22
+ * It reports; it never changes anything. The offending values stay in the document, still applied (a renderer
23
+ * shows its fallback), and apply again as soon as the icon is available, so removing a library is reversible.
24
+ * Each diagnostic names the component, property, collection item and - for a contributed value - the instance.
25
+ *
26
+ * Returns nothing when the context carries no `iconCatalog`. Load the catalogs first (`iconCatalog.load()`) for
27
+ * definite answers; an icon from an unloaded library is reported as not verified.
28
+ */
29
+ export function diagnoseIcons(document: SceneDocument, context: EditingContext): SceneDiagnostic[] {
30
+ const iconCatalog = context.iconCatalog;
31
+ if (!iconCatalog) return [];
32
+
33
+ const chain = resolveTemplateChain(document, context.scope);
34
+ const configuration = resolveEffectiveConfiguration(chain, document.instanceContributions, context.catalog);
35
+ const diagnostics: SceneDiagnostic[] = [];
36
+
37
+ for (const level of chain.levels) {
38
+ for (const element of level.elements) {
39
+ const descriptor = findComponentDescriptor(context.catalog, element.componentName);
40
+ const configured = configuration.components.find((candidate) => candidate.component === element.id);
41
+
42
+ for (const property of (descriptor?.properties ?? []).filter(isIconBearing)) {
43
+ const effective = configured?.values.find((candidate) => candidate.path === property.path);
44
+ const where = { component: element.id, ...(effective?.contributedBy === undefined ? {} : { instance: effective.contributedBy }) };
45
+
46
+ if (property.valueType === PropertyValueType.Icon) {
47
+ const value = effective ? effective.value : getValueAtPath(element.properties, property.path);
48
+ diagnostics.push(...iconProblemsOfProperty(iconCatalog, property, value, DiagnosticSeverity.Warning, where));
49
+ } else if (effective?.items) {
50
+ const items = effective.items.map((item) => ({ id: item.id, values: item.values, instance: item.origin === 'owner' ? undefined : item.origin }));
51
+ diagnostics.push(...iconProblemsOfItems(iconCatalog, property.item, items, DiagnosticSeverity.Warning, { component: element.id, path: property.path }));
52
+ } else {
53
+ diagnostics.push(...iconProblemsOfProperty(iconCatalog, property, getValueAtPath(element.properties, property.path), DiagnosticSeverity.Warning, where));
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ return diagnostics;
60
+ }
@@ -0,0 +1,90 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { CollectionItemDescriptor, DiagnosticCode, DiagnosticSeverity, PropertyDescriptor, PropertyValueType, SceneDiagnostic, isIconReference } from '@cratis/scene.model';
5
+ import { EffectiveIconCatalog } from '../icons/EffectiveIconCatalog';
6
+ import { IconDiagnosticKind } from '../icons/IconDiagnostic';
7
+ import { DiagnosticContext } from './diagnostics';
8
+
9
+ const codes: Record<IconDiagnosticKind, DiagnosticCode> = {
10
+ 'missing-library': DiagnosticCode.MissingIconLibrary,
11
+ 'missing-icon': DiagnosticCode.MissingIcon,
12
+ 'missing-variant': DiagnosticCode.MissingIconVariant,
13
+ 'incompatible-version': DiagnosticCode.IncompatibleIconLibrary,
14
+ 'catalog-unavailable': DiagnosticCode.IconCatalogUnavailable,
15
+ };
16
+
17
+ /**
18
+ * Checks one icon value against the effective icon catalog.
19
+ *
20
+ * Nothing is rewritten or dropped: this only reports. A value whose library, icon or variant is missing, or
21
+ * whose library is at an incompatible version, produces a diagnostic of the given severity - an error when
22
+ * the value is being *set* (the edit is refused), a warning when it is already stored (it is kept, and
23
+ * applies again if the icon comes back). A value that cannot be judged yet, because its library's catalog has
24
+ * not been loaded, produces an `IconNotVerified` warning rather than silently passing.
25
+ *
26
+ * @param catalog The effective catalog of the profile being edited.
27
+ * @param value The value; anything that is not an icon reference is left to `validateValue`.
28
+ * @param severity How serious a definite problem is.
29
+ * @param context What the diagnostic is about.
30
+ */
31
+ export function iconProblem(catalog: EffectiveIconCatalog, value: unknown, severity: DiagnosticSeverity, context: DiagnosticContext): SceneDiagnostic | undefined {
32
+ if (!isIconReference(value)) return undefined;
33
+
34
+ const resolution = catalog.lookup(value);
35
+ if (resolution === undefined) {
36
+ return {
37
+ code: DiagnosticCode.IconNotVerified,
38
+ severity: DiagnosticSeverity.Warning,
39
+ message: `The catalog of the icon library '${value.library}' is not loaded, so '${value.key}' could not be verified.`,
40
+ ...context,
41
+ };
42
+ }
43
+
44
+ return resolution.isResolved ? undefined : { code: codes[resolution.diagnostic.kind], severity, message: `${resolution.diagnostic.message}.`, ...context };
45
+ }
46
+
47
+ /**
48
+ * Checks the icon fields of collection items.
49
+ *
50
+ * @param itemDescriptor What the items look like.
51
+ * @param items The items, each with its id and field values.
52
+ */
53
+ export function iconProblemsOfItems(
54
+ catalog: EffectiveIconCatalog,
55
+ itemDescriptor: CollectionItemDescriptor | undefined,
56
+ items: { id: string; values: Record<string, unknown>; instance?: string }[],
57
+ severity: DiagnosticSeverity,
58
+ context: DiagnosticContext
59
+ ): SceneDiagnostic[] {
60
+ const iconFields = (itemDescriptor?.properties ?? []).filter((field) => field.valueType === PropertyValueType.Icon);
61
+ return items.flatMap((item) =>
62
+ iconFields.flatMap((field) => {
63
+ const problem = iconProblem(catalog, item.values[field.path], severity, { ...context, itemId: item.id, ...(item.instance === undefined ? {} : { instance: item.instance }) });
64
+ return problem ? [{ ...problem, message: `'${field.label}' of '${item.id}': ${problem.message}` }] : [];
65
+ })
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Checks a property's value - an icon, or a collection whose items have icon fields - against the effective icon
71
+ * catalog. Any other kind of property has nothing to check.
72
+ *
73
+ * @param descriptor The property.
74
+ * @param value The value; for a collection, the flat items (`{ id, ...fields }`) as they are stored.
75
+ */
76
+ export function iconProblemsOfProperty(catalog: EffectiveIconCatalog, descriptor: PropertyDescriptor, value: unknown, severity: DiagnosticSeverity, context: DiagnosticContext): SceneDiagnostic[] {
77
+ if (descriptor.valueType === PropertyValueType.Icon) {
78
+ const problem = iconProblem(catalog, value, severity, { ...context, path: descriptor.path });
79
+ return problem ? [{ ...problem, message: `'${descriptor.label}': ${problem.message}` }] : [];
80
+ }
81
+
82
+ if (descriptor.valueType === PropertyValueType.Collection && Array.isArray(value)) {
83
+ const items = value
84
+ .filter((item): item is Record<string, unknown> => item !== null && typeof item === 'object')
85
+ .map(({ id, ...values }) => ({ id: String(id), values }));
86
+ return iconProblemsOfItems(catalog, descriptor.item, items, severity, { ...context, path: descriptor.path });
87
+ }
88
+
89
+ return [];
90
+ }
package/editing/index.ts CHANGED
@@ -19,3 +19,6 @@ export * from './EditingContext';
19
19
  export * from './inspect';
20
20
  export * from './EditOutcome';
21
21
  export * from './applyEdit';
22
+ export * from './iconDiagnostics';
23
+ export * from './diagnoseIcons';
24
+ export * from './collectIconUsages';
@@ -2,12 +2,14 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  import {
5
- DiagnosticCode, EditTarget, InspectedCollectionItem, InspectedProperty, NodeInspection,
5
+ DiagnosticCode, DiagnosticSeverity, EditTarget, InspectedCollectionItem, InspectedProperty, NodeInspection,
6
6
  NodeProvenance, ProvenanceKind, PropertyDescriptor, PropertyValueType, SceneDiagnostic, SceneDocument, SceneNodeKind, ValueSource,
7
7
  } from '@cratis/scene.model';
8
8
  import { describeNode, readNodeValue } from './describeNode';
9
+ import { EffectiveIconCatalog } from '../icons/EffectiveIconCatalog';
9
10
  import { EditingContext } from './EditingContext';
10
11
  import { computeExposureGrants, ExposureGrant, grantKey } from './exposureGrants';
12
+ import { iconProblemsOfItems, iconProblemsOfProperty } from './iconDiagnostics';
11
13
  import { indexDocument } from './indexDocument';
12
14
  import { NodeRecord } from './DocumentIndex';
13
15
  import { resolveEffectiveConfiguration } from './resolveEffectiveConfiguration';
@@ -90,6 +92,22 @@ function inspectProperty(
90
92
  return inspected;
91
93
  }
92
94
 
95
+ /**
96
+ * Reports icons the profile cannot supply - on the node itself and in configuration contributed to it. The values
97
+ * stay where they are; these are warnings.
98
+ */
99
+ function iconDiagnosticsOf(properties: InspectedProperty[], iconCatalog: EffectiveIconCatalog, nodeId: string): SceneDiagnostic[] {
100
+ return properties.flatMap(property => {
101
+ const descriptor = property.descriptor;
102
+ if (descriptor.valueType !== PropertyValueType.Collection) {
103
+ return iconProblemsOfProperty(iconCatalog, descriptor, property.effectiveValue, DiagnosticSeverity.Warning, { nodeId });
104
+ }
105
+
106
+ const items = (property.items ?? []).map(item => ({ id: item.id, values: item.values, instance: item.origin === 'owner' ? undefined : item.origin }));
107
+ return iconProblemsOfItems(iconCatalog, descriptor.item, items, DiagnosticSeverity.Warning, { nodeId, path: descriptor.path });
108
+ });
109
+ }
110
+
93
111
  /**
94
112
  * Describes one node for an editor: what it is, where it comes from, what can be edited on it - with current and
95
113
  * effective values and effective editability - and what can be done with it.
@@ -127,6 +145,11 @@ export function inspect(document: SceneDocument, nodeId: string, context: Editin
127
145
  diagnostics.push(...chain.diagnostics.filter(diagnostic => diagnostic.code === DiagnosticCode.UnknownScope));
128
146
  }
129
147
 
148
+ const properties = description.properties.map(descriptor => inspectProperty(descriptor, record, provenance, scopeInstance, scopeGrants, configuredValues));
149
+ if (context.iconCatalog) {
150
+ diagnostics.push(...iconDiagnosticsOf(properties, context.iconCatalog, nodeId));
151
+ }
152
+
130
153
  return {
131
154
  nodeId,
132
155
  kind: record.kind,
@@ -134,7 +157,7 @@ export function inspect(document: SceneDocument, nodeId: string, context: Editin
134
157
  label: record.label,
135
158
  parentId: record.parentId,
136
159
  provenance,
137
- properties: description.properties.map(descriptor => inspectProperty(descriptor, record, provenance, scopeInstance, scopeGrants, configuredValues)),
160
+ properties,
138
161
  capabilities: description.capabilities,
139
162
  removable: isRemovable(record, provenance),
140
163
  diagnostics,
@@ -2,11 +2,12 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  import {
5
- AddCollectionItemEdit, CollectionOperation, ContributedItem, DiagnosticCode, EditCollectionItemEdit, InstanceContribution,
5
+ AddCollectionItemEdit, CollectionOperation, ContributedItem, DiagnosticCode, DiagnosticSeverity, EditCollectionItemEdit, InstanceContribution,
6
6
  PropertyValueType, RemoveCollectionItemEdit, ReorderCollectionItemEdit, ResetInstanceValueEdit, SceneDiagnostic,
7
7
  SceneDocument, SetInstanceValueEdit,
8
8
  } from '@cratis/scene.model';
9
- import { errorDiagnostic } from './diagnostics';
9
+ import { errorDiagnostic, hasErrors } from './diagnostics';
10
+ import { iconProblemsOfItems } from './iconDiagnostics';
10
11
  import { EditSession } from './EditSession';
11
12
  import { ExposureGrant, grantKey } from './exposureGrants';
12
13
  import { cloneData } from './pathAccess';
@@ -66,7 +67,7 @@ function permitted(grant: ExposureGrant, operation: CollectionOperation, instanc
66
67
  return true;
67
68
  }
68
69
 
69
- function checkFields(grant: ExposureGrant, values: Record<string, unknown>, itemId: string, instance: string, diagnostics: SceneDiagnostic[], requireAll: boolean): boolean {
70
+ function checkFields(session: EditSession, grant: ExposureGrant, values: Record<string, unknown>, itemId: string, instance: string, diagnostics: SceneDiagnostic[], requireAll: boolean): boolean {
70
71
  const context = { instance, component: grant.component, path: grant.path, itemId };
71
72
  let valid = true;
72
73
 
@@ -88,6 +89,13 @@ function checkFields(grant: ExposureGrant, values: Record<string, unknown>, item
88
89
  valid = false;
89
90
  }
90
91
 
92
+ const icons = session.context.iconCatalog;
93
+ if (icons && valid) {
94
+ const iconProblems = iconProblemsOfItems(icons, itemDescriptor, [{ id: itemId, values }], DiagnosticSeverity.Error, context);
95
+ diagnostics.push(...iconProblems);
96
+ valid = !hasErrors(iconProblems);
97
+ }
98
+
91
99
  return valid;
92
100
  }
93
101
 
@@ -163,7 +171,7 @@ export function applyAddCollectionItem(session: EditSession, edit: AddCollection
163
171
  return;
164
172
  }
165
173
 
166
- if (!checkFields(grant, edit.item.values, edit.item.id, instance, diagnostics, true)) return;
174
+ if (!checkFields(session, grant, edit.item.values, edit.item.id, instance, diagnostics, true)) return;
167
175
 
168
176
  const { contribution, items } = ownItems(clone, instance, grant);
169
177
  if (edit.index !== undefined && (!Number.isInteger(edit.index) || edit.index < 0 || edit.index > items.length)) {
@@ -236,7 +244,7 @@ export function applyEditCollectionItem(session: EditSession, edit: EditCollecti
236
244
  const position = findOwnItem(items, edit.itemId, resolved.instance, resolved.grant, session, diagnostics);
237
245
  if (position < 0) return;
238
246
 
239
- if (!checkFields(resolved.grant, { [edit.field]: edit.value }, edit.itemId, resolved.instance, diagnostics, false)) return;
247
+ if (!checkFields(session, resolved.grant, { [edit.field]: edit.value }, edit.itemId, resolved.instance, diagnostics, false)) return;
240
248
 
241
249
  const next = items.map((item, index) => index === position ? { ...item, values: { ...item.values, [edit.field]: cloneData(edit.value) } } : item);
242
250
  save(clone, resolved.instance, resolved.grant, contribution, next);
@@ -2,14 +2,15 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  import {
5
- DiagnosticCode, PropertyDescriptor, PropertyValueType, QueryBinding, ResetPropertyEdit, SceneDiagnostic, SceneDocument,
5
+ DiagnosticCode, DiagnosticSeverity, PropertyDescriptor, PropertyValueType, QueryBinding, ResetPropertyEdit, SceneDiagnostic, SceneDocument,
6
6
  SceneNodeKind, SetPropertyEdit,
7
7
  } from '@cratis/scene.model';
8
8
  import { describeNode } from './describeNode';
9
- import { errorDiagnostic } from './diagnostics';
9
+ import { errorDiagnostic, hasErrors } from './diagnostics';
10
10
  import { NodeRecord } from './DocumentIndex';
11
11
  import { getAtPath, mirroredPaths } from './documentPaths';
12
12
  import { EditSession } from './EditSession';
13
+ import { iconProblemsOfProperty } from './iconDiagnostics';
13
14
  import { cloneData, removeValueAtPath, setValueAtPath } from './pathAccess';
14
15
  import { validateBinding } from './validateBinding';
15
16
  import { validateValue } from './validateValue';
@@ -75,6 +76,13 @@ export function checkValue(session: EditSession, descriptor: PropertyDescriptor,
75
76
  return problems.length === 0;
76
77
  }
77
78
 
79
+ const icons = session.context.iconCatalog;
80
+ if (icons) {
81
+ const problems = iconProblemsOfProperty(icons, descriptor, value, DiagnosticSeverity.Error, context);
82
+ diagnostics.push(...problems);
83
+ return !hasErrors(problems);
84
+ }
85
+
78
86
  return true;
79
87
  }
80
88
 
@@ -0,0 +1,199 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { IconEntry, IconReference, formatIconReference } from '@cratis/scene.model';
5
+ import { EffectiveIconEntry, referencesOfEntry } from './EffectiveIconEntry';
6
+ import { IconCatalogSource } from './IconCatalogSource';
7
+ import { IconDiagnostic } from './IconDiagnostic';
8
+ import { IconLibraryResolution } from './IconLibraryResolution';
9
+ import { IconQuery } from './IconQuery';
10
+ import { IconResolution } from './IconResolution';
11
+ import { IconSearchResult } from './IconSearchResult';
12
+ import { ResolvedIconLibrary } from './ResolvedIconLibrary';
13
+ import { matchesIconQuery } from './matchesIconQuery';
14
+
15
+ /**
16
+ * The icons a profile can actually use: the active libraries from {@link resolveIconLibraries}, with their
17
+ * catalogs loaded on demand.
18
+ *
19
+ * Opening it costs nothing - no catalog is read until a query or a lookup needs that library, and a
20
+ * query narrowed to one library reads only that one. A catalog is loaded once and kept; a load that
21
+ * fails is not kept, so the next call tries again. Everything that goes wrong is reported as an
22
+ * {@link IconDiagnostic}; nothing is ever answered with a different icon than the one asked for.
23
+ */
24
+ export class EffectiveIconCatalog {
25
+ readonly #sources: Map<string, IconCatalogSource>;
26
+ readonly #loads = new Map<string, Promise<IconEntry[]>>();
27
+ readonly #loadedEntries = new Map<string, IconEntry[]>();
28
+
29
+ /**
30
+ * @param resolution The active libraries and the problems found with them.
31
+ * @param sources The catalog source of each library; a library with none is active but reports
32
+ * 'catalog-unavailable' when its icons are needed.
33
+ */
34
+ constructor(readonly resolution: IconLibraryResolution, sources: IconCatalogSource[]) {
35
+ this.#sources = new Map(sources.map((source) => [source.library, source]));
36
+ }
37
+
38
+ /**
39
+ * The active libraries, with provenance.
40
+ */
41
+ get libraries(): ResolvedIconLibrary[] {
42
+ return this.resolution.libraries;
43
+ }
44
+
45
+ /**
46
+ * Problems with the libraries themselves, known without loading any catalog.
47
+ */
48
+ get diagnostics(): IconDiagnostic[] {
49
+ return this.resolution.diagnostics;
50
+ }
51
+
52
+ /**
53
+ * Whether a library's catalog has been read yet.
54
+ */
55
+ isLoaded(library: string): boolean {
56
+ return this.#loadedEntries.has(library);
57
+ }
58
+
59
+ /**
60
+ * The entries of one library, loading its catalog if this is the first time it is needed.
61
+ * Resolves to `undefined` when the library is not active.
62
+ *
63
+ * @throws Whatever the library's source throws, or an Error when an active library has no source.
64
+ */
65
+ async entriesOf(library: string): Promise<IconEntry[] | undefined> {
66
+ if (!this.libraries.some((resolved) => resolved.library === library)) return undefined;
67
+
68
+ let load = this.#loads.get(library);
69
+ if (!load) {
70
+ const source = this.#sources.get(library);
71
+ if (!source) throw new Error(`The icon library '${library}' is active but has no catalog source`);
72
+
73
+ load = source.loadEntries();
74
+ this.#loads.set(library, load);
75
+ load.then(
76
+ (entries) => this.#loadedEntries.set(library, entries),
77
+ () => this.#loads.delete(library)
78
+ );
79
+ }
80
+
81
+ return load;
82
+ }
83
+
84
+ /**
85
+ * Searches the catalogs of the libraries the query covers - all of them, or the one it names.
86
+ */
87
+ async search(query: IconQuery = {}): Promise<IconSearchResult> {
88
+ const covered = this.libraries.filter((resolved) => query.library === undefined || resolved.library === query.library);
89
+ const outcomes = await Promise.all(covered.map(async (resolved) => this.#entriesOrDiagnostic(resolved)));
90
+
91
+ const entries: EffectiveIconEntry[] = [];
92
+ const diagnostics: IconDiagnostic[] = [];
93
+ outcomes.forEach((outcome, position) => {
94
+ const library = covered[position];
95
+ if ('diagnostic' in outcome) {
96
+ diagnostics.push(outcome.diagnostic);
97
+ return;
98
+ }
99
+
100
+ for (const entry of outcome.entries.filter((candidate) => matchesIconQuery(candidate, query))) {
101
+ entries.push({ library, entry, references: referencesOfEntry(library.library, entry) });
102
+ }
103
+ });
104
+
105
+ return { entries, diagnostics };
106
+ }
107
+
108
+ /**
109
+ * Every active library's icons whose name or key is exactly `name` (ignoring case) - the several
110
+ * candidates a bare legacy icon name could have meant. More than one is a real answer, not a tie to
111
+ * break: it is the author's decision which library the icon comes from.
112
+ */
113
+ async findByName(name: string): Promise<EffectiveIconEntry[]> {
114
+ const wanted = name.trim().toLowerCase();
115
+ const { entries } = await this.search({});
116
+ return entries.filter(({ entry }) => entry.key.toLowerCase() === wanted || entry.name.toLowerCase() === wanted);
117
+ }
118
+
119
+ /**
120
+ * The categories in use, sorted, across all libraries or one.
121
+ */
122
+ async categories(library?: string): Promise<string[]> {
123
+ const { entries } = await this.search({ library });
124
+ return [...new Set(entries.flatMap(({ entry }) => entry.categories))].sort();
125
+ }
126
+
127
+ /**
128
+ * Looks a reference up: the icon it names, or exactly why there is none.
129
+ */
130
+ async resolve(reference: IconReference): Promise<IconResolution> {
131
+ const library = this.libraries.find((resolved) => resolved.library === reference.library);
132
+ if (library && !this.isLoaded(library.library) && !this.#incompatibility(library)) {
133
+ const outcome = await this.#entriesOrDiagnostic(library);
134
+ if ('diagnostic' in outcome) return { isResolved: false, reference, diagnostic: { ...outcome.diagnostic, reference } };
135
+ }
136
+
137
+ return this.lookup(reference) as IconResolution;
138
+ }
139
+
140
+ /**
141
+ * Looks a reference up without loading anything, for callers that cannot wait - an edit is applied
142
+ * synchronously. Resolves everything that is decidable from what is already known: a library that is not
143
+ * active, one at an incompatible version, and - once the library's catalog has been loaded - a missing
144
+ * icon or variant.
145
+ *
146
+ * @returns The resolution, or `undefined` when the library is active but its catalog has not been loaded,
147
+ * so nothing can be said about the icon yet. Call {@link load} first to make the answer definite.
148
+ */
149
+ lookup(reference: IconReference): IconResolution | undefined {
150
+ const library = this.libraries.find((resolved) => resolved.library === reference.library);
151
+ if (!library) {
152
+ return this.#unresolved(reference, 'missing-library', `The icon library '${reference.library}' is not active in this profile`);
153
+ }
154
+
155
+ const incompatible = this.#incompatibility(library);
156
+ if (incompatible) return { isResolved: false, reference, diagnostic: { ...incompatible, reference } };
157
+
158
+ const entries = this.#loadedEntries.get(library.library);
159
+ if (!entries) return undefined;
160
+
161
+ const entry = entries.find((candidate) => candidate.key === reference.key);
162
+ if (!entry) {
163
+ return this.#unresolved(reference, 'missing-icon', `The icon library '${library.library}' has no icon '${reference.key}'`);
164
+ }
165
+
166
+ if (reference.variant !== undefined && !(entry.variants ?? []).includes(reference.variant)) {
167
+ return this.#unresolved(reference, 'missing-variant', `The icon '${formatIconReference({ library: library.library, key: entry.key })}' has no '${reference.variant}' variant`);
168
+ }
169
+
170
+ return { isResolved: true, reference, library, entry };
171
+ }
172
+
173
+ /**
174
+ * Loads the catalogs of one library, or of every active library, so that {@link lookup} can answer
175
+ * definitely. Libraries whose catalog cannot be loaded are reported, not thrown.
176
+ */
177
+ async load(library?: string): Promise<IconDiagnostic[]> {
178
+ const covered = this.libraries.filter((resolved) => library === undefined || resolved.library === library);
179
+ const outcomes = await Promise.all(covered.map(async (resolved) => this.#entriesOrDiagnostic(resolved)));
180
+ return outcomes.flatMap((outcome) => ('diagnostic' in outcome ? [outcome.diagnostic] : []));
181
+ }
182
+
183
+ #incompatibility(library: ResolvedIconLibrary): IconDiagnostic | undefined {
184
+ return this.diagnostics.find((diagnostic) => diagnostic.kind === 'incompatible-version' && diagnostic.library === library.library);
185
+ }
186
+
187
+ async #entriesOrDiagnostic(library: ResolvedIconLibrary): Promise<{ entries: IconEntry[] } | { diagnostic: IconDiagnostic }> {
188
+ try {
189
+ return { entries: (await this.entriesOf(library.library)) ?? [] };
190
+ } catch (error) {
191
+ const reason = error instanceof Error ? error.message : String(error);
192
+ return { diagnostic: { kind: 'catalog-unavailable', library: library.library, message: `The catalog of '${library.library}' could not be loaded: ${reason}` } };
193
+ }
194
+ }
195
+
196
+ #unresolved(reference: IconReference, kind: IconDiagnostic['kind'], message: string): IconResolution & { isResolved: false } {
197
+ return { isResolved: false, reference, diagnostic: { kind, library: reference.library, message, reference } };
198
+ }
199
+ }
@@ -0,0 +1,36 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { IconEntry, IconReference } from '@cratis/scene.model';
5
+ import { ResolvedIconLibrary } from './ResolvedIconLibrary';
6
+
7
+ /**
8
+ * An icon as a picker shows it: the catalog entry, the library that provides it (so provenance and
9
+ * attribution are one step away), and the references that name it.
10
+ */
11
+ export interface EffectiveIconEntry {
12
+ /**
13
+ * The active library that provides the icon.
14
+ */
15
+ library: ResolvedIconLibrary;
16
+
17
+ /**
18
+ * The catalog entry.
19
+ */
20
+ entry: IconEntry;
21
+
22
+ /**
23
+ * One reference per variant the icon exists in, or the single variant-less reference when it has none.
24
+ */
25
+ references: IconReference[];
26
+ }
27
+
28
+ /**
29
+ * The references that name an entry of a library: one per variant, or one without a variant.
30
+ */
31
+ export function referencesOfEntry(library: string, entry: IconEntry): IconReference[] {
32
+ const variants = entry.variants ?? [];
33
+ return variants.length === 0
34
+ ? [{ library, key: entry.key }]
35
+ : variants.map((variant) => ({ library, key: entry.key, variant }));
36
+ }