retold-facto 0.0.4

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 (92) hide show
  1. package/.claude/launch.json +11 -0
  2. package/.dockerignore +8 -0
  3. package/.quackage.json +19 -0
  4. package/Dockerfile +26 -0
  5. package/bin/retold-facto.js +909 -0
  6. package/examples/facto-government-data.sqlite +0 -0
  7. package/examples/government-data-catalog.json +137 -0
  8. package/examples/government-data-loader.js +1432 -0
  9. package/package.json +91 -0
  10. package/scripts/facto-download.js +425 -0
  11. package/source/Retold-Facto.js +1042 -0
  12. package/source/services/Retold-Facto-BeaconProvider.js +511 -0
  13. package/source/services/Retold-Facto-CatalogManager.js +1252 -0
  14. package/source/services/Retold-Facto-DataLakeService.js +1642 -0
  15. package/source/services/Retold-Facto-DatasetManager.js +417 -0
  16. package/source/services/Retold-Facto-IngestEngine.js +1315 -0
  17. package/source/services/Retold-Facto-ProjectionEngine.js +3960 -0
  18. package/source/services/Retold-Facto-RecordManager.js +360 -0
  19. package/source/services/Retold-Facto-SchemaManager.js +1110 -0
  20. package/source/services/Retold-Facto-SourceFolderScanner.js +2243 -0
  21. package/source/services/Retold-Facto-SourceManager.js +730 -0
  22. package/source/services/Retold-Facto-StoreConnectionManager.js +441 -0
  23. package/source/services/Retold-Facto-ThroughputMonitor.js +478 -0
  24. package/source/services/web-app/codemirror-entry.js +7 -0
  25. package/source/services/web-app/pict-app/Pict-Application-Facto-Configuration.json +9 -0
  26. package/source/services/web-app/pict-app/Pict-Application-Facto.js +70 -0
  27. package/source/services/web-app/pict-app/Pict-Facto-Bundle.js +11 -0
  28. package/source/services/web-app/pict-app/providers/Pict-Provider-Facto-UI.js +66 -0
  29. package/source/services/web-app/pict-app/providers/Pict-Provider-Facto.js +69 -0
  30. package/source/services/web-app/pict-app/providers/facto-api/Facto-API-Catalog.js +93 -0
  31. package/source/services/web-app/pict-app/providers/facto-api/Facto-API-Connections.js +42 -0
  32. package/source/services/web-app/pict-app/providers/facto-api/Facto-API-Datasets.js +605 -0
  33. package/source/services/web-app/pict-app/providers/facto-api/Facto-API-Projections.js +188 -0
  34. package/source/services/web-app/pict-app/providers/facto-api/Facto-API-Scanner.js +80 -0
  35. package/source/services/web-app/pict-app/providers/facto-api/Facto-API-Schema.js +116 -0
  36. package/source/services/web-app/pict-app/providers/facto-api/Facto-API-Sources.js +104 -0
  37. package/source/services/web-app/pict-app/views/PictView-Facto-Catalog.js +526 -0
  38. package/source/services/web-app/pict-app/views/PictView-Facto-Datasets.js +173 -0
  39. package/source/services/web-app/pict-app/views/PictView-Facto-Ingest.js +259 -0
  40. package/source/services/web-app/pict-app/views/PictView-Facto-Layout.js +191 -0
  41. package/source/services/web-app/pict-app/views/PictView-Facto-Projections.js +231 -0
  42. package/source/services/web-app/pict-app/views/PictView-Facto-Records.js +326 -0
  43. package/source/services/web-app/pict-app/views/PictView-Facto-Scanner.js +624 -0
  44. package/source/services/web-app/pict-app/views/PictView-Facto-Sources.js +201 -0
  45. package/source/services/web-app/pict-app/views/PictView-Facto-Throughput.js +456 -0
  46. package/source/services/web-app/pict-app-full/Pict-Application-Facto-Full-Configuration.json +14 -0
  47. package/source/services/web-app/pict-app-full/Pict-Application-Facto-Full.js +391 -0
  48. package/source/services/web-app/pict-app-full/providers/PictRouter-Facto-Configuration.json +56 -0
  49. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-BottomBar.js +68 -0
  50. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Connections.js +340 -0
  51. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Dashboard.js +149 -0
  52. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Dashboards.js +819 -0
  53. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Datasets.js +178 -0
  54. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-IngestJobs.js +99 -0
  55. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Layout.js +62 -0
  56. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-MappingEditor.js +158 -0
  57. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-ProjectionDetail.js +1120 -0
  58. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Projections.js +172 -0
  59. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-QueryPanel.js +119 -0
  60. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-RecordViewer.js +663 -0
  61. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Records.js +648 -0
  62. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Scanner.js +1017 -0
  63. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-SchemaDetail.js +1404 -0
  64. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-SchemaDocEditor.js +1036 -0
  65. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-SchemaEditor.js +636 -0
  66. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-SchemaResearch.js +357 -0
  67. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-SourceDetail.js +822 -0
  68. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-SourceEditor.js +1036 -0
  69. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-SourceResearch.js +487 -0
  70. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Sources.js +165 -0
  71. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-Throughput.js +439 -0
  72. package/source/services/web-app/pict-app-full/views/PictView-Facto-Full-TopBar.js +335 -0
  73. package/source/services/web-app/pict-app-full/views/projections/Facto-Projections-Constants.js +71 -0
  74. package/source/services/web-app/web/chart.min.js +20 -0
  75. package/source/services/web-app/web/codemirror-bundle.js +30099 -0
  76. package/source/services/web-app/web/css/facto-themes.css +467 -0
  77. package/source/services/web-app/web/css/facto.css +502 -0
  78. package/source/services/web-app/web/index.html +28 -0
  79. package/source/services/web-app/web/retold-facto.js +12138 -0
  80. package/source/services/web-app/web/retold-facto.js.map +1 -0
  81. package/source/services/web-app/web/retold-facto.min.js +2 -0
  82. package/source/services/web-app/web/retold-facto.min.js.map +1 -0
  83. package/source/services/web-app/web/simple/index.html +17 -0
  84. package/test/Facto_Browser_Integration_tests.js +798 -0
  85. package/test/RetoldFacto_tests.js +4117 -0
  86. package/test/fixtures/weather-readings.csv +17 -0
  87. package/test/fixtures/weather-stations.csv +9 -0
  88. package/test/model/MeadowModel-Extended.json +8497 -0
  89. package/test/model/MeadowModel-PICT.json +1 -0
  90. package/test/model/MeadowModel.json +1355 -0
  91. package/test/model/ddl/Facto.ddl +225 -0
  92. package/test/model/fable-configuration.json +14 -0
