sqlite-hub 0.9.3 → 0.9.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.
- package/.github/workflows/ci.yml +36 -0
- package/README.md +2 -2
- package/bin/sqlite-hub.js +1 -1
- package/frontend/index.html +2 -158
- package/frontend/js/app.js +5 -1
- package/frontend/js/components/connectionCard.js +62 -87
- package/frontend/js/components/emptyState.js +20 -23
- package/frontend/js/components/modal.js +145 -195
- package/frontend/js/components/pageHeader.js +1 -1
- package/frontend/js/components/queryEditor.js +16 -30
- package/frontend/js/components/queryHistoryDetail.js +93 -164
- package/frontend/js/components/queryHistoryPanel.js +81 -99
- package/frontend/js/components/queryResults.js +3 -1
- package/frontend/js/components/rowEditorPanel.js +28 -31
- package/frontend/js/components/tableDesignerEditor.js +91 -116
- package/frontend/js/store.js +39 -3
- package/frontend/js/utils/tableDesigner.js +8 -2
- package/frontend/js/views/charts.js +23 -43
- package/frontend/js/views/data.js +116 -132
- package/frontend/js/views/mediaTagging.js +131 -164
- package/frontend/js/views/structure.js +52 -48
- package/frontend/styles/tailwind.css +81 -0
- package/frontend/styles/tailwind.generated.css +1 -0
- package/frontend/styles/tokens.css +3 -3
- package/package.json +16 -3
- package/server/routes/mediaTagging.js +2 -10
- package/server/routes/sql.js +35 -10
- package/server/server.js +24 -0
- package/server/services/sqlite/dataBrowserService.js +25 -5
- package/server/services/sqlite/exportService.js +4 -2
- package/server/services/sqlite/introspection.js +2 -2
- package/server/services/sqlite/mediaTaggingService.js +166 -53
- package/server/services/sqlite/structureService.js +2 -2
- package/server/services/sqlite/tableDesigner/sql.js +19 -3
- package/server/services/storage/appStateStore.js +227 -87
- package/server/utils/appPaths.js +55 -19
- package/server/utils/fileValidation.js +94 -8
- package/tailwind.config.cjs +73 -0
- package/tests/security-paths.test.js +84 -0
- package/tests/sql-identifier-safety.test.js +66 -0
- package/.npmingnore +0 -4
- package/changelog.md +0 -84
- package/docs/DESIGN_GUIDELINES.md +0 -36
- package/scripts/publish_brew.sh +0 -466
- package/scripts/publish_npm.sh +0 -241
- package/shortkeys.md +0 -5
|
@@ -6,25 +6,108 @@ const { ConflictError, NotFoundError, ValidationError } = require("./errors");
|
|
|
6
6
|
const SQLITE_EXTENSIONS = new Set([".db", ".sqlite", ".sqlite3"]);
|
|
7
7
|
const SQL_DUMP_EXTENSIONS = new Set([".sql"]);
|
|
8
8
|
const SQLITE_HEADER = Buffer.from("SQLite format 3\0", "utf8");
|
|
9
|
+
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f]/;
|
|
9
10
|
|
|
10
|
-
function
|
|
11
|
+
function splitPathSegments(filePath) {
|
|
12
|
+
return String(filePath)
|
|
13
|
+
.split(/[\\/]+/)
|
|
14
|
+
.filter((segment) => segment && segment !== ".");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function hasParentTraversal(filePath) {
|
|
18
|
+
return splitPathSegments(filePath).includes("..");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isAbsolutePathInput(filePath) {
|
|
22
|
+
return path.isAbsolute(filePath) || path.win32.isAbsolute(filePath);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function toAbsolutePath(filePath, baseDirectory = process.cwd()) {
|
|
26
|
+
const normalizedPath = String(filePath);
|
|
27
|
+
|
|
28
|
+
if (isAbsolutePathInput(normalizedPath)) {
|
|
29
|
+
return path.normalize(normalizedPath);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return path.normalize(`${baseDirectory}${path.sep}${normalizedPath}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function assertSafePathInput(filePath, label = "File path") {
|
|
11
36
|
if (typeof filePath !== "string" || !filePath.trim()) {
|
|
12
|
-
throw new ValidationError(
|
|
37
|
+
throw new ValidationError(`${label} is required.`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (CONTROL_CHARACTER_PATTERN.test(filePath)) {
|
|
41
|
+
throw new ValidationError(`${label} contains unsupported control characters.`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (hasParentTraversal(filePath)) {
|
|
45
|
+
throw new ValidationError(`${label} must not contain parent directory segments.`);
|
|
13
46
|
}
|
|
14
47
|
|
|
15
|
-
|
|
48
|
+
return filePath.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function expandHome(filePath, label = "File path") {
|
|
52
|
+
const normalizedPath = assertSafePathInput(filePath, label);
|
|
53
|
+
|
|
54
|
+
if (normalizedPath === "~") {
|
|
16
55
|
return os.homedir();
|
|
17
56
|
}
|
|
18
57
|
|
|
19
|
-
if (
|
|
20
|
-
return
|
|
58
|
+
if (normalizedPath.startsWith("~/")) {
|
|
59
|
+
return toAbsolutePath(normalizedPath.slice(2), os.homedir());
|
|
21
60
|
}
|
|
22
61
|
|
|
23
|
-
return
|
|
62
|
+
return normalizedPath;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveBaseDirectory(baseDirectory, label = "Base directory") {
|
|
66
|
+
return toAbsolutePath(assertSafePathInput(baseDirectory, label));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isInsideDirectory(filePath, baseDirectory) {
|
|
70
|
+
const relativePath = path.relative(baseDirectory, filePath);
|
|
71
|
+
return (
|
|
72
|
+
relativePath === "" ||
|
|
73
|
+
(!relativePath.startsWith("..") && !path.isAbsolute(relativePath))
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function assertPathInsideDirectory(filePath, baseDirectory, label = "File path") {
|
|
78
|
+
const resolvedBaseDirectory = resolveBaseDirectory(baseDirectory);
|
|
79
|
+
const resolvedPath = toAbsolutePath(filePath);
|
|
80
|
+
|
|
81
|
+
if (!isInsideDirectory(resolvedPath, resolvedBaseDirectory)) {
|
|
82
|
+
throw new ValidationError(`${label} must stay inside ${resolvedBaseDirectory}.`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return resolvedPath;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolveUserPath(filePath, options = {}) {
|
|
89
|
+
const label = options.label ?? "File path";
|
|
90
|
+
const expandedPath = expandHome(filePath, label);
|
|
91
|
+
const baseDirectory = options.baseDirectory
|
|
92
|
+
? resolveBaseDirectory(options.baseDirectory)
|
|
93
|
+
: null;
|
|
94
|
+
const resolvedPath =
|
|
95
|
+
baseDirectory && !isAbsolutePathInput(expandedPath)
|
|
96
|
+
? toAbsolutePath(expandedPath, baseDirectory)
|
|
97
|
+
: toAbsolutePath(expandedPath);
|
|
98
|
+
|
|
99
|
+
if (baseDirectory) {
|
|
100
|
+
return assertPathInsideDirectory(resolvedPath, baseDirectory, label);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return resolvedPath;
|
|
24
104
|
}
|
|
25
105
|
|
|
26
|
-
function
|
|
27
|
-
return
|
|
106
|
+
function resolvePathInsideDirectory(baseDirectory, inputPath, label = "File path") {
|
|
107
|
+
return resolveUserPath(inputPath, {
|
|
108
|
+
baseDirectory,
|
|
109
|
+
label,
|
|
110
|
+
});
|
|
28
111
|
}
|
|
29
112
|
|
|
30
113
|
function assertExtension(filePath, allowedExtensions, label) {
|
|
@@ -124,11 +207,14 @@ function getFileMetadata(filePath) {
|
|
|
124
207
|
|
|
125
208
|
module.exports = {
|
|
126
209
|
SQLITE_EXTENSIONS,
|
|
210
|
+
assertPathInsideDirectory,
|
|
211
|
+
assertSafePathInput,
|
|
127
212
|
ensureFileDoesNotExist,
|
|
128
213
|
ensureParentDirectory,
|
|
129
214
|
getFileMetadata,
|
|
130
215
|
isRealSqliteDatabase,
|
|
131
216
|
isWritable,
|
|
217
|
+
resolvePathInsideDirectory,
|
|
132
218
|
resolveUserPath,
|
|
133
219
|
validateSqlDumpPath,
|
|
134
220
|
validateSqlitePath,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const forms = require("@tailwindcss/forms");
|
|
2
|
+
const containerQueries = require("@tailwindcss/container-queries");
|
|
3
|
+
|
|
4
|
+
module.exports = {
|
|
5
|
+
darkMode: "class",
|
|
6
|
+
content: ["./frontend/index.html", "./frontend/js/**/*.js"],
|
|
7
|
+
theme: {
|
|
8
|
+
extend: {
|
|
9
|
+
colors: {
|
|
10
|
+
"on-secondary-fixed": "#1c1b1b",
|
|
11
|
+
background: "rgb(var(--rgb-background) / <alpha-value>)",
|
|
12
|
+
"inverse-primary": "#6a5f00",
|
|
13
|
+
"primary-fixed": "#fde403",
|
|
14
|
+
tertiary: "#fbfffe",
|
|
15
|
+
outline: "#979177",
|
|
16
|
+
"on-secondary": "#313030",
|
|
17
|
+
"error-container": "#93000a",
|
|
18
|
+
"on-primary-fixed-variant": "#504700",
|
|
19
|
+
"on-tertiary-container": "#006f72",
|
|
20
|
+
"on-tertiary-fixed-variant": "#004f51",
|
|
21
|
+
"secondary-fixed-dim": "#c8c6c5",
|
|
22
|
+
"tertiary-fixed-dim": "#00dce1",
|
|
23
|
+
"on-background": "rgb(var(--rgb-on-surface) / <alpha-value>)",
|
|
24
|
+
"surface-tint": "#dec800",
|
|
25
|
+
"primary-fixed-dim": "#dec800",
|
|
26
|
+
"on-surface": "rgb(var(--rgb-on-surface) / <alpha-value>)",
|
|
27
|
+
"surface-container-low": "rgb(var(--rgb-surface-low) / <alpha-value>)",
|
|
28
|
+
"on-tertiary": "#003738",
|
|
29
|
+
"surface-container": "rgb(var(--rgb-surface-container) / <alpha-value>)",
|
|
30
|
+
"on-surface-variant": "rgb(var(--rgb-on-surface-variant) / <alpha-value>)",
|
|
31
|
+
"on-primary-container": "#706400",
|
|
32
|
+
"on-error": "#690005",
|
|
33
|
+
"surface-container-high": "rgb(var(--rgb-surface-high) / <alpha-value>)",
|
|
34
|
+
"surface-container-highest": "rgb(var(--rgb-surface-highest) / <alpha-value>)",
|
|
35
|
+
error: "rgb(var(--rgb-error) / <alpha-value>)",
|
|
36
|
+
primary: "#fffeff",
|
|
37
|
+
"outline-variant": "rgb(var(--rgb-outline) / <alpha-value>)",
|
|
38
|
+
"on-error-container": "#ffdad6",
|
|
39
|
+
"on-secondary-fixed-variant": "#474746",
|
|
40
|
+
"surface-container-lowest": "rgb(var(--rgb-surface-lowest) / <alpha-value>)",
|
|
41
|
+
"inverse-surface": "#e5e2e1",
|
|
42
|
+
surface: "rgb(var(--rgb-surface) / <alpha-value>)",
|
|
43
|
+
"tertiary-container": "#04faff",
|
|
44
|
+
"on-tertiary-fixed": "#002021",
|
|
45
|
+
"tertiary-fixed": "#2dfaff",
|
|
46
|
+
"surface-variant": "#353534",
|
|
47
|
+
"on-primary": "#373100",
|
|
48
|
+
"primary-container": "rgb(var(--rgb-primary) / <alpha-value>)",
|
|
49
|
+
"inverse-on-surface": "#313030",
|
|
50
|
+
secondary: "#c8c6c5",
|
|
51
|
+
"on-secondary-container": "#b7b5b4",
|
|
52
|
+
"on-primary-fixed": "#201c00",
|
|
53
|
+
"secondary-container": "#474746",
|
|
54
|
+
"surface-dim": "rgb(var(--rgb-surface) / <alpha-value>)",
|
|
55
|
+
"surface-bright": "rgb(var(--rgb-surface-bright) / <alpha-value>)",
|
|
56
|
+
"secondary-fixed": "#e5e2e1",
|
|
57
|
+
},
|
|
58
|
+
fontFamily: {
|
|
59
|
+
headline: "var(--font-family-headline)",
|
|
60
|
+
body: "var(--font-family-body)",
|
|
61
|
+
label: "var(--font-family-body)",
|
|
62
|
+
mono: "var(--font-family-mono)",
|
|
63
|
+
},
|
|
64
|
+
borderRadius: {
|
|
65
|
+
DEFAULT: "0px",
|
|
66
|
+
lg: "0px",
|
|
67
|
+
xl: "0px",
|
|
68
|
+
full: "9999px",
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
plugins: [forms, containerQueries],
|
|
73
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const os = require("node:os");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const { pathToFileURL } = require("node:url");
|
|
5
|
+
const assert = require("node:assert/strict");
|
|
6
|
+
const test = require("node:test");
|
|
7
|
+
const { ValidationError } = require("../server/utils/errors");
|
|
8
|
+
const {
|
|
9
|
+
resolvePathInsideDirectory,
|
|
10
|
+
resolveUserPath,
|
|
11
|
+
} = require("../server/utils/fileValidation");
|
|
12
|
+
const { MediaTaggingService } = require("../server/services/sqlite/mediaTaggingService");
|
|
13
|
+
|
|
14
|
+
function createTempDirectory(t) {
|
|
15
|
+
const directoryPath = fs.mkdtempSync(path.join(os.tmpdir(), "sqlite-hub-security-"));
|
|
16
|
+
t.after(() => fs.rmSync(directoryPath, { recursive: true, force: true }));
|
|
17
|
+
return directoryPath;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test("resolveUserPath rejects parent traversal segments", () => {
|
|
21
|
+
assert.throws(() => resolveUserPath("../outside.db"), ValidationError);
|
|
22
|
+
assert.throws(() => resolveUserPath("~/../outside.db"), ValidationError);
|
|
23
|
+
assert.throws(() => resolveUserPath("data/\0outside.db"), ValidationError);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("resolvePathInsideDirectory resolves only paths under the supplied root", (t) => {
|
|
27
|
+
const root = createTempDirectory(t);
|
|
28
|
+
const insidePath = path.join(root, "media", "photo.jpg");
|
|
29
|
+
|
|
30
|
+
assert.equal(
|
|
31
|
+
resolvePathInsideDirectory(root, "media/photo.jpg", "Media file path"),
|
|
32
|
+
insidePath
|
|
33
|
+
);
|
|
34
|
+
assert.equal(
|
|
35
|
+
resolvePathInsideDirectory(root, insidePath, "Media file path"),
|
|
36
|
+
insidePath
|
|
37
|
+
);
|
|
38
|
+
assert.throws(
|
|
39
|
+
() =>
|
|
40
|
+
resolvePathInsideDirectory(
|
|
41
|
+
root,
|
|
42
|
+
path.join(path.dirname(root), "secret.txt"),
|
|
43
|
+
"Media file path"
|
|
44
|
+
),
|
|
45
|
+
ValidationError
|
|
46
|
+
);
|
|
47
|
+
assert.throws(
|
|
48
|
+
() => resolvePathInsideDirectory(root, "../secret.txt", "Media file path"),
|
|
49
|
+
ValidationError
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("media file resolution is scoped to the active database directory", (t) => {
|
|
54
|
+
const root = createTempDirectory(t);
|
|
55
|
+
const mediaDirectory = path.join(root, "media");
|
|
56
|
+
const mediaPath = path.join(mediaDirectory, "photo.jpg");
|
|
57
|
+
|
|
58
|
+
fs.mkdirSync(mediaDirectory, { recursive: true });
|
|
59
|
+
fs.writeFileSync(mediaPath, "image");
|
|
60
|
+
|
|
61
|
+
const service = new MediaTaggingService({
|
|
62
|
+
connectionManager: {
|
|
63
|
+
getActiveConnection: () => ({
|
|
64
|
+
path: path.join(root, "database.sqlite"),
|
|
65
|
+
}),
|
|
66
|
+
},
|
|
67
|
+
appStateStore: {},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
assert.equal(service.resolveMediaFilePath("media/photo.jpg"), mediaPath);
|
|
71
|
+
assert.equal(
|
|
72
|
+
service.resolveMediaFilePath(pathToFileURL(mediaPath).toString()),
|
|
73
|
+
mediaPath
|
|
74
|
+
);
|
|
75
|
+
assert.deepEqual(service.getMediaFileForRequest("media/photo.jpg"), {
|
|
76
|
+
directory: mediaDirectory,
|
|
77
|
+
fileName: "photo.jpg",
|
|
78
|
+
});
|
|
79
|
+
assert.throws(() => service.resolveMediaFilePath("../secret.jpg"), ValidationError);
|
|
80
|
+
assert.throws(
|
|
81
|
+
() => service.resolveMediaFilePath(path.join(path.dirname(root), "secret.jpg")),
|
|
82
|
+
ValidationError
|
|
83
|
+
);
|
|
84
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const Database = require("better-sqlite3");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const { DataBrowserService } = require("../server/services/sqlite/dataBrowserService");
|
|
5
|
+
const { quoteIdentifier } = require("../server/utils/identifier");
|
|
6
|
+
|
|
7
|
+
test("data browser mutations preserve quoted dynamic identifiers", () => {
|
|
8
|
+
const db = new Database(":memory:");
|
|
9
|
+
const tableName = 'items" archived';
|
|
10
|
+
const valueColumn = 'display"name';
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
db.exec(
|
|
14
|
+
[
|
|
15
|
+
"CREATE TABLE",
|
|
16
|
+
quoteIdentifier(tableName),
|
|
17
|
+
"(",
|
|
18
|
+
quoteIdentifier(valueColumn),
|
|
19
|
+
"TEXT, status INTEGER",
|
|
20
|
+
")",
|
|
21
|
+
].join(" ")
|
|
22
|
+
);
|
|
23
|
+
db.prepare(
|
|
24
|
+
[
|
|
25
|
+
"INSERT INTO",
|
|
26
|
+
quoteIdentifier(tableName),
|
|
27
|
+
"(" + quoteIdentifier(valueColumn) + ", status)",
|
|
28
|
+
"VALUES (?, ?)",
|
|
29
|
+
].join(" ")
|
|
30
|
+
).run("before", 0);
|
|
31
|
+
|
|
32
|
+
const service = new DataBrowserService({
|
|
33
|
+
connectionManager: {
|
|
34
|
+
assertWritable() {},
|
|
35
|
+
getActiveDatabase: () => db,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
const tableData = service.getTableData(tableName, { limit: 10, offset: 0 });
|
|
39
|
+
const identity = tableData.rows[0].__identity;
|
|
40
|
+
|
|
41
|
+
const updated = service.updateTableRow(tableName, {
|
|
42
|
+
identity,
|
|
43
|
+
values: {
|
|
44
|
+
[valueColumn]: "after",
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
assert.equal(updated.row[valueColumn], "after");
|
|
49
|
+
assert.equal(
|
|
50
|
+
db.prepare(["SELECT", quoteIdentifier(valueColumn), "FROM", quoteIdentifier(tableName)].join(" ")).get()[valueColumn],
|
|
51
|
+
"after"
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const deleted = service.deleteTableRow(tableName, {
|
|
55
|
+
identity: updated.row.__identity,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
assert.equal(deleted.affectedRowCount, 1);
|
|
59
|
+
assert.equal(
|
|
60
|
+
db.prepare(["SELECT COUNT(*) AS count FROM", quoteIdentifier(tableName)].join(" ")).get().count,
|
|
61
|
+
0
|
|
62
|
+
);
|
|
63
|
+
} finally {
|
|
64
|
+
db.close();
|
|
65
|
+
}
|
|
66
|
+
});
|
package/.npmingnore
DELETED
package/changelog.md
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
# v0.9.3
|
|
2
|
-
|
|
3
|
-
- improving rerendering
|
|
4
|
-
- fixed the sorting bug in charts
|
|
5
|
-
- data row size is stored in localstorage
|
|
6
|
-
- saved charts
|
|
7
|
-
|
|
8
|
-
# v0.9.1
|
|
9
|
-
|
|
10
|
-
- Improvements in tagging queue
|
|
11
|
-
- rotate right, rotate left
|
|
12
|
-
|
|
13
|
-
# v0.9.0
|
|
14
|
-
|
|
15
|
-
- tagging view
|
|
16
|
-
- filtered out the create, update, delete queries from the chart section
|
|
17
|
-
|
|
18
|
-
# v0.8.8
|
|
19
|
-
|
|
20
|
-
- modal window fix in delete query
|
|
21
|
-
- csv export filename based on query name
|
|
22
|
-
- unsaved query history tab
|
|
23
|
-
|
|
24
|
-
# v0.8.7
|
|
25
|
-
|
|
26
|
-
- UX fixes
|
|
27
|
-
- fixing a lot of the vibe slop
|
|
28
|
-
- trying to build reuseable components
|
|
29
|
-
|
|
30
|
-
# v0.8.0
|
|
31
|
-
|
|
32
|
-
- DDL copy button
|
|
33
|
-
- open in charts button in query details
|
|
34
|
-
- clear makes editor active
|
|
35
|
-
- UI fixes
|
|
36
|
-
- Overview improvement
|
|
37
|
-
|
|
38
|
-
# v0.7.0
|
|
39
|
-
|
|
40
|
-
- hide query history, hide editor
|
|
41
|
-
- better cli interface
|
|
42
|
-
- plotting and graphs
|
|
43
|
-
- removing inconsistencies in the UI
|
|
44
|
-
|
|
45
|
-
# v0.6.0
|
|
46
|
-
|
|
47
|
-
- table designer, create tables in sqlit_hub
|
|
48
|
-
|
|
49
|
-
# v0.5.1
|
|
50
|
-
|
|
51
|
-
- bug fix in sql editor
|
|
52
|
-
|
|
53
|
-
# v0.5.0
|
|
54
|
-
|
|
55
|
-
- sql query history
|
|
56
|
-
- open in finder in overview
|
|
57
|
-
- sort columns in data & sql_editor
|
|
58
|
-
- json view in row editor
|
|
59
|
-
|
|
60
|
-
# v0.4.0
|
|
61
|
-
|
|
62
|
-
- search in data
|
|
63
|
-
- shift + enter
|
|
64
|
-
- logos for databases
|
|
65
|
-
- clean up some chaotic code things :)
|
|
66
|
-
|
|
67
|
-
# v0.3.2
|
|
68
|
-
|
|
69
|
-
- delete rows
|
|
70
|
-
- modal windows for rows
|
|
71
|
-
|
|
72
|
-
# v0.3.1
|
|
73
|
-
|
|
74
|
-
- visualize tables
|
|
75
|
-
- export csv from data and sql_editor
|
|
76
|
-
- Backup mode
|
|
77
|
-
|
|
78
|
-
# v0.2.0
|
|
79
|
-
|
|
80
|
-
- db fix
|
|
81
|
-
|
|
82
|
-
# v0.1.3
|
|
83
|
-
|
|
84
|
-
- edit in sql editor
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
# SQLite Hub UI Rules for Codex
|
|
2
|
-
|
|
3
|
-
Follow these rules exactly when creating or modifying UI controls.
|
|
4
|
-
|
|
5
|
-
1. Reuse existing shared components before creating anything new.
|
|
6
|
-
2. Use exactly one semantic main class per control.
|
|
7
|
-
3. Never combine multiple button-style classes on the same element.
|
|
8
|
-
4. View-specific classes may change layout, width, spacing, or position only.
|
|
9
|
-
5. View-specific classes must not redefine the base style of shared components.
|
|
10
|
-
6. The standard height for interactive controls is `36px`.
|
|
11
|
-
7. The source of truth for control height is `--control-height` in `frontend/styles/tokens.css`.
|
|
12
|
-
8. Controls in the same row must have the same height.
|
|
13
|
-
9. All regular buttons must use one of these classes only: `standard-button`, `signature-button`, `delete-button`.
|
|
14
|
-
10. Use `standard-button` for normal actions.
|
|
15
|
-
11. Use `signature-button` only for the primary yellow CTA in a context.
|
|
16
|
-
12. Use `delete-button` only for destructive actions.
|
|
17
|
-
13. `signature-button` must keep its hover state and chamfered corner.
|
|
18
|
-
14. All visible clickable buttons must have a hover state.
|
|
19
|
-
15. Disabled states must come from the shared component, not from local view CSS.
|
|
20
|
-
16. All reusable checkboxes must use `standard-checkbox`.
|
|
21
|
-
17. Do not build feature-specific checkbox shells or checkbox base styles.
|
|
22
|
-
18. Inputs and selects must follow the shared base rules in `frontend/styles/base.css`.
|
|
23
|
-
19. Ghost or transparent inputs must not use a white background, including unfocused state.
|
|
24
|
-
20. When a new control pattern appears in multiple places, promote it to a shared component first, then migrate existing usages.
|
|
25
|
-
|
|
26
|
-
## Source of Truth
|
|
27
|
-
|
|
28
|
-
- `frontend/styles/tokens.css`
|
|
29
|
-
- `frontend/styles/base.css`
|
|
30
|
-
- `frontend/index.html`
|
|
31
|
-
|
|
32
|
-
## Default Decision Rules
|
|
33
|
-
|
|
34
|
-
- Prefer reuse over invention.
|
|
35
|
-
- Prefer migration over local override.
|
|
36
|
-
- Prefer shared component updates over per-view fixes.
|