sqlite-hub 1.4.0 → 1.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.
- package/README.md +9 -0
- package/bin/sqlite-hub.js +555 -122
- package/docs/API.md +30 -0
- package/docs/CLI.md +22 -0
- package/docs/CLI_API_PARITY.md +61 -0
- package/docs/changelog.md +9 -1
- package/docs/guidelines/AGENTS.md +32 -0
- package/docs/{DESIGN_GUIDELINES.md → guidelines/DESIGN.md} +10 -0
- package/docs/todo.md +1 -13
- package/frontend/js/api.js +45 -0
- package/frontend/js/app.js +192 -2
- package/frontend/js/components/connectionCard.js +3 -1
- package/frontend/js/components/modal.js +356 -15
- package/frontend/js/components/sidebar.js +41 -7
- package/frontend/js/components/topNav.js +2 -14
- package/frontend/js/router.js +10 -0
- package/frontend/js/store.js +448 -0
- package/frontend/js/utils/inputClear.js +36 -0
- package/frontend/js/utils/syntheticData.js +240 -0
- package/frontend/js/views/backups.js +52 -0
- package/frontend/js/views/data.js +16 -0
- package/frontend/js/views/logs.js +339 -0
- package/frontend/js/views/settings.js +77 -27
- package/frontend/js/views/structure.js +6 -0
- package/frontend/js/views/tableAdvisor.js +385 -0
- package/frontend/js/views/tableDesigner.js +6 -0
- package/frontend/styles/components.css +149 -0
- package/frontend/styles/tailwind.generated.css +1 -1
- package/frontend/styles/views.css +8 -0
- package/package.json +1 -1
- package/server/routes/data.js +45 -0
- package/server/routes/externalApi.js +285 -2
- package/server/routes/logs.js +127 -0
- package/server/server.js +3 -0
- package/server/services/databaseCommandService.js +45 -0
- package/server/services/sqlite/dataBrowserService.js +36 -0
- package/server/services/sqlite/introspection.js +226 -22
- package/server/services/sqlite/syntheticDataGenerator.js +728 -0
- package/server/services/sqlite/tableAdvisor.js +790 -0
- package/server/services/storage/appStateStore.js +773 -169
package/frontend/js/app.js
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
initializeApp,
|
|
49
49
|
insertMarkdownIntoLastOpenDocument,
|
|
50
50
|
loadMoreQueryHistory,
|
|
51
|
+
loadMoreLogs,
|
|
51
52
|
openModal,
|
|
52
53
|
openOverviewInFinder,
|
|
53
54
|
openQueryHistoryInEditor,
|
|
@@ -64,6 +65,7 @@ import {
|
|
|
64
65
|
openRestoreBackupModal,
|
|
65
66
|
openDeleteQueryChartModal,
|
|
66
67
|
openDataExportModal,
|
|
68
|
+
openGenerateDataModal,
|
|
67
69
|
openDeleteDocumentModal,
|
|
68
70
|
openQueryExportModal,
|
|
69
71
|
openDataRowByIdentity,
|
|
@@ -77,6 +79,7 @@ import {
|
|
|
77
79
|
openDataRowUpdatePreview,
|
|
78
80
|
openEditorRowUpdatePreview,
|
|
79
81
|
refreshCurrentRoute,
|
|
82
|
+
refreshLogs,
|
|
80
83
|
refreshBackups,
|
|
81
84
|
refreshMediaTaggingPreview,
|
|
82
85
|
removeConnection,
|
|
@@ -94,6 +97,9 @@ import {
|
|
|
94
97
|
selectQueryHistoryItem,
|
|
95
98
|
selectStructureEntry,
|
|
96
99
|
setDocumentsSearchQuery,
|
|
100
|
+
setLogFilter,
|
|
101
|
+
setLogSearchInput,
|
|
102
|
+
applyLogSearch,
|
|
97
103
|
setTableDesignerSearchQuery,
|
|
98
104
|
setTableDesignerSqlPreviewVisibility,
|
|
99
105
|
toggleTableDesignerTablesPanel,
|
|
@@ -126,7 +132,9 @@ import {
|
|
|
126
132
|
submitBackupSafetyChoice,
|
|
127
133
|
submitCreateBackupConfirmation,
|
|
128
134
|
submitEditBackupConfirmation,
|
|
135
|
+
submitGenerateDataRows,
|
|
129
136
|
submitRowUpdatePreviewConfirmation,
|
|
137
|
+
previewGenerateDataRows,
|
|
130
138
|
setQueryHistoryPanelVisibility,
|
|
131
139
|
sortDataTableByColumn,
|
|
132
140
|
sortEditorResultsByColumn,
|
|
@@ -170,6 +178,8 @@ import {
|
|
|
170
178
|
updateCurrentTableDesignerColumnField,
|
|
171
179
|
updateCurrentTableDesignerConstraintField,
|
|
172
180
|
updateCurrentTableDesignerField,
|
|
181
|
+
updateGenerateDataMapping,
|
|
182
|
+
updateGenerateDataModal,
|
|
173
183
|
updateGenerateTypesModal,
|
|
174
184
|
addCurrentTableDesignerColumn,
|
|
175
185
|
applyCurrentMediaTaggingSelection,
|
|
@@ -182,10 +192,12 @@ import { renderDataRowEditorPanel, renderDataView } from './views/data.js';
|
|
|
182
192
|
import { renderDocumentsView } from './views/documents.js';
|
|
183
193
|
import { renderEditorView } from './views/editor.js';
|
|
184
194
|
import { renderLandingView } from './views/landing.js';
|
|
195
|
+
import { renderLogsView, renderLogTable } from './views/logs.js';
|
|
185
196
|
import { renderMediaTaggingView } from './views/mediaTagging.js';
|
|
186
197
|
import { renderOverviewView } from './views/overview.js';
|
|
187
198
|
import { renderSettingsView } from './views/settings.js';
|
|
188
199
|
import { renderStructureView } from './views/structure.js';
|
|
200
|
+
import { renderTableAdvisorView } from './views/tableAdvisor.js';
|
|
189
201
|
import { renderTableDesignerView } from './views/tableDesigner.js';
|
|
190
202
|
import { replaceChildrenFromRenderedMarkup, replaceElementFromRenderedMarkup } from './utils/dom.js';
|
|
191
203
|
import {
|
|
@@ -200,6 +212,7 @@ import {
|
|
|
200
212
|
detectFilePathValue,
|
|
201
213
|
getPathTypeLabel,
|
|
202
214
|
} from './utils/filePathPreview.js';
|
|
215
|
+
import { clearInputForEscape } from './utils/inputClear.js';
|
|
203
216
|
import { formatSqlQuery } from './utils/sqlFormatter.js';
|
|
204
217
|
import {
|
|
205
218
|
buildDataRowEditorJsonObject,
|
|
@@ -263,6 +276,7 @@ const ROUTE_TITLE_SEGMENTS = {
|
|
|
263
276
|
backups: 'Backups',
|
|
264
277
|
overview: 'Overview',
|
|
265
278
|
data: 'Data',
|
|
279
|
+
tableAdvisor: 'Table Advisor',
|
|
266
280
|
structure: 'Structure',
|
|
267
281
|
editor: 'SQL Editor',
|
|
268
282
|
editorResults: 'SQL Editor',
|
|
@@ -272,6 +286,7 @@ const ROUTE_TITLE_SEGMENTS = {
|
|
|
272
286
|
mediaTaggingSetup: 'Media Tagging',
|
|
273
287
|
mediaTaggingQueue: 'Tagging Queue',
|
|
274
288
|
settings: 'Settings',
|
|
289
|
+
logs: 'Logs',
|
|
275
290
|
notFound: 'Not Found',
|
|
276
291
|
};
|
|
277
292
|
|
|
@@ -973,6 +988,92 @@ function patchChartsDetailUi(state, { preserveCharts = false } = {}) {
|
|
|
973
988
|
return true;
|
|
974
989
|
}
|
|
975
990
|
|
|
991
|
+
function getLogsActiveDatabaseId(state) {
|
|
992
|
+
return String(state.connections?.active?.id ?? state.logs?.metadata?.activeDatabase?.id ?? '');
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function syncLogsFilterControls(state) {
|
|
996
|
+
const filters = state.logs?.filters ?? {};
|
|
997
|
+
|
|
998
|
+
for (const button of shellRefs.view.querySelectorAll('[data-action="set-log-filter"][data-field][data-value]')) {
|
|
999
|
+
if (!(button instanceof HTMLElement)) {
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const field = button.dataset.field;
|
|
1004
|
+
const value = button.dataset.value;
|
|
1005
|
+
const active = String(filters[field] ?? '') === String(value ?? '');
|
|
1006
|
+
|
|
1007
|
+
button.classList.toggle('is-active', active);
|
|
1008
|
+
button.setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function syncLogsMetaStrip(state) {
|
|
1013
|
+
const logs = state.logs ?? {};
|
|
1014
|
+
const values = {
|
|
1015
|
+
visible: formatNumber((logs.items ?? []).length),
|
|
1016
|
+
matched: formatNumber(logs.total ?? 0),
|
|
1017
|
+
scope: logs.metadata?.activeDatabase?.label ?? 'Active Database',
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
for (const [key, value] of Object.entries(values)) {
|
|
1021
|
+
const node = shellRefs.view.querySelector(`[data-logs-meta="${key}"]`);
|
|
1022
|
+
|
|
1023
|
+
if (node instanceof HTMLElement) {
|
|
1024
|
+
node.textContent = value;
|
|
1025
|
+
node.title = value;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function patchLogsTableUi(state) {
|
|
1031
|
+
const logsView = shellRefs.view.querySelector('[data-logs-view]');
|
|
1032
|
+
|
|
1033
|
+
if (!(logsView instanceof HTMLElement)) {
|
|
1034
|
+
return false;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const activeDatabaseId = getLogsActiveDatabaseId(state);
|
|
1038
|
+
|
|
1039
|
+
if (String(logsView.dataset.logsActiveDatabaseId ?? '') !== activeDatabaseId) {
|
|
1040
|
+
return false;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const currentTable = logsView.querySelector('[data-logs-table]');
|
|
1044
|
+
|
|
1045
|
+
if (!(currentTable instanceof HTMLElement)) {
|
|
1046
|
+
return false;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const currentScrollNode = currentTable.querySelector('[data-logs-table-scroll]');
|
|
1050
|
+
const scrollState =
|
|
1051
|
+
currentScrollNode instanceof HTMLElement
|
|
1052
|
+
? {
|
|
1053
|
+
left: currentScrollNode.scrollLeft,
|
|
1054
|
+
top: currentScrollNode.scrollTop,
|
|
1055
|
+
}
|
|
1056
|
+
: null;
|
|
1057
|
+
const patched = replaceElementFromRenderedMarkup(currentTable, renderLogTable(state.logs ?? {}));
|
|
1058
|
+
|
|
1059
|
+
if (!patched) {
|
|
1060
|
+
return false;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (scrollState) {
|
|
1064
|
+
const nextScrollNode = logsView.querySelector('[data-logs-table-scroll]');
|
|
1065
|
+
|
|
1066
|
+
if (nextScrollNode instanceof HTMLElement) {
|
|
1067
|
+
nextScrollNode.scrollLeft = scrollState.left;
|
|
1068
|
+
nextScrollNode.scrollTop = scrollState.top;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
syncLogsFilterControls(state);
|
|
1073
|
+
syncLogsMetaStrip(state);
|
|
1074
|
+
return true;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
976
1077
|
function readFileAsBase64(file) {
|
|
977
1078
|
return new Promise((resolve, reject) => {
|
|
978
1079
|
const reader = new FileReader();
|
|
@@ -1112,12 +1213,16 @@ function resolveView(state) {
|
|
|
1112
1213
|
return renderStructureView(state);
|
|
1113
1214
|
case 'tableDesigner':
|
|
1114
1215
|
return renderTableDesignerView(state);
|
|
1216
|
+
case 'tableAdvisor':
|
|
1217
|
+
return renderTableAdvisorView(state);
|
|
1115
1218
|
case 'mediaTaggingSetup':
|
|
1116
1219
|
return renderMediaTaggingView(state, { subView: 'setup' });
|
|
1117
1220
|
case 'mediaTaggingQueue':
|
|
1118
1221
|
return renderMediaTaggingView(state, { subView: 'queue' });
|
|
1119
1222
|
case 'settings':
|
|
1120
1223
|
return renderSettingsView(state);
|
|
1224
|
+
case 'logs':
|
|
1225
|
+
return renderLogsView(state);
|
|
1121
1226
|
default:
|
|
1122
1227
|
return renderNotFoundView();
|
|
1123
1228
|
}
|
|
@@ -1403,9 +1508,11 @@ function renderApp(state) {
|
|
|
1403
1508
|
'editorResults',
|
|
1404
1509
|
'data',
|
|
1405
1510
|
'charts',
|
|
1511
|
+
'logs',
|
|
1406
1512
|
'documents',
|
|
1407
1513
|
'structure',
|
|
1408
1514
|
'tableDesigner',
|
|
1515
|
+
'tableAdvisor',
|
|
1409
1516
|
'mediaTaggingSetup',
|
|
1410
1517
|
'mediaTaggingQueue',
|
|
1411
1518
|
].includes(state.route.name);
|
|
@@ -1457,8 +1564,8 @@ function renderApp(state) {
|
|
|
1457
1564
|
pendingNewTableDesignerAutofocus = false;
|
|
1458
1565
|
}
|
|
1459
1566
|
|
|
1460
|
-
const canPatchChartsMain =
|
|
1461
|
-
|
|
1567
|
+
const canPatchChartsMain = mainChanged && previousRouteName === 'charts' && state.route.name === 'charts';
|
|
1568
|
+
const canPatchLogsMain = mainChanged && previousRouteName === 'logs' && state.route.name === 'logs';
|
|
1462
1569
|
let mainPatched = false;
|
|
1463
1570
|
let preservedChartsDom = false;
|
|
1464
1571
|
|
|
@@ -1484,6 +1591,10 @@ function renderApp(state) {
|
|
|
1484
1591
|
}
|
|
1485
1592
|
}
|
|
1486
1593
|
|
|
1594
|
+
if (!mainPatched && canPatchLogsMain) {
|
|
1595
|
+
mainPatched = patchLogsTableUi(state);
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1487
1598
|
if (mainChanged) {
|
|
1488
1599
|
if (!mainPatched) {
|
|
1489
1600
|
teardownStructureGraph();
|
|
@@ -2428,6 +2539,15 @@ async function handleAction(actionNode) {
|
|
|
2428
2539
|
case 'refresh-view':
|
|
2429
2540
|
await refreshCurrentRoute();
|
|
2430
2541
|
return;
|
|
2542
|
+
case 'refresh-logs':
|
|
2543
|
+
await refreshLogs();
|
|
2544
|
+
return;
|
|
2545
|
+
case 'set-log-filter':
|
|
2546
|
+
await setLogFilter(actionNode.dataset.field, actionNode.dataset.value);
|
|
2547
|
+
return;
|
|
2548
|
+
case 'load-more-logs':
|
|
2549
|
+
await loadMoreLogs();
|
|
2550
|
+
return;
|
|
2431
2551
|
case 'set-settings-section':
|
|
2432
2552
|
setSettingsSection(actionNode.dataset.section);
|
|
2433
2553
|
return;
|
|
@@ -2878,6 +2998,12 @@ async function handleAction(actionNode) {
|
|
|
2878
2998
|
case 'open-data-export-modal':
|
|
2879
2999
|
openDataExportModal();
|
|
2880
3000
|
return;
|
|
3001
|
+
case 'open-generate-data-modal':
|
|
3002
|
+
openGenerateDataModal();
|
|
3003
|
+
return;
|
|
3004
|
+
case 'preview-generate-data':
|
|
3005
|
+
await previewGenerateDataRows();
|
|
3006
|
+
return;
|
|
2881
3007
|
case 'export-data-format': {
|
|
2882
3008
|
const format = actionNode.dataset.exportFormat;
|
|
2883
3009
|
const filename = getExportFilenameFromAction(actionNode);
|
|
@@ -2948,6 +3074,30 @@ async function handleAction(actionNode) {
|
|
|
2948
3074
|
}
|
|
2949
3075
|
return;
|
|
2950
3076
|
}
|
|
3077
|
+
case 'copy-table-advisor-sql': {
|
|
3078
|
+
const issueId = actionNode.dataset.issueId;
|
|
3079
|
+
const advisor = getState().tableAdvisor;
|
|
3080
|
+
const issue = (advisor.result?.issues ?? []).find(item => item.id === issueId);
|
|
3081
|
+
const sql = String(issue?.sql ?? '');
|
|
3082
|
+
|
|
3083
|
+
if (!sql.trim()) {
|
|
3084
|
+
showToast('No advisor SQL to copy.', 'alert');
|
|
3085
|
+
return;
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
if (!navigator.clipboard?.writeText) {
|
|
3089
|
+
showToast('Clipboard API is not available.', 'alert');
|
|
3090
|
+
return;
|
|
3091
|
+
}
|
|
3092
|
+
|
|
3093
|
+
try {
|
|
3094
|
+
await navigator.clipboard.writeText(sql);
|
|
3095
|
+
showToast('Advisor SQL copied.', 'success');
|
|
3096
|
+
} catch (error) {
|
|
3097
|
+
showToast('Advisor SQL could not be copied.', 'alert');
|
|
3098
|
+
}
|
|
3099
|
+
return;
|
|
3100
|
+
}
|
|
2951
3101
|
case 'toggle-table-designer-sql-preview':
|
|
2952
3102
|
setTableDesignerSqlPreviewVisibility(
|
|
2953
3103
|
actionNode.dataset.nextValue ? actionNode.dataset.nextValue === 'true' : undefined,
|
|
@@ -3236,6 +3386,11 @@ document.addEventListener('keydown', event => {
|
|
|
3236
3386
|
return;
|
|
3237
3387
|
}
|
|
3238
3388
|
|
|
3389
|
+
if (target instanceof HTMLInputElement && clearInputForEscape(target)) {
|
|
3390
|
+
event.preventDefault();
|
|
3391
|
+
return;
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3239
3394
|
if (state.modal) {
|
|
3240
3395
|
event.preventDefault();
|
|
3241
3396
|
closeModal();
|
|
@@ -3327,6 +3482,20 @@ document.addEventListener('input', event => {
|
|
|
3327
3482
|
return;
|
|
3328
3483
|
}
|
|
3329
3484
|
|
|
3485
|
+
if (bindNode.dataset.bind === 'generate-data-field') {
|
|
3486
|
+
updateGenerateDataModal(bindNode.dataset.field, bindNode.value, { notify: false });
|
|
3487
|
+
return;
|
|
3488
|
+
}
|
|
3489
|
+
|
|
3490
|
+
if (bindNode.dataset.bind === 'generate-data-mapping') {
|
|
3491
|
+
if (bindNode instanceof HTMLSelectElement) {
|
|
3492
|
+
return;
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3495
|
+
updateGenerateDataMapping(bindNode.dataset.columnName, bindNode.dataset.field, bindNode.value, { notify: false });
|
|
3496
|
+
return;
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3330
3499
|
if (bindNode.dataset.bind === 'copy-column-format-field') {
|
|
3331
3500
|
updateCopyColumnModalFormatField(
|
|
3332
3501
|
bindNode.dataset.field,
|
|
@@ -3354,6 +3523,11 @@ document.addEventListener('input', event => {
|
|
|
3354
3523
|
return;
|
|
3355
3524
|
}
|
|
3356
3525
|
|
|
3526
|
+
if (bindNode.dataset.bind === 'logs-search') {
|
|
3527
|
+
setLogSearchInput(bindNode.value);
|
|
3528
|
+
return;
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3357
3531
|
if (bindNode.dataset.bind === 'data-search-query') {
|
|
3358
3532
|
void setDataSearchQuery(bindNode.value);
|
|
3359
3533
|
return;
|
|
@@ -3505,6 +3679,16 @@ document.addEventListener('change', event => {
|
|
|
3505
3679
|
return;
|
|
3506
3680
|
}
|
|
3507
3681
|
|
|
3682
|
+
if (bindNode.dataset.bind === 'generate-data-field') {
|
|
3683
|
+
updateGenerateDataModal(bindNode.dataset.field, bindNode.value);
|
|
3684
|
+
return;
|
|
3685
|
+
}
|
|
3686
|
+
|
|
3687
|
+
if (bindNode.dataset.bind === 'generate-data-mapping') {
|
|
3688
|
+
updateGenerateDataMapping(bindNode.dataset.columnName, bindNode.dataset.field, bindNode.value);
|
|
3689
|
+
return;
|
|
3690
|
+
}
|
|
3691
|
+
|
|
3508
3692
|
if (bindNode.dataset.bind === 'data-search-column') {
|
|
3509
3693
|
void setDataSearchColumn(bindNode.value);
|
|
3510
3694
|
return;
|
|
@@ -3649,6 +3833,9 @@ document.addEventListener('submit', async event => {
|
|
|
3649
3833
|
const formData = submitter ? new FormData(form, submitter) : new FormData(form);
|
|
3650
3834
|
|
|
3651
3835
|
switch (form.dataset.form) {
|
|
3836
|
+
case 'logs-search':
|
|
3837
|
+
await applyLogSearch(String(formData.get('search') ?? ''));
|
|
3838
|
+
return;
|
|
3652
3839
|
case 'create-api-token':
|
|
3653
3840
|
await createSettingsApiToken(String(formData.get('name') ?? ''));
|
|
3654
3841
|
return;
|
|
@@ -3780,6 +3967,9 @@ document.addEventListener('submit', async event => {
|
|
|
3780
3967
|
case 'apply-row-update-preview':
|
|
3781
3968
|
await submitRowUpdatePreviewConfirmation();
|
|
3782
3969
|
return;
|
|
3970
|
+
case 'generate-data':
|
|
3971
|
+
await submitGenerateDataRows();
|
|
3972
|
+
return;
|
|
3783
3973
|
case 'create-media-tagging-tag-table':
|
|
3784
3974
|
await submitCreateMediaTaggingTagTable();
|
|
3785
3975
|
return;
|
|
@@ -56,7 +56,9 @@ export function renderConnectionCard(connection, activeConnectionId) {
|
|
|
56
56
|
'<div><div class="mb-1 text-[9px] font-mono uppercase text-outline-variant">Last Opened</div><div class="text-xs font-bold text-on-surface">',
|
|
57
57
|
escapeHtml(formatDateTime(connection.lastOpenedAt)),
|
|
58
58
|
"</div></div>",
|
|
59
|
-
'<div><div class="mb-1 text-[9px] font-mono uppercase text-outline-variant">Mode</div><div class="text-xs font-bold
|
|
59
|
+
'<div><div class="mb-1 text-[9px] font-mono uppercase text-outline-variant">Mode</div><div class="text-xs font-bold ',
|
|
60
|
+
connection.readOnly ? "text-primary-container" : "text-on-surface",
|
|
61
|
+
'">',
|
|
60
62
|
connection.readOnly ? "Read only" : "Read / Write",
|
|
61
63
|
"</div></div></div></div>",
|
|
62
64
|
'<div class="border-t border-outline-variant/10 bg-surface-container-low px-4 py-3">',
|