@@ -0,0 +1,1017 @@
1
+ const libPictView = require('pict-view');
2
+
3
+ const _ViewConfiguration =
4
+ {
5
+ ViewIdentifier: "Facto-Full-Scanner",
6
+
7
+ DefaultRenderable: "Facto-Full-Scanner-Content",
8
+ DefaultDestinationAddress: "#Facto-Full-Content-Container",
9
+
10
+ AutoRender: false,
11
+
12
+ CSS: /*css*/`
13
+ .facto-scanner-summary {
14
+ display: flex;
15
+ gap: 1.5em;
16
+ padding: 0.75em 1em;
17
+ background: var(--facto-bg-elevated);
18
+ border-radius: 6px;
19
+ margin-bottom: 1.25em;
20
+ font-size: 0.9em;
21
+ }
22
+ .facto-scanner-summary-stat {
23
+ display: flex;
24
+ gap: 0.4em;
25
+ align-items: baseline;
26
+ }
27
+ .facto-scanner-summary-stat strong {
28
+ font-size: 1.2em;
29
+ color: var(--facto-text-heading);
30
+ }
31
+ .facto-scanner-add-path {
32
+ display: flex;
33
+ gap: 0.75em;
34
+ margin-bottom: 1.25em;
35
+ }
36
+ .facto-scanner-add-path input {
37
+ flex: 1;
38
+ margin-bottom: 0;
39
+ }
40
+ .facto-scanner-filters {
41
+ display: flex;
42
+ gap: 0.75em;
43
+ margin-bottom: 0.75em;
44
+ align-items: flex-end;
45
+ }
46
+ .facto-scanner-filters input {
47
+ flex: 3;
48
+ margin-bottom: 0;
49
+ }
50
+ .facto-scanner-filters select {
51
+ flex: 1;
52
+ margin-bottom: 0;
53
+ }
54
+ .facto-scanner-detail-panel {
55
+ margin-top: 1.25em;
56
+ padding: 1.25em;
57
+ background: var(--facto-bg-elevated);
58
+ border: 1px solid var(--facto-border-subtle);
59
+ border-radius: 8px;
60
+ }
61
+ .facto-scanner-detail-grid {
62
+ display: grid;
63
+ grid-template-columns: 1fr 1fr;
64
+ gap: 0.5em 1.5em;
65
+ font-size: 0.9em;
66
+ margin-bottom: 1em;
67
+ }
68
+ .facto-scanner-description {
69
+ color: var(--facto-text-secondary);
70
+ font-size: 0.9em;
71
+ margin-top: 0.35em;
72
+ max-height: 100px;
73
+ overflow-y: auto;
74
+ white-space: pre-wrap;
75
+ }
76
+ `,
77
+
78
+ Templates:
79
+ [
80
+ {
81
+ Hash: "Facto-Full-Scanner-Template",
82
+ Template: /*html*/`
83
+ <div class="facto-content">
84
+ <div class="facto-content-header">
85
+ <h1>Source Folder Scanner</h1>
86
+ <p>Point the scanner at folder trees containing dataset research (README.md + data/). Discovered datasets can be provisioned into the database individually or in bulk.</p>
87
+ </div>
88
+
89
+ <!-- Summary bar -->
90
+ <div class="facto-scanner-summary" id="Facto-Full-Scanner-Summary">
91
+ <span style="color:var(--facto-text-secondary);">No datasets loaded</span>
92
+ </div>
93
+
94
+ <!-- Add scan path -->
95
+ <div class="facto-scanner-add-path">
96
+ <input type="text" id="Facto-Full-Scanner-PathInput" placeholder="/path/to/dataset/folder/tree">
97
+ <button class="facto-btn facto-btn-primary" onclick="{~P~}.views['Facto-Full-Scanner'].addPath()">Add &amp; Scan</button>
98
+ <button class="facto-btn facto-btn-secondary" onclick="{~P~}.views['Facto-Full-Scanner'].rescanAll()">Re-scan All</button>
99
+ </div>
100
+
101
+ <!-- Scan paths list -->
102
+ <div id="Facto-Full-Scanner-PathsList" style="margin-bottom:1.5em;"></div>
103
+
104
+ <!-- Filter bar -->
105
+ <div class="facto-scanner-filters">
106
+ <input type="text" id="Facto-Full-Scanner-Search" placeholder="Search by name, title, or provider..." oninput="{~P~}.views['Facto-Full-Scanner'].filterDatasets()">
107
+ <select id="Facto-Full-Scanner-StatusFilter" onchange="{~P~}.views['Facto-Full-Scanner'].filterDatasets()">
108
+ <option value="">All Statuses</option>
109
+ <option value="Discovered">Discovered</option>
110
+ <option value="Provisioned">Provisioned</option>
111
+ <option value="Ingested">Ingested</option>
112
+ <option value="Error">Error</option>
113
+ </select>
114
+ <button class="facto-btn facto-btn-success" onclick="{~P~}.views['Facto-Full-Scanner'].provisionSelected()">Provision Selected</button>
115
+ <button class="facto-btn facto-btn-success" onclick="{~P~}.views['Facto-Full-Scanner'].provisionAll()">Provision All</button>
116
+ </div>
117
+
118
+ <!-- Datasets table -->
119
+ <div id="Facto-Full-Scanner-DatasetsList"></div>
120
+ </div>
121
+ `
122
+ }
123
+ ],
124
+
125
+ Renderables:
126
+ [
127
+ {
128
+ RenderableHash: "Facto-Full-Scanner-Content",
129
+ TemplateHash: "Facto-Full-Scanner-Template",
130
+ DestinationAddress: "#Facto-Full-Content-Container",
131
+ RenderMethod: "replace"
132
+ }
133
+ ]
134
+ };
135
+
136
+ class FactoFullScannerView extends libPictView
137
+ {
138
+ constructor(pFable, pOptions, pServiceHash)
139
+ {
140
+ super(pFable, pOptions, pServiceHash);
141
+ }
142
+
143
+ onAfterRender(pRenderable, pRenderDestinationAddress, pRecord, pContent)
144
+ {
145
+ this.loadScannerState();
146
+ return super.onAfterRender(pRenderable, pRenderDestinationAddress, pRecord, pContent);
147
+ }
148
+
149
+
150
+
151
+ loadScannerState()
152
+ {
153
+ Promise.all(
154
+ [
155
+ this.pict.providers.Facto.loadScannerPaths(),
156
+ this.pict.providers.Facto.loadScannerDatasets()
157
+ ]).then(
158
+ () =>
159
+ {
160
+ this.refreshPathsList();
161
+ this.refreshDatasetsList();
162
+ }).catch(
163
+ (pError) =>
164
+ {
165
+ this.pict.views['Pict-Section-Modal'].toast('Error loading scanner state: ' + pError.message, {type: 'error'});
166
+ });
167
+ }
168
+
169
+ refreshPathsList()
170
+ {
171
+ let tmpContainer = document.getElementById('Facto-Full-Scanner-PathsList');
172
+ if (!tmpContainer) return;
173
+
174
+ let tmpPaths = this.pict.AppData.Facto.ScannerPaths || [];
175
+
176
+ if (tmpPaths.length === 0)
177
+ {
178
+ tmpContainer.innerHTML = '<div class="facto-empty">No scan paths configured. Add a folder path to discover datasets.</div>';
179
+ return;
180
+ }
181
+
182
+ let tmpHtml = '<table><thead><tr><th>Path</th><th>Datasets</th><th>Last Scanned</th><th>Actions</th></tr></thead><tbody>';
183
+ for (let i = 0; i < tmpPaths.length; i++)
184
+ {
185
+ let tmpPath = tmpPaths[i];
186
+ let tmpLastScanned = tmpPath.LastScannedAt ? new Date(tmpPath.LastScannedAt).toLocaleString() : 'Never';
187
+ tmpHtml += '<tr>';
188
+ tmpHtml += '<td style="font-family:\'SF Mono\', Consolas, monospace; font-size:0.85em;">' + this.escapeHtml(tmpPath.Path) + '</td>';
189
+ tmpHtml += '<td>' + (tmpPath.DatasetCount || 0) + '</td>';
190
+ tmpHtml += '<td>' + tmpLastScanned + '</td>';
191
+ tmpHtml += '<td><button class="facto-btn facto-btn-danger facto-btn-small" onclick="pict.views[\'Facto-Full-Scanner\'].removePath(\'' + this.escapeAttr(tmpPath.Path) + '\')">Remove</button></td>';
192
+ tmpHtml += '</tr>';
193
+ }
194
+ tmpHtml += '</tbody></table>';
195
+ tmpContainer.innerHTML = tmpHtml;
196
+ }
197
+
198
+ refreshDatasetsList()
199
+ {
200
+ let tmpContainer = document.getElementById('Facto-Full-Scanner-DatasetsList');
201
+ if (!tmpContainer) return;
202
+
203
+ let tmpDatasets = this.pict.AppData.Facto.ScannerDatasets || [];
204
+
205
+ if (tmpDatasets.length === 0)
206
+ {
207
+ tmpContainer.innerHTML = '<div class="facto-empty">No datasets discovered yet. Add a scan path containing dataset folders with README.md files.</div>';
208
+ this.updateSummary(0, 0, 0, 0);
209
+ return;
210
+ }
211
+
212
+ // Compute summary stats
213
+ let tmpDiscovered = 0;
214
+ let tmpProvisioned = 0;
215
+ let tmpIngested = 0;
216
+ let tmpWithData = 0;
217
+ for (let i = 0; i < tmpDatasets.length; i++)
218
+ {
219
+ if (tmpDatasets[i].Status === 'Discovered') tmpDiscovered++;
220
+ if (tmpDatasets[i].Status === 'Provisioned') tmpProvisioned++;
221
+ if (tmpDatasets[i].Status === 'Ingested') tmpIngested++;
222
+ if (tmpDatasets[i].HasData) tmpWithData++;
223
+ }
224
+ this.updateSummary(tmpDatasets.length, tmpDiscovered, tmpProvisioned, tmpWithData);
225
+
226
+ // Apply search filter
227
+ let tmpSearchEl = document.getElementById('Facto-Full-Scanner-Search');
228
+ let tmpSearchTerm = tmpSearchEl ? tmpSearchEl.value.toLowerCase() : '';
229
+
230
+ let tmpStatusFilter = document.getElementById('Facto-Full-Scanner-StatusFilter');
231
+ let tmpStatusValue = tmpStatusFilter ? tmpStatusFilter.value : '';
232
+
233
+ let tmpFiltered = tmpDatasets;
234
+ if (tmpSearchTerm)
235
+ {
236
+ tmpFiltered = tmpFiltered.filter(
237
+ (pDS) =>
238
+ {
239
+ let tmpText = ((pDS.FolderName || '') + ' ' + (pDS.Title || '') + ' ' + (pDS.Provider || '')).toLowerCase();
240
+ return tmpText.indexOf(tmpSearchTerm) > -1;
241
+ });
242
+ }
243
+ if (tmpStatusValue)
244
+ {
245
+ tmpFiltered = tmpFiltered.filter(
246
+ (pDS) =>
247
+ {
248
+ return pDS.Status === tmpStatusValue;
249
+ });
250
+ }
251
+
252
+ let tmpHtml = '<table><thead><tr>';
253
+ tmpHtml += '<th><input type="checkbox" id="Facto-Full-Scanner-SelectAll" onclick="pict.views[\'Facto-Full-Scanner\'].toggleSelectAll(this.checked)"></th>';
254
+ tmpHtml += '<th>Name</th><th>Title</th><th>Provider</th><th>Format</th><th>Data</th><th>Status</th><th>Actions</th>';
255
+ tmpHtml += '</tr></thead><tbody>';
256
+
257
+ for (let i = 0; i < tmpFiltered.length; i++)
258
+ {
259
+ let tmpDS = tmpFiltered[i];
260
+ let tmpStatusBadge = this.getStatusBadge(tmpDS.Status);
261
+ let tmpDataInfo = tmpDS.HasData
262
+ ? (tmpDS.DataFileCount + ' file' + (tmpDS.DataFileCount !== 1 ? 's' : '') + ' (' + (tmpDS.TotalDataSizeFormatted || '0 B') + ')')
263
+ : '<span style="color:var(--facto-text-danger, #dc3545);">No data</span>';
264
+ let tmpFormat = tmpDS.DataFormat ? (tmpDS.DataFormat.Format || 'unknown') : 'unknown';
265
+ let tmpEscFolder = this.escapeAttr(tmpDS.FolderName);
266
+
267
+ tmpHtml += '<tr id="facto-scanner-row-' + tmpEscFolder + '">';
268
+ tmpHtml += '<td><input type="checkbox" class="facto-scanner-checkbox" data-folder="' + tmpEscFolder + '"></td>';
269
+ tmpHtml += '<td style="font-family:\'SF Mono\', Consolas, monospace; font-size:0.85em;">' + this.escapeHtml(tmpDS.FolderName) + '</td>';
270
+ tmpHtml += '<td>' + this.escapeHtml((tmpDS.Title || '').substring(0, 50)) + '</td>';
271
+ tmpHtml += '<td>' + this.escapeHtml((tmpDS.Provider || '').substring(0, 30)) + '</td>';
272
+ tmpHtml += '<td><span class="facto-badge facto-badge-primary">' + tmpFormat + '</span></td>';
273
+ tmpHtml += '<td>' + tmpDataInfo + '</td>';
274
+ tmpHtml += '<td>' + tmpStatusBadge + '</td>';
275
+ tmpHtml += '<td>';
276
+ tmpHtml += '<div class="facto-row-actions" id="facto-row-actions-' + tmpEscFolder + '">';
277
+ tmpHtml += '<button class="facto-row-actions-trigger" onclick="pict.views[\'Facto-Full-Scanner\'].toggleRowMenu(event, \'' + tmpEscFolder + '\')" title="Actions">&#8942;</button>';
278
+ tmpHtml += '<div class="facto-row-actions-menu">';
279
+ tmpHtml += '<button onclick="pict.views[\'Facto-Full-Scanner\'].viewDetail(\'' + tmpEscFolder + '\'); pict.views[\'Facto-Full-Scanner\'].closeRowMenus();">Detail</button>';
280
+ if ((tmpDS.Status === 'Provisioned' || tmpDS.Status === 'Discovered') && tmpDS.HasData)
281
+ {
282
+ tmpHtml += '<button class="facto-action-primary" onclick="pict.views[\'Facto-Full-Scanner\'].ingestOne(\'' + tmpEscFolder + '\'); pict.views[\'Facto-Full-Scanner\'].closeRowMenus();">Ingest</button>';
283
+ }
284
+ if (tmpDS.Status === 'Discovered')
285
+ {
286
+ tmpHtml += '<button class="facto-action-success" onclick="pict.views[\'Facto-Full-Scanner\'].provisionOne(\'' + tmpEscFolder + '\'); pict.views[\'Facto-Full-Scanner\'].closeRowMenus();">Provision</button>';
287
+ }
288
+ tmpHtml += '</div>';
289
+ tmpHtml += '</div>';
290
+ tmpHtml += '</td>';
291
+ tmpHtml += '</tr>';
292
+ }
293
+ tmpHtml += '</tbody></table>';
294
+ tmpHtml += '<div style="color:var(--facto-text-secondary); font-size:0.85em; margin-top:0.5em;">Showing ' + tmpFiltered.length + ' of ' + tmpDatasets.length + ' dataset(s)</div>';
295
+
296
+ tmpContainer.innerHTML = tmpHtml;
297
+ }
298
+
299
+ updateSummary(pTotal, pDiscovered, pProvisioned, pWithData)
300
+ {
301
+ let tmpEl = document.getElementById('Facto-Full-Scanner-Summary');
302
+ if (!tmpEl) return;
303
+
304
+ tmpEl.innerHTML = '<div class="facto-scanner-summary-stat"><strong>' + pTotal + '</strong> discovered</div>'
305
+ + '<div class="facto-scanner-summary-stat"><strong>' + pProvisioned + '</strong> provisioned</div>'
306
+ + '<div class="facto-scanner-summary-stat"><strong>' + pWithData + '</strong> with data</div>';
307
+ }
308
+
309
+ getStatusBadge(pStatus)
310
+ {
311
+ if (pStatus === 'Discovered') return '<span class="facto-badge facto-badge-info">Discovered</span>';
312
+ if (pStatus === 'Provisioned') return '<span class="facto-badge facto-badge-success">Provisioned</span>';
313
+ if (pStatus === 'Ingested') return '<span class="facto-badge facto-badge-warning">Ingested</span>';
314
+ if (pStatus === 'Error') return '<span class="facto-badge facto-badge-danger">Error</span>';
315
+ return '<span class="facto-badge facto-badge-muted">' + (pStatus || '') + '</span>';
316
+ }
317
+
318
+ escapeHtml(pStr)
319
+ {
320
+ if (!pStr) return '';
321
+ return pStr.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
322
+ }
323
+
324
+ escapeAttr(pStr)
325
+ {
326
+ if (!pStr) return '';
327
+ return pStr.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '&quot;');
328
+ }
329
+
330
+ formatSize(pBytes)
331
+ {
332
+ if (pBytes === 0) return '0 B';
333
+ let tmpUnits = ['B', 'KB', 'MB', 'GB'];
334
+ let tmpI = Math.floor(Math.log(pBytes) / Math.log(1024));
335
+ if (tmpI >= tmpUnits.length) tmpI = tmpUnits.length - 1;
336
+ return (pBytes / Math.pow(1024, tmpI)).toFixed(tmpI > 0 ? 1 : 0) + ' ' + tmpUnits[tmpI];
337
+ }
338
+
339
+ // ================================================================
340
+ // Actions
341
+ // ================================================================
342
+
343
+ addPath()
344
+ {
345
+ let tmpPathInput = document.getElementById('Facto-Full-Scanner-PathInput');
346
+ let tmpPath = tmpPathInput ? tmpPathInput.value.trim() : '';
347
+
348
+ if (!tmpPath)
349
+ {
350
+ this.pict.views['Pict-Section-Modal'].toast('Enter a folder path to scan', {type: 'warning'});
351
+ return;
352
+ }
353
+
354
+ this.pict.views['Pict-Section-Modal'].toast('Scanning ' + tmpPath + '...', {type: 'info'});
355
+
356
+ this.pict.providers.Facto.addScannerPath(tmpPath).then(
357
+ (pResponse) =>
358
+ {
359
+ if (pResponse && pResponse.Error)
360
+ {
361
+ this.pict.views['Pict-Section-Modal'].toast('Error: ' + pResponse.Error, {type: 'error'});
362
+ return;
363
+ }
364
+ let tmpResult = pResponse.ScanResult || {};
365
+ this.pict.views['Pict-Section-Modal'].toast('Scanned! Found ' + (tmpResult.DatasetsFound || 0) + ' dataset(s) in ' + (tmpResult.FoldersScanned || 0) + ' folder(s)', {type: 'success'});
366
+ if (tmpPathInput) tmpPathInput.value = '';
367
+ this.loadScannerState();
368
+ }).catch(
369
+ (pError) =>
370
+ {
371
+ this.pict.views['Pict-Section-Modal'].toast('Error: ' + pError.message, {type: 'error'});
372
+ });
373
+ }
374
+
375
+ async removePath(pPath)
376
+ {
377
+ let tmpConfirmed = await this.pict.views['Pict-Section-Modal'].confirm('Remove scan path and its discovered datasets?\n\n' + pPath, { title: 'Remove Path', confirmLabel: 'Remove', dangerous: true });
378
+ if (!tmpConfirmed) return;
379
+
380
+ this.pict.providers.Facto.removeScannerPath(pPath).then(
381
+ (pResponse) =>
382
+ {
383
+ if (pResponse && pResponse.Error)
384
+ {
385
+ this.pict.views['Pict-Section-Modal'].toast('Error: ' + pResponse.Error, {type: 'error'});
386
+ return;
387
+ }
388
+ this.pict.views['Pict-Section-Modal'].toast('Path removed', {type: 'success'});
389
+ this.loadScannerState();
390
+ }).catch(
391
+ (pError) =>
392
+ {
393
+ this.pict.views['Pict-Section-Modal'].toast('Error: ' + pError.message, {type: 'error'});
394
+ });
395
+ }
396
+
397
+ rescanAll()
398
+ {
399
+ this.pict.views['Pict-Section-Modal'].toast('Re-scanning all paths...', {type: 'info'});
400
+
401
+ this.pict.providers.Facto.rescanPaths().then(
402
+ (pResponse) =>
403
+ {
404
+ if (pResponse && pResponse.Error)
405
+ {
406
+ this.pict.views['Pict-Section-Modal'].toast('Error: ' + pResponse.Error, {type: 'error'});
407
+ return;
408
+ }
409
+ this.pict.views['Pict-Section-Modal'].toast('Re-scan complete', {type: 'success'});
410
+ this.loadScannerState();
411
+ }).catch(
412
+ (pError) =>
413
+ {
414
+ this.pict.views['Pict-Section-Modal'].toast('Error: ' + pError.message, {type: 'error'});
415
+ });
416
+ }
417
+
418
+ filterDatasets()
419
+ {
420
+ this.refreshDatasetsList();
421
+ }
422
+
423
+ toggleSelectAll(pChecked)
424
+ {
425
+ let tmpCheckboxes = document.querySelectorAll('.facto-scanner-checkbox');
426
+ for (let i = 0; i < tmpCheckboxes.length; i++)
427
+ {
428
+ tmpCheckboxes[i].checked = pChecked;
429
+ }
430
+ }
431
+
432
+ getSelectedFolderNames()
433
+ {
434
+ let tmpCheckboxes = document.querySelectorAll('.facto-scanner-checkbox:checked');
435
+ let tmpNames = [];
436
+ for (let i = 0; i < tmpCheckboxes.length; i++)
437
+ {
438
+ tmpNames.push(tmpCheckboxes[i].getAttribute('data-folder'));
439
+ }
440
+ return tmpNames;
441
+ }
442
+
443
+ provisionOne(pFolderName)
444
+ {
445
+ this.pict.views['Pict-Section-Modal'].toast('Provisioning ' + pFolderName + '...', {type: 'info'});
446
+
447
+ this.pict.providers.Facto.provisionScannerDataset(pFolderName).then(
448
+ (pResponse) =>
449
+ {
450
+ if (pResponse && pResponse.Error)
451
+ {
452
+ this.pict.views['Pict-Section-Modal'].toast('Error: ' + pResponse.Error, {type: 'error'});
453
+ return;
454
+ }
455
+ this.pict.views['Pict-Section-Modal'].toast(
456
+ 'Provisioned ' + pFolderName + ' (Source #' + (pResponse.Source ? pResponse.Source.IDSource : '?') + ', Dataset #' + (pResponse.Dataset ? pResponse.Dataset.IDDataset : '?') + ')', {type: 'success'});
457
+ this.loadScannerState();
458
+ }).catch(
459
+ (pError) =>
460
+ {
461
+ this.pict.views['Pict-Section-Modal'].toast('Error: ' + pError.message, {type: 'error'});
462
+ });
463
+ }
464
+
465
+ ingestOne(pFolderName)
466
+ {
467
+ this.pict.views['Pict-Section-Modal'].toast('Ingesting ' + pFolderName + '...', {type: 'info'});
468
+
469
+ this.pict.providers.Facto.ingestScannerDataset(pFolderName).then(
470
+ (pResponse) =>
471
+ {
472
+ if (pResponse && pResponse.Error)
473
+ {
474
+ this.pict.views['Pict-Section-Modal'].toast('Ingest error: ' + pResponse.Error, {type: 'error'});
475
+ return;
476
+ }
477
+ let tmpMsg = 'Ingested ' + pFolderName;
478
+ if (pResponse.RecordsIngested !== undefined)
479
+ {
480
+ tmpMsg += ' — ' + pResponse.RecordsIngested + ' records';
481
+ }
482
+ if (pResponse.File)
483
+ {
484
+ tmpMsg += ' from ' + pResponse.File;
485
+ }
486
+ this.pict.views['Pict-Section-Modal'].toast(tmpMsg, {type: 'success'});
487
+ this.loadScannerState();
488
+ }).catch(
489
+ (pError) =>
490
+ {
491
+ this.pict.views['Pict-Section-Modal'].toast('Ingest error: ' + pError.message, {type: 'error'});
492
+ });
493
+ }
494
+
495
+ async provisionSelected()
496
+ {
497
+ let tmpSelected = this.getSelectedFolderNames();
498
+ if (tmpSelected.length === 0)
499
+ {
500
+ this.pict.views['Pict-Section-Modal'].toast('Select datasets to provision using the checkboxes', {type: 'warning'});
501
+ return;
502
+ }
503
+
504
+ let tmpConfirmed = await this.pict.views['Pict-Section-Modal'].confirm('Provision ' + tmpSelected.length + ' selected dataset(s)?', { title: 'Provision Selected', confirmLabel: 'Provision' });
505
+ if (!tmpConfirmed) return;
506
+
507
+ this.provisionBatch(tmpSelected, 0, 0, 0);
508
+ }
509
+
510
+ async provisionAll()
511
+ {
512
+ let tmpConfirmed = await this.pict.views['Pict-Section-Modal'].confirm('Provision ALL discovered datasets?', { title: 'Provision All', confirmLabel: 'Provision All', dangerous: true });
513
+ if (!tmpConfirmed) return;
514
+
515
+ this.pict.views['Pict-Section-Modal'].toast('Provisioning all datasets...', {type: 'info'});
516
+
517
+ this.pict.providers.Facto.provisionAllScannerDatasets().then(
518
+ (pResponse) =>
519
+ {
520
+ if (pResponse && pResponse.Error)
521
+ {
522
+ this.pict.views['Pict-Section-Modal'].toast('Error: ' + pResponse.Error, {type: 'error'});
523
+ return;
524
+ }
525
+ this.pict.views['Pict-Section-Modal'].toast(
526
+ 'Provisioned ' + pResponse.Provisioned + ' of ' + pResponse.Total + ' (' + pResponse.Errors + ' error(s))', {type: 'success'});
527
+ this.loadScannerState();
528
+ }).catch(
529
+ (pError) =>
530
+ {
531
+ this.pict.views['Pict-Section-Modal'].toast('Error: ' + pError.message, {type: 'error'});
532
+ });
533
+ }
534
+
535
+ provisionBatch(pFolderNames, pIndex, pSuccessCount, pErrorCount)
536
+ {
537
+ if (pIndex >= pFolderNames.length)
538
+ {
539
+ this.pict.views['Pict-Section-Modal'].toast(
540
+ 'Provisioned ' + pSuccessCount + ' of ' + pFolderNames.length + ' (' + pErrorCount + ' error(s))', {type: 'success'});
541
+ this.loadScannerState();
542
+ return;
543
+ }
544
+
545
+ let tmpName = pFolderNames[pIndex];
546
+ this.pict.views['Pict-Section-Modal'].toast('Provisioning ' + (pIndex + 1) + '/' + pFolderNames.length + ': ' + tmpName + '...', {type: 'info'});
547
+
548
+ this.pict.providers.Facto.provisionScannerDataset(tmpName).then(
549
+ (pResponse) =>
550
+ {
551
+ if (pResponse && pResponse.Error)
552
+ {
553
+ this.provisionBatch(pFolderNames, pIndex + 1, pSuccessCount, pErrorCount + 1);
554
+ }
555
+ else
556
+ {
557
+ this.provisionBatch(pFolderNames, pIndex + 1, pSuccessCount + 1, pErrorCount);
558
+ }
559
+ }).catch(
560
+ () =>
561
+ {
562
+ this.provisionBatch(pFolderNames, pIndex + 1, pSuccessCount, pErrorCount + 1);
563
+ });
564
+ }
565
+
566
+ // ================================================================
567
+ // Ingestion Plan
568
+ // ================================================================
569
+
570
+ loadIngestionPlan(pFolderName)
571
+ {
572
+ let tmpContainer = document.getElementById('facto-scanner-plan-' + pFolderName);
573
+ if (!tmpContainer) return;
574
+
575
+ tmpContainer.innerHTML = '<span style="color:var(--facto-text-secondary);">Loading ingestion plan...</span>';
576
+
577
+ this.pict.providers.Facto.loadIngestionPlan(pFolderName).then(
578
+ (pPlan) =>
579
+ {
580
+ if (pPlan && pPlan.Error)
581
+ {
582
+ tmpContainer.innerHTML = '<div class="facto-status facto-status-error" style="margin:0;">' + pPlan.Error + '</div>';
583
+ return;
584
+ }
585
+
586
+ this.renderIngestionPlan(pFolderName, pPlan);
587
+ }).catch(
588
+ (pError) =>
589
+ {
590
+ tmpContainer.innerHTML = '<div class="facto-status facto-status-error" style="margin:0;">Error: ' + pError.message + '</div>';
591
+ });
592
+ }
593
+
594
+ renderIngestionPlan(pFolderName, pPlan)
595
+ {
596
+ let tmpContainer = document.getElementById('facto-scanner-plan-' + pFolderName);
597
+ if (!tmpContainer) return;
598
+
599
+ let tmpFiles = pPlan.files || [];
600
+ let tmpEscFolder = this.escapeAttr(pFolderName);
601
+
602
+ let tmpHtml = '';
603
+
604
+ // Plan metadata
605
+ tmpHtml += '<div style="font-size:0.85em; color:var(--facto-text-secondary); margin-bottom:0.75em;">';
606
+ if (pPlan.autoGenerated)
607
+ {
608
+ tmpHtml += 'Auto-generated ' + new Date(pPlan.generatedAt).toLocaleString();
609
+ }
610
+ else
611
+ {
612
+ tmpHtml += 'Modified ' + new Date(pPlan.modifiedAt).toLocaleString();
613
+ }
614
+ tmpHtml += ' &mdash; ' + tmpFiles.filter((pF) => pF.include).length + ' of ' + tmpFiles.length + ' file(s) included';
615
+ tmpHtml += '</div>';
616
+
617
+ if (tmpFiles.length === 0)
618
+ {
619
+ tmpHtml += '<div class="facto-empty">No data files found for plan generation.</div>';
620
+ tmpContainer.innerHTML = tmpHtml;
621
+ return;
622
+ }
623
+
624
+ // Plan table
625
+ tmpHtml += '<table style="font-size:0.9em;"><thead><tr>';
626
+ tmpHtml += '<th>Include</th><th>File</th><th>Record Type</th><th>Format</th><th>Order</th><th>Primary Key</th><th>Notes</th>';
627
+ tmpHtml += '</tr></thead><tbody>';
628
+
629
+ for (let i = 0; i < tmpFiles.length; i++)
630
+ {
631
+ let tmpFile = tmpFiles[i];
632
+ let tmpRowStyle = tmpFile.include ? '' : ' style="opacity:0.5;"';
633
+ tmpHtml += '<tr' + tmpRowStyle + '>';
634
+
635
+ // Include checkbox
636
+ tmpHtml += '<td><input type="checkbox" class="facto-plan-include" data-index="' + i + '"' + (tmpFile.include ? ' checked' : '') + '></td>';
637
+
638
+ // File name (read-only)
639
+ tmpHtml += '<td style="font-family:\'SF Mono\', Consolas, monospace; font-size:0.85em;">' + this.escapeHtml(tmpFile.fileName) + '</td>';
640
+
641
+ // Record type (editable)
642
+ tmpHtml += '<td><input type="text" class="facto-plan-recordtype" data-index="' + i + '" value="' + this.escapeHtml(tmpFile.recordType || '') + '" style="width:120px; font-size:0.9em; padding:2px 4px; margin:0;"></td>';
643
+
644
+ // Format (read-only)
645
+ tmpHtml += '<td><span class="facto-badge facto-badge-primary">' + (tmpFile.format || '') + '</span></td>';
646
+
647
+ // Order (editable)
648
+ tmpHtml += '<td><input type="number" class="facto-plan-order" data-index="' + i + '" value="' + (tmpFile.order || i + 1) + '" style="width:50px; font-size:0.9em; padding:2px 4px; margin:0;" min="1"></td>';
649
+
650
+ // Primary key (read-only display)
651
+ tmpHtml += '<td style="font-size:0.85em; color:var(--facto-text-secondary);">' + this.escapeHtml(tmpFile.primaryKey || '') + '</td>';
652
+
653
+ // Notes (editable)
654
+ tmpHtml += '<td><input type="text" class="facto-plan-notes" data-index="' + i + '" value="' + this.escapeHtml(tmpFile.notes || '') + '" style="width:150px; font-size:0.85em; padding:2px 4px; margin:0;" placeholder="optional"></td>';
655
+
656
+ tmpHtml += '</tr>';
657
+ }
658
+
659
+ tmpHtml += '</tbody></table>';
660
+
661
+ // Action buttons
662
+ tmpHtml += '<div style="margin-top:0.75em;">';
663
+ tmpHtml += '<button class="facto-btn facto-btn-success facto-btn-small" onclick="pict.views[\'Facto-Full-Scanner\'].saveIngestionPlan(\'' + tmpEscFolder + '\')">Save Plan</button> ';
664
+ tmpHtml += '<button class="facto-btn facto-btn-primary facto-btn-small" onclick="pict.views[\'Facto-Full-Scanner\'].ingestFromPlan(\'' + tmpEscFolder + '\')">Ingest from Plan</button>';
665
+ tmpHtml += '</div>';
666
+
667
+ tmpContainer.innerHTML = tmpHtml;
668
+
669
+ // Store the plan for later reference
670
+ this._currentPlan = pPlan;
671
+ this._currentPlanFolder = pFolderName;
672
+ }
673
+
674
+ collectPlanFromDOM(pFolderName)
675
+ {
676
+ if (!this._currentPlan || this._currentPlanFolder !== pFolderName)
677
+ {
678
+ return null;
679
+ }
680
+
681
+ let tmpPlan = JSON.parse(JSON.stringify(this._currentPlan));
682
+
683
+ for (let i = 0; i < tmpPlan.files.length; i++)
684
+ {
685
+ let tmpIncludeEl = document.querySelector('.facto-plan-include[data-index="' + i + '"]');
686
+ let tmpRecordTypeEl = document.querySelector('.facto-plan-recordtype[data-index="' + i + '"]');
687
+ let tmpOrderEl = document.querySelector('.facto-plan-order[data-index="' + i + '"]');
688
+ let tmpNotesEl = document.querySelector('.facto-plan-notes[data-index="' + i + '"]');
689
+
690
+ if (tmpIncludeEl) tmpPlan.files[i].include = tmpIncludeEl.checked;
691
+ if (tmpRecordTypeEl) tmpPlan.files[i].recordType = tmpRecordTypeEl.value;
692
+ if (tmpOrderEl) tmpPlan.files[i].order = parseInt(tmpOrderEl.value, 10) || (i + 1);
693
+ if (tmpNotesEl) tmpPlan.files[i].notes = tmpNotesEl.value;
694
+ }
695
+
696
+ return tmpPlan;
697
+ }
698
+
699
+ saveIngestionPlan(pFolderName)
700
+ {
701
+ let tmpPlan = this.collectPlanFromDOM(pFolderName);
702
+ if (!tmpPlan)
703
+ {
704
+ this.pict.views['Pict-Section-Modal'].toast('No plan loaded to save', {type: 'warning'});
705
+ return;
706
+ }
707
+
708
+ this.pict.views['Pict-Section-Modal'].toast('Saving ingestion plan...', {type: 'info'});
709
+
710
+ this.pict.providers.Facto.saveIngestionPlan(pFolderName, tmpPlan).then(
711
+ (pResponse) =>
712
+ {
713
+ if (pResponse && pResponse.Error)
714
+ {
715
+ this.pict.views['Pict-Section-Modal'].toast('Save error: ' + pResponse.Error, {type: 'error'});
716
+ return;
717
+ }
718
+ this.pict.views['Pict-Section-Modal'].toast('Ingestion plan saved for ' + pFolderName, {type: 'success'});
719
+ if (pResponse.Plan)
720
+ {
721
+ this._currentPlan = pResponse.Plan;
722
+ this.renderIngestionPlan(pFolderName, pResponse.Plan);
723
+ }
724
+ }).catch(
725
+ (pError) =>
726
+ {
727
+ this.pict.views['Pict-Section-Modal'].toast('Save error: ' + pError.message, {type: 'error'});
728
+ });
729
+ }
730
+
731
+ async ingestFromPlan(pFolderName)
732
+ {
733
+ // If we have a plan loaded in the DOM, save it first
734
+ let tmpPlan = this.collectPlanFromDOM(pFolderName);
735
+ if (tmpPlan)
736
+ {
737
+ let tmpIncluded = tmpPlan.files.filter((pF) => pF.include);
738
+ let tmpConfirmed = await this.pict.views['Pict-Section-Modal'].confirm('Ingest ' + tmpIncluded.length + ' file(s) from the ingestion plan for ' + pFolderName + '?', { title: 'Ingest from Plan', confirmLabel: 'Ingest' });
739
+ if (!tmpConfirmed)
740
+ {
741
+ return;
742
+ }
743
+
744
+ // Save changes first, then ingest
745
+ this.pict.views['Pict-Section-Modal'].toast('Saving plan and starting ingestion...', {type: 'info'});
746
+
747
+ this.pict.providers.Facto.saveIngestionPlan(pFolderName, tmpPlan).then(
748
+ () =>
749
+ {
750
+ return this.pict.providers.Facto.ingestScannerDataset(pFolderName, { useIngestionPlan: true });
751
+ }).then(
752
+ (pResponse) =>
753
+ {
754
+ if (pResponse && pResponse.Error)
755
+ {
756
+ this.pict.views['Pict-Section-Modal'].toast('Ingest error: ' + pResponse.Error, {type: 'error'});
757
+ return;
758
+ }
759
+ let tmpMsg = 'Ingested ' + (pResponse.FilesIngested || 0) + ' file(s), '
760
+ + (pResponse.TotalRecords || 0) + ' records';
761
+ if (pResponse.FilesErrored > 0)
762
+ {
763
+ tmpMsg += ' (' + pResponse.FilesErrored + ' error(s))';
764
+ }
765
+ this.pict.views['Pict-Section-Modal'].toast(tmpMsg, {type: 'success'});
766
+ this.loadScannerState();
767
+ }).catch(
768
+ (pError) =>
769
+ {
770
+ this.pict.views['Pict-Section-Modal'].toast('Ingest error: ' + pError.message, {type: 'error'});
771
+ });
772
+ }
773
+ else
774
+ {
775
+ // No plan loaded — ingest with auto-generated plan
776
+ let tmpConfirmed2 = await this.pict.views['Pict-Section-Modal'].confirm('Ingest ' + pFolderName + ' using its ingestion plan?', { title: 'Ingest from Plan', confirmLabel: 'Ingest' });
777
+ if (!tmpConfirmed2)
778
+ {
779
+ return;
780
+ }
781
+
782
+ this.pict.views['Pict-Section-Modal'].toast('Ingesting from plan...', {type: 'info'});
783
+
784
+ this.pict.providers.Facto.ingestScannerDataset(pFolderName, { useIngestionPlan: true }).then(
785
+ (pResponse) =>
786
+ {
787
+ if (pResponse && pResponse.Error)
788
+ {
789
+ this.pict.views['Pict-Section-Modal'].toast('Ingest error: ' + pResponse.Error, {type: 'error'});
790
+ return;
791
+ }
792
+ let tmpMsg = 'Ingested ' + (pResponse.FilesIngested || 0) + ' file(s), '
793
+ + (pResponse.TotalRecords || 0) + ' records';
794
+ if (pResponse.FilesErrored > 0)
795
+ {
796
+ tmpMsg += ' (' + pResponse.FilesErrored + ' error(s))';
797
+ }
798
+ this.pict.views['Pict-Section-Modal'].toast(tmpMsg, {type: 'success'});
799
+ this.loadScannerState();
800
+ }).catch(
801
+ (pError) =>
802
+ {
803
+ this.pict.views['Pict-Section-Modal'].toast('Ingest error: ' + pError.message, {type: 'error'});
804
+ });
805
+ }
806
+ }
807
+
808
+ // ================================================================
809
+ // Row Actions Menu
810
+ // ================================================================
811
+
812
+ toggleRowMenu(pEvent, pFolderName)
813
+ {
814
+ pEvent.stopPropagation();
815
+ let tmpEl = document.getElementById('facto-row-actions-' + pFolderName);
816
+ if (!tmpEl) return;
817
+
818
+ let tmpWasOpen = tmpEl.classList.contains('open');
819
+
820
+ // Close all open menus first
821
+ this.closeRowMenus();
822
+
823
+ if (!tmpWasOpen)
824
+ {
825
+ tmpEl.classList.add('open');
826
+
827
+ // Close on outside click
828
+ let tmpCloseHandler = (pCloseEvent) =>
829
+ {
830
+ if (!tmpEl.contains(pCloseEvent.target))
831
+ {
832
+ tmpEl.classList.remove('open');
833
+ document.removeEventListener('click', tmpCloseHandler);
834
+ }
835
+ };
836
+ // Defer so the current click doesn't immediately close it
837
+ setTimeout(() => { document.addEventListener('click', tmpCloseHandler); }, 0);
838
+ }
839
+ }
840
+
841
+ closeRowMenus()
842
+ {
843
+ let tmpOpenMenus = document.querySelectorAll('.facto-row-actions.open');
844
+ for (let i = 0; i < tmpOpenMenus.length; i++)
845
+ {
846
+ tmpOpenMenus[i].classList.remove('open');
847
+ }
848
+ }
849
+
850
+ // ================================================================
851
+ // Detail Panel
852
+ // ================================================================
853
+
854
+ viewDetail(pFolderName)
855
+ {
856
+ let tmpDetailRowId = 'facto-scanner-detail-' + pFolderName;
857
+ let tmpExisting = document.getElementById(tmpDetailRowId);
858
+
859
+ // Toggle: if already open, close it
860
+ if (tmpExisting)
861
+ {
862
+ tmpExisting.remove();
863
+ return;
864
+ }
865
+
866
+ // Close any other open detail row
867
+ let tmpOldDetails = document.querySelectorAll('.facto-scanner-detail-row');
868
+ for (let i = 0; i < tmpOldDetails.length; i++)
869
+ {
870
+ tmpOldDetails[i].remove();
871
+ }
872
+
873
+ // Find the clicked row and insert a detail row right after it
874
+ let tmpRow = document.getElementById('facto-scanner-row-' + this.escapeAttr(pFolderName));
875
+ if (!tmpRow) return;
876
+
877
+ let tmpColCount = tmpRow.children.length;
878
+ let tmpDetailRow = document.createElement('tr');
879
+ tmpDetailRow.id = tmpDetailRowId;
880
+ tmpDetailRow.className = 'facto-scanner-detail-row';
881
+ tmpDetailRow.innerHTML = '<td colspan="' + tmpColCount + '"><div class="facto-scanner-detail-panel" style="margin:0;"><span style="color:var(--facto-text-secondary);">Loading details for ' + this.escapeHtml(pFolderName) + '...</span></div></td>';
882
+ tmpRow.after(tmpDetailRow);
883
+
884
+ this.pict.providers.Facto.loadScannerDatasetDetail(pFolderName).then(
885
+ (pDS) =>
886
+ {
887
+ // Row might have been removed while loading
888
+ let tmpTarget = document.getElementById(tmpDetailRowId);
889
+ if (!tmpTarget) return;
890
+
891
+ if (pDS && pDS.Error)
892
+ {
893
+ tmpTarget.innerHTML = '<td colspan="' + tmpColCount + '"><div class="facto-status facto-status-error" style="margin:0;">' + pDS.Error + '</div></td>';
894
+ return;
895
+ }
896
+
897
+ let tmpHtml = '<td colspan="' + tmpColCount + '"><div class="facto-scanner-detail-panel" style="margin:0;">';
898
+ tmpHtml += '<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1em;">';
899
+ tmpHtml += '<h3 style="margin:0;">' + this.escapeHtml(pDS.Title || pDS.FolderName) + '</h3>';
900
+ tmpHtml += '<button class="facto-btn facto-btn-secondary" onclick="pict.views[\'Facto-Full-Scanner\'].viewDetail(\'' + this.escapeAttr(pFolderName) + '\')">Close</button>';
901
+ tmpHtml += '</div>';
902
+
903
+ // Metadata grid
904
+ tmpHtml += '<div class="facto-scanner-detail-grid">';
905
+ tmpHtml += '<div><strong>Folder:</strong> <span style="font-family:\'SF Mono\', Consolas, monospace;">' + this.escapeHtml(pDS.FolderName) + '</span></div>';
906
+ tmpHtml += '<div><strong>Status:</strong> ' + this.getStatusBadge(pDS.Status) + '</div>';
907
+ tmpHtml += '<div><strong>Provider:</strong> ' + this.escapeHtml(pDS.Provider || 'Unknown') + '</div>';
908
+ tmpHtml += '<div><strong>License:</strong> ' + this.escapeHtml(pDS.License || 'Unknown') + '</div>';
909
+ if (pDS.SourceURL)
910
+ {
911
+ tmpHtml += '<div><strong>Source:</strong> <a href="' + this.escapeHtml(pDS.SourceURL) + '" target="_blank" style="color:var(--facto-brand);">' + this.escapeHtml(pDS.SourceURL.substring(0, 60)) + '</a></div>';
912
+ }
913
+ if (pDS.UpdateFrequency)
914
+ {
915
+ tmpHtml += '<div><strong>Update Frequency:</strong> ' + this.escapeHtml(pDS.UpdateFrequency.substring(0, 100)) + '</div>';
916
+ }
917
+ if (pDS.RecordCount)
918
+ {
919
+ tmpHtml += '<div><strong>Record Count:</strong> ' + this.escapeHtml(pDS.RecordCount.substring(0, 100)) + '</div>';
920
+ }
921
+ if (pDS.IDSource)
922
+ {
923
+ tmpHtml += '<div><strong>Source ID:</strong> <a href="#/Source/' + pDS.IDSource + '" style="color:var(--facto-brand);">#' + pDS.IDSource + '</a></div>';
924
+ }
925
+ if (pDS.IDDataset)
926
+ {
927
+ tmpHtml += '<div><strong>Dataset ID:</strong> #' + pDS.IDDataset + '</div>';
928
+ }
929
+ tmpHtml += '</div>';
930
+
931
+ // Description
932
+ if (pDS.Description)
933
+ {
934
+ tmpHtml += '<div style="margin-bottom:1em;"><strong>Description:</strong>';
935
+ tmpHtml += '<div class="facto-scanner-description">' + this.escapeHtml(pDS.Description.substring(0, 500)) + '</div>';
936
+ tmpHtml += '</div>';
937
+ }
938
+
939
+ // Data files
940
+ if (pDS.DataFiles && pDS.DataFiles.length > 0)
941
+ {
942
+ tmpHtml += '<div style="margin-bottom:1em;"><strong>Data Files (' + pDS.DataFiles.length + '):</strong>';
943
+ tmpHtml += '<table style="margin-top:0.35em;"><thead><tr><th>File</th><th>Format</th><th>Size</th><th>Compressed</th></tr></thead><tbody>';
944
+ for (let i = 0; i < pDS.DataFiles.length && i < 20; i++)
945
+ {
946
+ let tmpFile = pDS.DataFiles[i];
947
+ let tmpSize = this.formatSize(tmpFile.Size || 0);
948
+ tmpHtml += '<tr>';
949
+ tmpHtml += '<td style="font-family:\'SF Mono\', Consolas, monospace; font-size:0.85em;">' + this.escapeHtml(tmpFile.FileName) + '</td>';
950
+ tmpHtml += '<td><span class="facto-badge facto-badge-primary">' + (tmpFile.Format || '') + '</span></td>';
951
+ tmpHtml += '<td>' + tmpSize + '</td>';
952
+ tmpHtml += '<td>' + (tmpFile.Compressed ? 'Yes' : 'No') + '</td>';
953
+ tmpHtml += '</tr>';
954
+ }
955
+ tmpHtml += '</tbody></table>';
956
+ tmpHtml += '</div>';
957
+ }
958
+
959
+ // Ingestion Plan
960
+ if (pDS.HasData && pDS.DataFiles && pDS.DataFiles.length > 0)
961
+ {
962
+ tmpHtml += '<div style="margin-bottom:1em; padding-top:1em; border-top:1px solid var(--facto-border-subtle, #eee);">';
963
+ tmpHtml += '<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.5em;">';
964
+ tmpHtml += '<strong>Ingestion Plan</strong>';
965
+ tmpHtml += '<button class="facto-btn facto-btn-secondary facto-btn-small" onclick="pict.views[\'Facto-Full-Scanner\'].loadIngestionPlan(\'' + this.escapeAttr(pFolderName) + '\')">View / Generate Plan</button>';
966
+ tmpHtml += '</div>';
967
+ tmpHtml += '<div id="facto-scanner-plan-' + this.escapeAttr(pFolderName) + '"></div>';
968
+ tmpHtml += '</div>';
969
+ }
970
+
971
+ // Errors
972
+ if (pDS.Errors && pDS.Errors.length > 0)
973
+ {
974
+ tmpHtml += '<div style="margin-bottom:1em;"><strong>Errors:</strong>';
975
+ tmpHtml += '<ul style="margin-top:0.35em; padding-left:1.5em; color:var(--facto-text-danger, #dc3545);">';
976
+ for (let i = 0; i < pDS.Errors.length; i++)
977
+ {
978
+ tmpHtml += '<li>' + this.escapeHtml(pDS.Errors[i]) + '</li>';
979
+ }
980
+ tmpHtml += '</ul></div>';
981
+ }
982
+
983
+ // Action buttons
984
+ tmpHtml += '<div style="margin-top:1em; padding-top:1em; border-top:1px solid var(--facto-border-subtle, #eee);">';
985
+ if (pDS.Status === 'Discovered')
986
+ {
987
+ tmpHtml += '<button class="facto-btn facto-btn-success" onclick="pict.views[\'Facto-Full-Scanner\'].provisionOne(\'' + this.escapeAttr(pFolderName) + '\')">Provision</button> ';
988
+ }
989
+ if (pDS.HasData)
990
+ {
991
+ tmpHtml += '<button class="facto-btn facto-btn-primary" onclick="pict.views[\'Facto-Full-Scanner\'].ingestOne(\'' + this.escapeAttr(pFolderName) + '\')">Ingest Single</button> ';
992
+ tmpHtml += '<button class="facto-btn facto-btn-primary" onclick="pict.views[\'Facto-Full-Scanner\'].ingestFromPlan(\'' + this.escapeAttr(pFolderName) + '\')">Ingest from Plan</button> ';
993
+ }
994
+ if (pDS.IDSource)
995
+ {
996
+ tmpHtml += '<button class="facto-btn facto-btn-secondary" onclick="pict.PictApplication.navigateTo(\'/Source/' + pDS.IDSource + '\')">View Source &rarr;</button> ';
997
+ }
998
+ tmpHtml += '</div>';
999
+
1000
+ tmpHtml += '</div></td>';
1001
+ tmpTarget.innerHTML = tmpHtml;
1002
+ tmpTarget.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
1003
+ }).catch(
1004
+ (pError) =>
1005
+ {
1006
+ let tmpTarget = document.getElementById(tmpDetailRowId);
1007
+ if (tmpTarget)
1008
+ {
1009
+ tmpTarget.innerHTML = '<td colspan="' + tmpColCount + '"><div class="facto-status facto-status-error" style="margin:0;">Error: ' + pError.message + '</div></td>';
1010
+ }
1011
+ });
1012
+ }
1013
+ }
1014
+
1015
+ module.exports = FactoFullScannerView;
1016
+
1017
+ module.exports.default_configuration = _ViewConfiguration;