sqlite-hub 0.16.0 → 0.17.2
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 +106 -19
- package/bin/sqlite-hub.js +1041 -224
- package/frontend/index.html +1 -0
- package/frontend/js/api.js +34 -0
- package/frontend/js/app.js +481 -32
- package/frontend/js/components/modal.js +270 -50
- package/frontend/js/components/pageHeader.js +14 -16
- package/frontend/js/components/queryHistoryPanel.js +126 -131
- package/frontend/js/components/queryResults.js +18 -1
- package/frontend/js/components/rowEditorPanel.js +42 -34
- package/frontend/js/components/sidebar.js +1 -0
- package/frontend/js/router.js +8 -0
- package/frontend/js/store.js +655 -2
- package/frontend/js/utils/jsonPreview.js +31 -0
- package/frontend/js/utils/markdownDocuments.js +248 -0
- package/frontend/js/utils/rowEditorValues.js +41 -0
- package/frontend/js/utils/tableScrollState.js +39 -0
- package/frontend/js/views/charts.js +8 -0
- package/frontend/js/views/data.js +4 -1
- package/frontend/js/views/documents.js +300 -0
- package/frontend/js/views/editor.js +2 -0
- package/frontend/js/views/settings.js +39 -3
- package/frontend/styles/components.css +19 -0
- package/frontend/styles/tailwind.generated.css +1 -1
- package/frontend/styles/views.css +422 -0
- package/package.json +2 -1
- package/server/middleware/localRequestSecurity.js +84 -0
- package/server/routes/connections.js +18 -1
- package/server/routes/documents.js +163 -0
- package/server/routes/settings.js +22 -6
- package/server/server.js +18 -1
- package/server/services/nativeFileDialogService.js +168 -0
- package/server/services/sqlite/dataBrowserService.js +0 -4
- package/server/services/sqlite/exportService.js +5 -1
- package/server/services/sqlite/sqlExecutor.js +51 -2
- package/server/services/storage/appStateStore.js +313 -0
- package/server/utils/sqliteTypes.js +17 -8
- package/tests/cli-args.test.js +100 -0
- package/tests/connections-file-dialog-route.test.js +46 -0
- package/tests/copy-column-modal.test.js +97 -0
- package/tests/database-documents.test.js +85 -0
- package/tests/export-blob.test.js +46 -0
- package/tests/json-preview.test.js +49 -0
- package/tests/local-request-security.test.js +85 -0
- package/tests/markdown-documents.test.js +79 -0
- package/tests/native-file-dialog.test.js +78 -0
- package/tests/query-results-truncation.test.js +20 -0
- package/tests/row-editor-null-values.test.js +131 -0
- package/tests/settings-metadata.test.js +16 -0
- package/tests/settings-view.test.js +32 -0
- package/tests/sql-identifier-safety.test.js +9 -3
- package/tests/sql-result-limit.test.js +66 -0
- package/tests/table-scroll-state.test.js +70 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function formatJsonPreview(value) {
|
|
2
|
+
if (value === null || value === undefined) {
|
|
3
|
+
return null;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
let parsed = value;
|
|
7
|
+
|
|
8
|
+
if (typeof value !== "object") {
|
|
9
|
+
const text = String(value).trim();
|
|
10
|
+
|
|
11
|
+
if (!text || !["{", "["].includes(text[0])) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
parsed = JSON.parse(text);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!parsed || typeof parsed !== "object") {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
return JSON.stringify(parsed, null, 2);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { escapeHtml } from './format.js';
|
|
2
|
+
|
|
3
|
+
const FENCED_CODE_PATTERN = /^\s*(```|~~~)/;
|
|
4
|
+
const TASK_LINE_PATTERN = /^(\s*)([-*+])\s+\[([ xX])\]\s+(.*?)(\r?)$/;
|
|
5
|
+
|
|
6
|
+
function decodeBasicHtmlEntities(value = '') {
|
|
7
|
+
return String(value)
|
|
8
|
+
.replace(/:/gi, ':')
|
|
9
|
+
.replace(/:/g, ':')
|
|
10
|
+
.replace(/:/gi, ':')
|
|
11
|
+
.replace(/&/gi, '&')
|
|
12
|
+
.replace(/"/gi, '"')
|
|
13
|
+
.replace(/'/gi, "'");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function sanitizeRenderedMarkdown(html = '') {
|
|
17
|
+
return String(html).replace(/\s(href|src)=("([^"]*)"|'([^']*)'|([^\s>]+))/gi, (match, attribute, raw, doubleQuoted, singleQuoted, bare) => {
|
|
18
|
+
const value = doubleQuoted ?? singleQuoted ?? bare ?? '';
|
|
19
|
+
const decodedValue = decodeBasicHtmlEntities(value).trim();
|
|
20
|
+
|
|
21
|
+
if (/^(javascript|data|vbscript):/i.test(decodedValue)) {
|
|
22
|
+
return ` ${attribute}="#"`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return match;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function renderFallbackMarkdown(markdown = '') {
|
|
30
|
+
const lines = String(markdown).split(/\r?\n/);
|
|
31
|
+
const html = [];
|
|
32
|
+
let listItems = [];
|
|
33
|
+
let listTag = 'ul';
|
|
34
|
+
|
|
35
|
+
const flushList = () => {
|
|
36
|
+
if (!listItems.length) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
html.push(`<${listTag}>${listItems.map(item => `<li>${item}</li>`).join('')}</${listTag}>`);
|
|
41
|
+
listItems = [];
|
|
42
|
+
listTag = 'ul';
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
for (const line of lines) {
|
|
46
|
+
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
47
|
+
const unorderedMatch = line.match(/^\s*[-*+]\s+(.+)$/);
|
|
48
|
+
const orderedMatch = line.match(/^\s*\d+[.)]\s+(.+)$/);
|
|
49
|
+
|
|
50
|
+
if (headingMatch) {
|
|
51
|
+
flushList();
|
|
52
|
+
const level = Math.min(6, headingMatch[1].length);
|
|
53
|
+
html.push(`<h${level}>${escapeHtml(headingMatch[2])}</h${level}>`);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (unorderedMatch) {
|
|
58
|
+
if (listItems.length && listTag !== 'ul') {
|
|
59
|
+
flushList();
|
|
60
|
+
}
|
|
61
|
+
listTag = 'ul';
|
|
62
|
+
listItems.push(escapeHtml(unorderedMatch[1]));
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (orderedMatch) {
|
|
67
|
+
if (listItems.length && listTag !== 'ol') {
|
|
68
|
+
flushList();
|
|
69
|
+
}
|
|
70
|
+
listTag = 'ol';
|
|
71
|
+
listItems.push(escapeHtml(orderedMatch[1]));
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
flushList();
|
|
76
|
+
|
|
77
|
+
if (line.trim()) {
|
|
78
|
+
html.push(`<p>${escapeHtml(line)}</p>`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
flushList();
|
|
83
|
+
return html.join('\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function renderMarkdownBlock(markdown = '') {
|
|
87
|
+
const source = escapeHtml(markdown);
|
|
88
|
+
const markedRuntime = globalThis.marked;
|
|
89
|
+
const parser = typeof markedRuntime?.parse === 'function' ? markedRuntime.parse.bind(markedRuntime) : null;
|
|
90
|
+
|
|
91
|
+
if (!parser) {
|
|
92
|
+
return renderFallbackMarkdown(markdown);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return sanitizeRenderedMarkdown(
|
|
96
|
+
parser(source, {
|
|
97
|
+
async: false,
|
|
98
|
+
breaks: false,
|
|
99
|
+
gfm: true,
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function renderMarkdownInline(markdown = '') {
|
|
105
|
+
const source = escapeHtml(markdown);
|
|
106
|
+
const markedRuntime = globalThis.marked;
|
|
107
|
+
const parser =
|
|
108
|
+
typeof markedRuntime?.parseInline === 'function' ? markedRuntime.parseInline.bind(markedRuntime) : null;
|
|
109
|
+
|
|
110
|
+
if (!parser) {
|
|
111
|
+
return source;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return sanitizeRenderedMarkdown(
|
|
115
|
+
parser(source, {
|
|
116
|
+
async: false,
|
|
117
|
+
breaks: false,
|
|
118
|
+
gfm: true,
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function renderTaskList(items = []) {
|
|
124
|
+
if (!items.length) {
|
|
125
|
+
return '';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return `
|
|
129
|
+
<ul class="document-markdown-task-list">
|
|
130
|
+
${items
|
|
131
|
+
.map(item => {
|
|
132
|
+
const checked = item.checked;
|
|
133
|
+
|
|
134
|
+
return `
|
|
135
|
+
<li class="document-markdown-task-item ${checked ? 'is-checked' : ''}">
|
|
136
|
+
<label class="document-markdown-task-label">
|
|
137
|
+
<input
|
|
138
|
+
${checked ? 'checked' : ''}
|
|
139
|
+
class="document-markdown-task-checkbox"
|
|
140
|
+
data-action="toggle-document-todo"
|
|
141
|
+
data-line-index="${escapeHtml(String(item.lineIndex))}"
|
|
142
|
+
type="checkbox"
|
|
143
|
+
/>
|
|
144
|
+
<span class="document-markdown-task-text">${renderMarkdownInline(item.text)}</span>
|
|
145
|
+
</label>
|
|
146
|
+
</li>
|
|
147
|
+
`;
|
|
148
|
+
})
|
|
149
|
+
.join('')}
|
|
150
|
+
</ul>
|
|
151
|
+
`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function renderMarkdownPreview(markdown = '') {
|
|
155
|
+
const lines = String(markdown ?? '').split('\n');
|
|
156
|
+
const blocks = [];
|
|
157
|
+
let markdownLines = [];
|
|
158
|
+
let taskItems = [];
|
|
159
|
+
let inFence = false;
|
|
160
|
+
|
|
161
|
+
const flushMarkdown = () => {
|
|
162
|
+
if (!markdownLines.length) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
blocks.push(renderMarkdownBlock(markdownLines.join('\n')));
|
|
167
|
+
markdownLines = [];
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const flushTasks = () => {
|
|
171
|
+
if (!taskItems.length) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
blocks.push(renderTaskList(taskItems));
|
|
176
|
+
taskItems = [];
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
lines.forEach((line, lineIndex) => {
|
|
180
|
+
const fenceLine = FENCED_CODE_PATTERN.test(line);
|
|
181
|
+
|
|
182
|
+
if (fenceLine) {
|
|
183
|
+
flushTasks();
|
|
184
|
+
markdownLines.push(line);
|
|
185
|
+
inFence = !inFence;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!inFence) {
|
|
190
|
+
const taskMatch = line.match(TASK_LINE_PATTERN);
|
|
191
|
+
|
|
192
|
+
if (taskMatch) {
|
|
193
|
+
flushMarkdown();
|
|
194
|
+
taskItems.push({
|
|
195
|
+
checked: taskMatch[3].toLowerCase() === 'x',
|
|
196
|
+
lineIndex,
|
|
197
|
+
text: taskMatch[4],
|
|
198
|
+
});
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
flushTasks();
|
|
204
|
+
markdownLines.push(line);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
flushTasks();
|
|
208
|
+
flushMarkdown();
|
|
209
|
+
|
|
210
|
+
return blocks.join('\n').trim() || '<p class="document-markdown-empty">Empty document</p>';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isLineInsideFencedCode(lines, lineIndex) {
|
|
214
|
+
let inFence = false;
|
|
215
|
+
|
|
216
|
+
for (let index = 0; index < lineIndex; index += 1) {
|
|
217
|
+
if (FENCED_CODE_PATTERN.test(lines[index] ?? '')) {
|
|
218
|
+
inFence = !inFence;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return inFence;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function toggleMarkdownTodoLine(markdown = '', lineIndex) {
|
|
226
|
+
const lines = String(markdown ?? '').split('\n');
|
|
227
|
+
const numericLineIndex = Number(lineIndex);
|
|
228
|
+
|
|
229
|
+
if (!Number.isInteger(numericLineIndex) || numericLineIndex < 0 || numericLineIndex >= lines.length) {
|
|
230
|
+
return String(markdown ?? '');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (isLineInsideFencedCode(lines, numericLineIndex)) {
|
|
234
|
+
return String(markdown ?? '');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const line = lines[numericLineIndex];
|
|
238
|
+
const match = line.match(TASK_LINE_PATTERN);
|
|
239
|
+
|
|
240
|
+
if (!match) {
|
|
241
|
+
return String(markdown ?? '');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const nextMarker = match[3].toLowerCase() === 'x' ? ' ' : 'x';
|
|
245
|
+
lines[numericLineIndex] = `${match[1]}${match[2]} [${nextMarker}] ${match[4]}${match[5]}`;
|
|
246
|
+
|
|
247
|
+
return lines.join('\n');
|
|
248
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function getRowEditorValueState(value) {
|
|
2
|
+
if (value === null || value === undefined) {
|
|
3
|
+
return "null";
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
return String(value) === "" ? "empty" : "value";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getRowEditorValueStateLabel(state) {
|
|
10
|
+
if (state === "null") {
|
|
11
|
+
return "NULL";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (state === "empty") {
|
|
15
|
+
return "EMPTY STRING";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return "VALUE";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildRowEditorSubmittedValues(formData, fieldMetadata = {}) {
|
|
22
|
+
const fieldNames = new Set();
|
|
23
|
+
|
|
24
|
+
for (const [key] of formData.entries()) {
|
|
25
|
+
if (key.startsWith("field:")) {
|
|
26
|
+
fieldNames.add(key.slice("field:".length));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return Object.fromEntries(
|
|
31
|
+
[...fieldNames].map((fieldName) => {
|
|
32
|
+
const valueKey = `field:${fieldName}`;
|
|
33
|
+
const value = String(formData.get(valueKey) ?? "");
|
|
34
|
+
const metadata = fieldMetadata[fieldName] ?? {};
|
|
35
|
+
const keepInitialNull =
|
|
36
|
+
metadata.initialState === "null" && metadata.dirty !== true && value === "";
|
|
37
|
+
|
|
38
|
+
return [fieldName, keepInitialNull ? null : value];
|
|
39
|
+
})
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function captureTableHorizontalScrollState({ routeName = "", scrollNodes = [] } = {}) {
|
|
2
|
+
return {
|
|
3
|
+
routeName: String(routeName ?? ""),
|
|
4
|
+
positions: Array.from(scrollNodes)
|
|
5
|
+
.map((scrollNode) => ({
|
|
6
|
+
key: String(scrollNode?.dataset?.tableScrollKey ?? ""),
|
|
7
|
+
scrollLeft: Number(scrollNode?.scrollLeft) || 0,
|
|
8
|
+
}))
|
|
9
|
+
.filter((position) => position.key),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function restoreTableHorizontalScrollState({
|
|
14
|
+
snapshot,
|
|
15
|
+
routeName = "",
|
|
16
|
+
scrollNodes = [],
|
|
17
|
+
} = {}) {
|
|
18
|
+
if (!snapshot || snapshot.routeName !== String(routeName ?? "")) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const candidates = Array.from(scrollNodes);
|
|
23
|
+
let restored = false;
|
|
24
|
+
|
|
25
|
+
for (const position of snapshot.positions ?? []) {
|
|
26
|
+
const scrollNode = candidates.find(
|
|
27
|
+
(candidate) => String(candidate?.dataset?.tableScrollKey ?? "") === position.key
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
if (!scrollNode) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
scrollNode.scrollLeft = position.scrollLeft;
|
|
35
|
+
restored = true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return restored;
|
|
39
|
+
}
|
|
@@ -220,6 +220,14 @@ function renderQueryResultState(state, result) {
|
|
|
220
220
|
`;
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
+
if (result.truncated) {
|
|
224
|
+
return `
|
|
225
|
+
<div class="border-b border-primary-container/20 bg-primary-container/10 px-4 py-3 text-sm text-on-surface">
|
|
226
|
+
Charts use the first ${escapeHtml(String(result.rowLimit ?? result.rows?.length ?? 0))} rows. Refine the query for a complete visualization.
|
|
227
|
+
</div>
|
|
228
|
+
`;
|
|
229
|
+
}
|
|
230
|
+
|
|
223
231
|
return '';
|
|
224
232
|
}
|
|
225
233
|
|
|
@@ -461,7 +461,9 @@ function renderTableSurface(state) {
|
|
|
461
461
|
return [
|
|
462
462
|
'<div class="flex flex-1 min-h-0 flex-col bg-surface-container-lowest">',
|
|
463
463
|
renderTableFilterBar(table, state, activeColumn),
|
|
464
|
-
'<div class="custom-scrollbar flex-1 overflow-auto"
|
|
464
|
+
'<div class="custom-scrollbar flex-1 overflow-auto" data-table-horizontal-scroll data-table-scroll-key="data:',
|
|
465
|
+
escapeHtml(table.name),
|
|
466
|
+
'">',
|
|
465
467
|
gridMarkup,
|
|
466
468
|
emptyMarkup,
|
|
467
469
|
'</div>',
|
|
@@ -603,6 +605,7 @@ export function renderDataRowEditorPanel(state) {
|
|
|
603
605
|
...getColumnNumberInputMeta(column),
|
|
604
606
|
allowedValues: column.allowedValues ?? [],
|
|
605
607
|
notNull: Boolean(column.notNull),
|
|
608
|
+
rawValue: value,
|
|
606
609
|
value: value === null || value === undefined ? '' : String(value),
|
|
607
610
|
};
|
|
608
611
|
}),
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { escapeHtml, formatCompactDateTime, formatNumber } from '../utils/format.js';
|
|
2
|
+
import { renderMarkdownPreview } from '../utils/markdownDocuments.js';
|
|
3
|
+
|
|
4
|
+
function renderMissingDatabase() {
|
|
5
|
+
return `
|
|
6
|
+
<section class="view-surface">
|
|
7
|
+
<div class="view-frame flex min-h-full items-center justify-center">
|
|
8
|
+
<div class="text-center">
|
|
9
|
+
<span class="material-symbols-outlined mb-3 text-5xl text-on-surface-variant/25">database_off</span>
|
|
10
|
+
<p class="font-headline text-xl font-black uppercase tracking-tight text-primary-container">
|
|
11
|
+
No Active SQLite Database
|
|
12
|
+
</p>
|
|
13
|
+
<p class="mt-3 max-w-xl text-sm leading-7 text-on-surface-variant/65">
|
|
14
|
+
Select a database before opening its document folder.
|
|
15
|
+
</p>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
</section>
|
|
19
|
+
`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function renderDocumentListItem(item, selectedId) {
|
|
23
|
+
const isSelected = String(item.id) === String(selectedId);
|
|
24
|
+
|
|
25
|
+
return `
|
|
26
|
+
<button
|
|
27
|
+
class="documents-list-item ${isSelected ? 'is-selected' : ''}"
|
|
28
|
+
data-action="select-document"
|
|
29
|
+
data-document-id="${escapeHtml(item.id)}"
|
|
30
|
+
type="button"
|
|
31
|
+
>
|
|
32
|
+
<span class="material-symbols-outlined documents-list-item__icon">description</span>
|
|
33
|
+
<span class="documents-list-item__body">
|
|
34
|
+
<span class="documents-list-item__title" title="${escapeHtml(item.filename)}">
|
|
35
|
+
${escapeHtml(item.filename)}
|
|
36
|
+
</span>
|
|
37
|
+
<span class="documents-list-item__meta">
|
|
38
|
+
${escapeHtml(formatCompactDateTime(item.updatedAt))} // ${formatNumber(item.contentLength ?? 0)} chars
|
|
39
|
+
</span>
|
|
40
|
+
</span>
|
|
41
|
+
</button>
|
|
42
|
+
`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function renderDocumentsSidebar(documents) {
|
|
46
|
+
const items = documents.items ?? [];
|
|
47
|
+
|
|
48
|
+
return `
|
|
49
|
+
<aside class="documents-view__sidebar">
|
|
50
|
+
<div class="documents-view__sidebar-header">
|
|
51
|
+
<div>
|
|
52
|
+
<div class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/50">
|
|
53
|
+
Documents
|
|
54
|
+
</div>
|
|
55
|
+
<div class="mt-1 font-mono text-xs text-primary-container">${formatNumber(items.length)} files</div>
|
|
56
|
+
</div>
|
|
57
|
+
<button
|
|
58
|
+
class="icon-button"
|
|
59
|
+
data-action="create-document"
|
|
60
|
+
title="New document"
|
|
61
|
+
type="button"
|
|
62
|
+
${documents.saving ? 'disabled aria-disabled="true"' : ''}
|
|
63
|
+
>
|
|
64
|
+
<span class="material-symbols-outlined">add</span>
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="documents-view__sidebar-body custom-scrollbar">
|
|
68
|
+
${
|
|
69
|
+
items.length
|
|
70
|
+
? items.map(item => renderDocumentListItem(item, documents.selectedId)).join('')
|
|
71
|
+
: `<div class="documents-list-empty">No documents yet</div>`
|
|
72
|
+
}
|
|
73
|
+
</div>
|
|
74
|
+
</aside>
|
|
75
|
+
`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function renderDocumentToolbar(documents) {
|
|
79
|
+
const disabled = documents.saving || documents.detailLoading || !documents.selectedId;
|
|
80
|
+
|
|
81
|
+
return ` <label class="documents-filename-field">
|
|
82
|
+
<input
|
|
83
|
+
class="control-input"
|
|
84
|
+
data-bind="document-field"
|
|
85
|
+
data-field="filename"
|
|
86
|
+
name="filename"
|
|
87
|
+
spellcheck="false"
|
|
88
|
+
type="text"
|
|
89
|
+
value="${escapeHtml(documents.draftFilename)}"
|
|
90
|
+
${!documents.selectedId ? 'disabled' : ''}
|
|
91
|
+
/>
|
|
92
|
+
</label>
|
|
93
|
+
<div class="documents-toolbar">
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
<div class="documents-toolbar__actions">
|
|
98
|
+
<button
|
|
99
|
+
class="standard-button"
|
|
100
|
+
data-action="toggle-document-pane"
|
|
101
|
+
data-pane="editor"
|
|
102
|
+
type="button"
|
|
103
|
+
>
|
|
104
|
+
<span class="material-symbols-outlined">${documents.editorVisible ? 'visibility_off' : 'edit_note'}</span>
|
|
105
|
+
${documents.editorVisible ? 'Hide Editor' : 'Show Editor'}
|
|
106
|
+
</button>
|
|
107
|
+
<button
|
|
108
|
+
class="standard-button"
|
|
109
|
+
data-action="toggle-document-pane"
|
|
110
|
+
data-pane="preview"
|
|
111
|
+
type="button"
|
|
112
|
+
>
|
|
113
|
+
<span class="material-symbols-outlined">${documents.previewVisible ? 'visibility_off' : 'visibility'}</span>
|
|
114
|
+
${documents.previewVisible ? 'Hide Preview' : 'Show Preview'}
|
|
115
|
+
</button>
|
|
116
|
+
<input
|
|
117
|
+
accept=".md,.markdown,text/markdown,text/plain"
|
|
118
|
+
data-bind="document-import-file"
|
|
119
|
+
hidden
|
|
120
|
+
type="file"
|
|
121
|
+
/>
|
|
122
|
+
</div>
|
|
123
|
+
<div class="documents-toolbar__actions">
|
|
124
|
+
<button
|
|
125
|
+
class="standard-button"
|
|
126
|
+
data-action="open-document-insert-table-modal"
|
|
127
|
+
type="button"
|
|
128
|
+
${disabled ? 'disabled aria-disabled="true"' : ''}
|
|
129
|
+
>
|
|
130
|
+
<span class="material-symbols-outlined">table_chart</span>
|
|
131
|
+
Insert Table
|
|
132
|
+
</button>
|
|
133
|
+
<button
|
|
134
|
+
class="standard-button"
|
|
135
|
+
data-action="open-document-insert-note-modal"
|
|
136
|
+
type="button"
|
|
137
|
+
${disabled ? 'disabled aria-disabled="true"' : ''}
|
|
138
|
+
>
|
|
139
|
+
<span class="material-symbols-outlined">note_add</span>
|
|
140
|
+
Insert Note
|
|
141
|
+
</button>
|
|
142
|
+
<button
|
|
143
|
+
class="standard-button"
|
|
144
|
+
data-action="export-document-markdown"
|
|
145
|
+
type="button"
|
|
146
|
+
${disabled ? 'disabled aria-disabled="true"' : ''}
|
|
147
|
+
>
|
|
148
|
+
<span class="material-symbols-outlined">download</span>
|
|
149
|
+
Export .md
|
|
150
|
+
</button>
|
|
151
|
+
<button
|
|
152
|
+
class="standard-button"
|
|
153
|
+
data-action="import-document-markdown"
|
|
154
|
+
type="button"
|
|
155
|
+
${documents.saving ? 'disabled aria-disabled="true"' : ''}
|
|
156
|
+
>
|
|
157
|
+
<span class="material-symbols-outlined">upload_file</span>
|
|
158
|
+
Import .md
|
|
159
|
+
</button>
|
|
160
|
+
</div>
|
|
161
|
+
<div class="documents-toolbar__actions">
|
|
162
|
+
<button
|
|
163
|
+
class="standard-button"
|
|
164
|
+
data-action="delete-document"
|
|
165
|
+
type="button"
|
|
166
|
+
${disabled || documents.deleting ? 'disabled aria-disabled="true"' : ''}
|
|
167
|
+
>
|
|
168
|
+
<span class="material-symbols-outlined">delete</span>
|
|
169
|
+
Delete
|
|
170
|
+
</button>
|
|
171
|
+
<button
|
|
172
|
+
class="signature-button"
|
|
173
|
+
data-action="save-document"
|
|
174
|
+
type="button"
|
|
175
|
+
${disabled || !documents.dirty ? 'disabled aria-disabled="true"' : ''}
|
|
176
|
+
>
|
|
177
|
+
<span class="material-symbols-outlined">save</span>
|
|
178
|
+
${documents.saving ? 'Saving...' : 'Save'}
|
|
179
|
+
</button>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function renderDocumentEditor(documents) {
|
|
186
|
+
if (!documents.editorVisible) {
|
|
187
|
+
return '';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return `
|
|
191
|
+
<section class="documents-pane documents-pane--editor">
|
|
192
|
+
<div class="documents-pane__header">
|
|
193
|
+
<span>Editor</span>
|
|
194
|
+
<span>${formatNumber(documents.draftContent.length)} chars</span>
|
|
195
|
+
</div>
|
|
196
|
+
<textarea
|
|
197
|
+
class="documents-editor-input custom-scrollbar"
|
|
198
|
+
data-bind="document-field"
|
|
199
|
+
data-field="content"
|
|
200
|
+
name="content"
|
|
201
|
+
spellcheck="true"
|
|
202
|
+
${!documents.selectedId ? 'disabled' : ''}
|
|
203
|
+
>${escapeHtml(documents.draftContent)}</textarea>
|
|
204
|
+
</section>
|
|
205
|
+
`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function renderDocumentPreview(documents) {
|
|
209
|
+
if (!documents.previewVisible) {
|
|
210
|
+
return '';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return `
|
|
214
|
+
<section class="documents-pane documents-pane--preview">
|
|
215
|
+
<div class="documents-pane__header">
|
|
216
|
+
<span>Preview</span>
|
|
217
|
+
<span>${escapeHtml(documents.dirty ? 'unsaved' : 'saved')}</span>
|
|
218
|
+
</div>
|
|
219
|
+
<div class="document-markdown-preview custom-scrollbar" data-document-preview>
|
|
220
|
+
${renderMarkdownPreview(documents.draftContent)}
|
|
221
|
+
</div>
|
|
222
|
+
</section>
|
|
223
|
+
`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function renderEmptyDocumentsState(documents) {
|
|
227
|
+
return `
|
|
228
|
+
<div class="documents-empty-state">
|
|
229
|
+
<span class="material-symbols-outlined">description</span>
|
|
230
|
+
<p class="font-headline text-2xl font-black uppercase tracking-tight text-primary-container">
|
|
231
|
+
No Documents
|
|
232
|
+
</p>
|
|
233
|
+
<button
|
|
234
|
+
class="signature-button mt-4"
|
|
235
|
+
data-action="create-document"
|
|
236
|
+
type="button"
|
|
237
|
+
${documents.saving ? 'disabled aria-disabled="true"' : ''}
|
|
238
|
+
>
|
|
239
|
+
<span class="material-symbols-outlined">add</span>
|
|
240
|
+
New Document
|
|
241
|
+
</button>
|
|
242
|
+
</div>
|
|
243
|
+
`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function renderDocumentDetail(documents) {
|
|
247
|
+
if (documents.loading && !documents.items.length) {
|
|
248
|
+
return `
|
|
249
|
+
<main class="documents-view__detail">
|
|
250
|
+
<div class="documents-empty-state">
|
|
251
|
+
<span class="material-symbols-outlined">sync</span>
|
|
252
|
+
<p class="font-headline text-2xl font-black uppercase tracking-tight text-primary-container">
|
|
253
|
+
Loading Documents
|
|
254
|
+
</p>
|
|
255
|
+
</div>
|
|
256
|
+
</main>
|
|
257
|
+
`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!documents.selectedId) {
|
|
261
|
+
return `
|
|
262
|
+
<main class="documents-view__detail">
|
|
263
|
+
${renderEmptyDocumentsState(documents)}
|
|
264
|
+
</main>
|
|
265
|
+
`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const paneCount = Number(documents.editorVisible) + Number(documents.previewVisible);
|
|
269
|
+
|
|
270
|
+
return `
|
|
271
|
+
<main class="documents-view__detail">
|
|
272
|
+
${renderDocumentToolbar(documents)}
|
|
273
|
+
${documents.saveError ? `<div class="documents-error">${escapeHtml(documents.saveError.message)}</div>` : ''}
|
|
274
|
+
<div class="documents-workspace ${paneCount > 1 ? 'documents-workspace--split' : ''}">
|
|
275
|
+
${renderDocumentEditor(documents)}
|
|
276
|
+
${renderDocumentPreview(documents)}
|
|
277
|
+
</div>
|
|
278
|
+
</main>
|
|
279
|
+
`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function renderDocumentsView(state) {
|
|
283
|
+
if (!state.connections.active) {
|
|
284
|
+
return {
|
|
285
|
+
main: renderMissingDatabase(),
|
|
286
|
+
panel: '',
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const documents = state.documents;
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
main: `<div class="documents-view">
|
|
294
|
+
${renderDocumentsSidebar(documents)}
|
|
295
|
+
${renderDocumentDetail(documents)}
|
|
296
|
+
</div>
|
|
297
|
+
</div>`,
|
|
298
|
+
panel: '',
|
|
299
|
+
};
|
|
300
|
+
}
|