@aiot-nexus/plugin-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +74 -0
  2. package/dev-host/host.html +344 -0
  3. package/dev-host/main.js +694 -0
  4. package/dev-host/preload.js +22 -0
  5. package/dist/.generated/plugin-framework-version.d.ts +11 -0
  6. package/dist/.generated/plugin-framework-version.js +39 -0
  7. package/dist/.generated/plugin-manifest.schema.json +106 -0
  8. package/dist/browser/frame-client.d.ts +26 -0
  9. package/dist/browser/frame-client.mjs +239 -0
  10. package/dist/src/browser/frame-client.d.ts +26 -0
  11. package/dist/src/browser/frame-client.js +244 -0
  12. package/dist/src/cli/run.d.ts +7 -0
  13. package/dist/src/cli/run.js +270 -0
  14. package/dist/src/cli.d.ts +2 -0
  15. package/dist/src/cli.js +9 -0
  16. package/dist/src/dev.d.ts +36 -0
  17. package/dist/src/dev.js +224 -0
  18. package/dist/src/index.d.ts +7 -0
  19. package/dist/src/index.js +27 -0
  20. package/dist/src/scaffold.d.ts +23 -0
  21. package/dist/src/scaffold.js +306 -0
  22. package/dist/src/types.d.ts +142 -0
  23. package/dist/src/types.js +2 -0
  24. package/dist/src/validation.d.ts +9 -0
  25. package/dist/src/validation.js +160 -0
  26. package/package.json +47 -0
  27. package/templates/minimal/.gitignore.tmpl +4 -0
  28. package/templates/minimal/.vscode/launch.json +22 -0
  29. package/templates/minimal/README.md.tmpl +22 -0
  30. package/templates/minimal/scripts/build.js.tmpl +83 -0
  31. package/templates/minimal/src/main.ts.tmpl +23 -0
  32. package/templates/minimal/src/renderer/app.ts.tmpl +194 -0
  33. package/templates/minimal/src/renderer/index.html +34 -0
  34. package/templates/minimal/src/renderer/plugin.css +164 -0
  35. package/templates/minimal/tsconfig.main.json +15 -0
  36. package/templates/minimal/tsconfig.renderer.json +17 -0
  37. package/templates/sqlite/.gitignore.tmpl +4 -0
  38. package/templates/sqlite/.vscode/launch.json +22 -0
  39. package/templates/sqlite/README.md.tmpl +22 -0
  40. package/templates/sqlite/scripts/build.js.tmpl +143 -0
  41. package/templates/sqlite/src/better-sqlite3.d.ts +22 -0
  42. package/templates/sqlite/src/main.ts.tmpl +190 -0
  43. package/templates/sqlite/src/renderer/app.ts.tmpl +428 -0
  44. package/templates/sqlite/src/renderer/index.html +57 -0
  45. package/templates/sqlite/src/renderer/plugin.css +275 -0
  46. package/templates/sqlite/tsconfig.main.json +15 -0
  47. package/templates/sqlite/tsconfig.renderer.json +17 -0
@@ -0,0 +1,190 @@
1
+ import Database = require("better-sqlite3");
2
+ import type {
3
+ NexusResult,
4
+ PluginMainActivationContext,
5
+ PluginRendererOperationHandler,
6
+ PluginRendererOperationRequest
7
+ } from "@aiot-nexus/plugin-sdk";
8
+
9
+ interface SqliteRecord {
10
+ id: number;
11
+ title: string;
12
+ note: string;
13
+ createdAt: string;
14
+ }
15
+
16
+ const databaseName = "plugin.db";
17
+ const tableName = "plugin_records";
18
+ let database: Database | undefined;
19
+ let databasePath = "";
20
+ let databaseCreated = false;
21
+
22
+ export function activate(context: PluginMainActivationContext): void {
23
+ const fs = require("fs") as typeof import("fs");
24
+ const path = require("path") as typeof import("path");
25
+ fs.mkdirSync(context.host.pluginDataDirectory, { recursive: true });
26
+ databasePath = path.join(context.host.pluginDataDirectory, databaseName);
27
+ databaseCreated = !fs.existsSync(databasePath);
28
+ database = new Database(databasePath);
29
+ database.pragma("journal_mode = DELETE");
30
+ database.prepare(
31
+ "CREATE TABLE IF NOT EXISTS " + tableName + " ("+
32
+ "id INTEGER PRIMARY KEY AUTOINCREMENT, "+
33
+ "title TEXT NOT NULL, "+
34
+ "note TEXT NOT NULL DEFAULT '', "+
35
+ "created_at TEXT NOT NULL"+
36
+ ")"
37
+ ).run();
38
+
39
+ register(context, "sqlite.getInfo", getInfo);
40
+ register(context, "sqlite.listRecords", listRecords);
41
+ register(context, "sqlite.insertRecord", insertRecord);
42
+ register(context, "sqlite.updateRecord", updateRecord);
43
+ register(context, "sqlite.deleteRecord", deleteRecord);
44
+ context.host.log("info", "__PLUGIN_ID__ database " + (databaseCreated ? "created" : "loaded") + ": " + databasePath);
45
+ }
46
+
47
+ function register(context: PluginMainActivationContext, name: string, handler: PluginRendererOperationHandler): void {
48
+ const result = context.host.registerRendererMethod(name, handler);
49
+ if (!result.ok) {
50
+ throw new Error(result.error.message);
51
+ }
52
+ }
53
+
54
+ function getInfo(): Record<string, unknown> {
55
+ const activeDatabase = requireDatabase();
56
+ const countRow = requireObject(activeDatabase.prepare("SELECT COUNT(*) AS count FROM " + tableName).get(), "count row");
57
+ const latestRow = requireObject(activeDatabase.prepare("SELECT MAX(created_at) AS latest FROM " + tableName).get(), "latest row");
58
+ return {
59
+ databaseName,
60
+ databasePath,
61
+ databaseCreated,
62
+ tableName,
63
+ recordCount: typeof countRow.count === "number" ? countRow.count : 0,
64
+ databaseReady: true,
65
+ latestCreatedAt: typeof latestRow.latest === "string" ? latestRow.latest : null
66
+ };
67
+ }
68
+
69
+ function listRecords(): { records: SqliteRecord[] } {
70
+ const rows = requireDatabase().prepare(
71
+ "SELECT id, title, note, created_at AS createdAt FROM " + tableName + " ORDER BY id DESC LIMIT 100"
72
+ ).all();
73
+ return { records: rows.map(readRecordRow) };
74
+ }
75
+
76
+ function insertRecord(request: PluginRendererOperationRequest): NexusResult<never> | { record: SqliteRecord } {
77
+ const inputResult = readRecordInput(request.input, "Insert");
78
+ if (!inputResult.ok) {
79
+ return inputResult;
80
+ }
81
+ const createdAt = new Date().toISOString();
82
+ const activeDatabase = requireDatabase();
83
+ const insertResult = activeDatabase.prepare(
84
+ "INSERT INTO " + tableName + " (title, note, created_at) VALUES (?, ?, ?)"
85
+ ).run(inputResult.value.title, inputResult.value.note, createdAt);
86
+ const id = Number(insertResult.lastInsertRowid);
87
+ const record = readRecord(id);
88
+ if (!record) {
89
+ throw new Error("Inserted SQLite record could not be read.");
90
+ }
91
+ return { record };
92
+ }
93
+
94
+ function updateRecord(request: PluginRendererOperationRequest): NexusResult<never> | { record: SqliteRecord } {
95
+ const idResult = readRecordId(request.input, "Update");
96
+ if (!idResult.ok) {
97
+ return idResult;
98
+ }
99
+ const inputResult = readRecordInput(request.input, "Update");
100
+ if (!inputResult.ok) {
101
+ return inputResult;
102
+ }
103
+ const updateResult = requireDatabase().prepare(
104
+ "UPDATE " + tableName + " SET title = ?, note = ? WHERE id = ?"
105
+ ).run(inputResult.value.title, inputResult.value.note, idResult.value);
106
+ if (updateResult.changes === 0) {
107
+ return fail("NotFound", "Record was not found.");
108
+ }
109
+ const record = readRecord(idResult.value);
110
+ if (!record) {
111
+ throw new Error("Updated SQLite record could not be read.");
112
+ }
113
+ return { record };
114
+ }
115
+
116
+ function deleteRecord(request: PluginRendererOperationRequest): NexusResult<never> | { deletedId: number; record: SqliteRecord } {
117
+ const idResult = readRecordId(request.input, "Delete");
118
+ if (!idResult.ok) {
119
+ return idResult;
120
+ }
121
+ const record = readRecord(idResult.value);
122
+ if (!record) {
123
+ return fail("NotFound", "Record was not found.");
124
+ }
125
+ requireDatabase().prepare("DELETE FROM " + tableName + " WHERE id = ?").run(idResult.value);
126
+ return { deletedId: idResult.value, record };
127
+ }
128
+
129
+ function readRecord(id: number): SqliteRecord | undefined {
130
+ const row = requireDatabase().prepare(
131
+ "SELECT id, title, note, created_at AS createdAt FROM " + tableName + " WHERE id = ?"
132
+ ).get(id);
133
+ return row === undefined ? undefined : readRecordRow(row);
134
+ }
135
+
136
+ function readRecordRow(value: unknown): SqliteRecord {
137
+ const row = requireObject(value, "record row");
138
+ if (!Number.isSafeInteger(row.id) || typeof row.title !== "string" || typeof row.note !== "string" || typeof row.createdAt !== "string") {
139
+ throw new Error("SQLite record row is malformed.");
140
+ }
141
+ return { id: row.id as number, title: row.title, note: row.note, createdAt: row.createdAt };
142
+ }
143
+
144
+ function readRecordInput(input: Record<string, unknown> | undefined, action: string): NexusResult<{ title: string; note: string }> {
145
+ if (!input) {
146
+ return fail("InvalidRequest", action + " input must be an object.");
147
+ }
148
+ const title = typeof input.title === "string" ? input.title.trim() : "";
149
+ const note = typeof input.note === "string" ? input.note.trim() : "";
150
+ if (title.length < 1 || title.length > 80) {
151
+ return fail("InvalidRequest", "Title must be 1 to 80 characters.");
152
+ }
153
+ if (note.length > 500) {
154
+ return fail("InvalidRequest", "Note must be 500 characters or fewer.");
155
+ }
156
+ return { ok: true, value: { title, note } };
157
+ }
158
+
159
+ function readRecordId(input: Record<string, unknown> | undefined, action: string): NexusResult<number> {
160
+ if (!input) {
161
+ return fail("InvalidRequest", action + " input must be an object.");
162
+ }
163
+ const id = typeof input.id === "number"
164
+ ? input.id
165
+ : typeof input.id === "string"
166
+ ? Number(input.id.trim())
167
+ : Number.NaN;
168
+ if (!Number.isSafeInteger(id) || id < 1) {
169
+ return fail("InvalidRequest", action + " record id must be a positive integer.");
170
+ }
171
+ return { ok: true, value: id };
172
+ }
173
+
174
+ function requireDatabase(): Database {
175
+ if (!database) {
176
+ throw new Error("SQLite database is not initialized.");
177
+ }
178
+ return database;
179
+ }
180
+
181
+ function requireObject(value: unknown, label: string): Record<string, unknown> {
182
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
183
+ throw new Error("SQLite " + label + " is malformed.");
184
+ }
185
+ return value as Record<string, unknown>;
186
+ }
187
+
188
+ function fail<T = never>(code: string, message: string): NexusResult<T> {
189
+ return { ok: false, error: { code, message } };
190
+ }
@@ -0,0 +1,428 @@
1
+ import { createPluginFrameClient } from "@aiot-nexus/plugin-sdk/browser";
2
+
3
+ interface DatabaseInfo {
4
+ databaseName: string;
5
+ databasePath: string;
6
+ tableName: string;
7
+ recordCount: number;
8
+ databaseReady: boolean;
9
+ }
10
+
11
+ interface SqliteRecord {
12
+ id: number;
13
+ title: string;
14
+ note: string;
15
+ createdAt: string;
16
+ }
17
+
18
+ type SupportedLanguage = "en-US" | "zh-CN";
19
+ type MessageParams = Record<string, string | number>;
20
+
21
+ const englishMessages = {
22
+ "document.title": "Plugin SQLite",
23
+ "heading.records": "SQLite Records",
24
+ "summary.aria": "Database summary",
25
+ "summary.database": "Database",
26
+ "summary.table": "Table",
27
+ "summary.rows": "Rows",
28
+ "summary.status": "Status",
29
+ "form.insertRow": "Insert Row",
30
+ "form.editRow": "Edit Row",
31
+ "field.title": "Title",
32
+ "field.note": "Note",
33
+ "section.recentRows": "Recent Rows",
34
+ "table.id": "ID",
35
+ "table.title": "Title",
36
+ "table.note": "Note",
37
+ "table.created": "Created",
38
+ "table.actions": "Actions",
39
+ "action.refresh": "Refresh",
40
+ "action.insert": "Insert",
41
+ "action.save": "Save",
42
+ "action.cancel": "Cancel",
43
+ "action.confirm": "Confirm",
44
+ "action.edit": "Edit",
45
+ "action.delete": "Delete",
46
+ "status.connecting": "Connecting",
47
+ "status.waiting": "Waiting",
48
+ "status.idle": "Idle",
49
+ "status.loading": "Loading",
50
+ "status.ready": "Ready",
51
+ "status.unavailable": "Unavailable",
52
+ "status.updating": "Updating",
53
+ "status.inserting": "Inserting",
54
+ "status.deleting": "Deleting",
55
+ "status.updatedRow": "Updated row #{id}.",
56
+ "status.insertedRow": "Inserted row #{id}.",
57
+ "status.deletedRow": "Deleted row #{id}.",
58
+ "empty.noRows": "No rows yet.",
59
+ "validation.titleRequired": "Title is required.",
60
+ "error.initializationFailed": "Initialization failed: {message}",
61
+ "error.refreshFailed": "Refresh failed: {message}",
62
+ "error.deleteFailed": "Delete failed: {message}"
63
+ } as const;
64
+
65
+ type MessageKey = keyof typeof englishMessages;
66
+
67
+ const messages: Record<SupportedLanguage, Record<MessageKey, string>> = {
68
+ "en-US": englishMessages,
69
+ "zh-CN": {
70
+ "document.title": "插件 SQLite",
71
+ "heading.records": "SQLite 记录",
72
+ "summary.aria": "数据库摘要",
73
+ "summary.database": "数据库",
74
+ "summary.table": "数据表",
75
+ "summary.rows": "记录数",
76
+ "summary.status": "状态",
77
+ "form.insertRow": "新增记录",
78
+ "form.editRow": "编辑记录",
79
+ "field.title": "标题",
80
+ "field.note": "备注",
81
+ "section.recentRows": "最近记录",
82
+ "table.id": "ID",
83
+ "table.title": "标题",
84
+ "table.note": "备注",
85
+ "table.created": "创建时间",
86
+ "table.actions": "操作",
87
+ "action.refresh": "刷新",
88
+ "action.insert": "新增",
89
+ "action.save": "保存",
90
+ "action.cancel": "取消",
91
+ "action.confirm": "确认",
92
+ "action.edit": "编辑",
93
+ "action.delete": "删除",
94
+ "status.connecting": "正在连接",
95
+ "status.waiting": "等待中",
96
+ "status.idle": "空闲",
97
+ "status.loading": "正在加载",
98
+ "status.ready": "已就绪",
99
+ "status.unavailable": "不可用",
100
+ "status.updating": "正在更新",
101
+ "status.inserting": "正在新增",
102
+ "status.deleting": "正在删除",
103
+ "status.updatedRow": "已更新第 #{id} 条记录。",
104
+ "status.insertedRow": "已新增第 #{id} 条记录。",
105
+ "status.deletedRow": "已删除第 #{id} 条记录。",
106
+ "empty.noRows": "暂无记录。",
107
+ "validation.titleRequired": "标题不能为空。",
108
+ "error.initializationFailed": "初始化失败:{message}",
109
+ "error.refreshFailed": "刷新失败:{message}",
110
+ "error.deleteFailed": "删除失败:{message}"
111
+ }
112
+ };
113
+
114
+ type LocalizedState =
115
+ | { kind: "message"; key: MessageKey; params?: MessageParams }
116
+ | { kind: "literal"; value: string };
117
+
118
+ const client = createPluginFrameClient();
119
+ const elements = {
120
+ databasePath: requireElement<HTMLElement>("databasePath"),
121
+ databaseName: requireElement<HTMLElement>("databaseName"),
122
+ tableName: requireElement<HTMLElement>("tableName"),
123
+ recordCount: requireElement<HTMLElement>("recordCount"),
124
+ loadingState: requireElement<HTMLElement>("loadingState"),
125
+ refreshButton: requireElement<HTMLButtonElement>("refreshButton"),
126
+ entryFormTitle: requireElement<HTMLElement>("entryFormTitle"),
127
+ titleInput: requireElement<HTMLInputElement>("titleInput"),
128
+ noteInput: requireElement<HTMLTextAreaElement>("noteInput"),
129
+ insertButton: requireElement<HTMLButtonElement>("insertButton"),
130
+ cancelEditButton: requireElement<HTMLButtonElement>("cancelEditButton"),
131
+ formError: requireElement<HTMLElement>("formError"),
132
+ emptyState: requireElement<HTMLElement>("emptyState"),
133
+ recordsBody: requireElement<HTMLTableSectionElement>("recordsBody"),
134
+ statusLog: requireElement<HTMLElement>("statusLog")
135
+ };
136
+
137
+ let language: SupportedLanguage = "en-US";
138
+ let records: SqliteRecord[] = [];
139
+ let editingRecord: SqliteRecord | undefined;
140
+ let pendingDeleteId: number | undefined;
141
+ let databasePath = "";
142
+ let busy = false;
143
+ let busyLabelKey: MessageKey = "status.waiting";
144
+ let statusState: LocalizedState = { kind: "message", key: "status.idle" };
145
+ let formErrorState: LocalizedState | undefined;
146
+
147
+ elements.refreshButton.addEventListener("click", () => void refreshAll());
148
+ elements.insertButton.addEventListener("click", () => void saveRecord());
149
+ elements.cancelEditButton.addEventListener("click", resetEntryForm);
150
+ elements.titleInput.addEventListener("keydown", (event) => {
151
+ if (event.key === "Enter") {
152
+ event.preventDefault();
153
+ void saveRecord();
154
+ }
155
+ });
156
+ const stopContextUpdates = client.onContextChanged((context) => {
157
+ applyLanguage(resolveLanguage(context.language));
158
+ });
159
+ window.addEventListener("beforeunload", () => {
160
+ stopContextUpdates();
161
+ client.dispose();
162
+ });
163
+
164
+ applyLanguage(language);
165
+
166
+ void client.waitForContext().then((context) => {
167
+ applyLanguage(resolveLanguage(context.language));
168
+ return refreshAll();
169
+ }).catch((error: unknown) => {
170
+ writeStatusMessage("error.initializationFailed", { message: errorMessage(error) });
171
+ });
172
+
173
+ async function refreshAll(): Promise<void> {
174
+ setBusy(true, "status.loading");
175
+ try {
176
+ const [info, list] = await Promise.all([
177
+ invoke<DatabaseInfo>("sqlite.getInfo"),
178
+ invoke<{ records: SqliteRecord[] }>("sqlite.listRecords")
179
+ ]);
180
+ elements.databaseName.textContent = info.databaseName;
181
+ databasePath = info.databasePath;
182
+ renderDatabasePath();
183
+ elements.tableName.textContent = info.tableName;
184
+ elements.recordCount.textContent = String(info.recordCount);
185
+ records = list.records;
186
+ renderRecords();
187
+ writeStatusMessage(info.databaseReady ? "status.ready" : "status.unavailable");
188
+ } catch (error: unknown) {
189
+ writeStatusMessage("error.refreshFailed", { message: errorMessage(error) });
190
+ } finally {
191
+ setBusy(false, "status.idle");
192
+ }
193
+ }
194
+
195
+ async function saveRecord(): Promise<void> {
196
+ clearFormError();
197
+ const title = elements.titleInput.value.trim();
198
+ const note = elements.noteInput.value.trim();
199
+ if (!title) {
200
+ showFormErrorMessage("validation.titleRequired");
201
+ return;
202
+ }
203
+ setBusy(true, editingRecord ? "status.updating" : "status.inserting");
204
+ try {
205
+ if (editingRecord) {
206
+ await invoke<{ record: SqliteRecord }>("sqlite.updateRecord", { id: editingRecord.id, title, note });
207
+ writeStatusMessage("status.updatedRow", { id: editingRecord.id });
208
+ } else {
209
+ const inserted = await invoke<{ record: SqliteRecord }>("sqlite.insertRecord", { title, note });
210
+ writeStatusMessage("status.insertedRow", { id: inserted.record.id });
211
+ }
212
+ resetEntryForm();
213
+ await refreshAll();
214
+ } catch (error: unknown) {
215
+ showFormErrorLiteral(errorMessage(error));
216
+ } finally {
217
+ setBusy(false, "status.idle");
218
+ }
219
+ }
220
+
221
+ function startEdit(record: SqliteRecord): void {
222
+ editingRecord = record;
223
+ pendingDeleteId = undefined;
224
+ elements.titleInput.value = record.title;
225
+ elements.noteInput.value = record.note;
226
+ elements.cancelEditButton.hidden = false;
227
+ elements.titleInput.focus();
228
+ renderEntryForm();
229
+ renderRecords();
230
+ }
231
+
232
+ function requestDelete(record: SqliteRecord): void {
233
+ pendingDeleteId = record.id;
234
+ renderRecords();
235
+ }
236
+
237
+ async function deleteRecord(record: SqliteRecord): Promise<void> {
238
+ setBusy(true, "status.deleting");
239
+ try {
240
+ await invoke<{ deletedId: number }>("sqlite.deleteRecord", { id: record.id });
241
+ if (editingRecord?.id === record.id) {
242
+ resetEntryForm();
243
+ }
244
+ pendingDeleteId = undefined;
245
+ writeStatusMessage("status.deletedRow", { id: record.id });
246
+ await refreshAll();
247
+ } catch (error: unknown) {
248
+ writeStatusMessage("error.deleteFailed", { message: errorMessage(error) });
249
+ } finally {
250
+ setBusy(false, "status.idle");
251
+ }
252
+ }
253
+
254
+ function renderRecords(): void {
255
+ const rows = records.map((record) => {
256
+ const row = document.createElement("tr");
257
+ appendCell(row, String(record.id));
258
+ appendCell(row, record.title);
259
+ appendCell(row, record.note);
260
+ appendCell(row, new Date(record.createdAt).toLocaleString(language));
261
+ const actions = document.createElement("td");
262
+ actions.className = "record-actions";
263
+ if (pendingDeleteId === record.id) {
264
+ actions.append(
265
+ actionButton(text("action.confirm"), "button-danger", () => void deleteRecord(record)),
266
+ actionButton(text("action.cancel"), "button-secondary", () => {
267
+ pendingDeleteId = undefined;
268
+ renderRecords();
269
+ })
270
+ );
271
+ } else {
272
+ actions.append(
273
+ actionButton(text("action.edit"), "button-secondary", () => startEdit(record)),
274
+ actionButton(text("action.delete"), "button-secondary", () => requestDelete(record))
275
+ );
276
+ }
277
+ row.append(actions);
278
+ return row;
279
+ });
280
+ elements.recordsBody.replaceChildren(...rows);
281
+ elements.emptyState.hidden = rows.length > 0;
282
+ }
283
+
284
+ function appendCell(row: HTMLTableRowElement, value: string): void {
285
+ const cell = document.createElement("td");
286
+ cell.textContent = value;
287
+ row.append(cell);
288
+ }
289
+
290
+ function actionButton(label: string, className: string, action: () => void): HTMLButtonElement {
291
+ const button = document.createElement("button");
292
+ button.type = "button";
293
+ button.className = className;
294
+ button.textContent = label;
295
+ button.disabled = busy;
296
+ button.addEventListener("click", action);
297
+ return button;
298
+ }
299
+
300
+ function resetEntryForm(): void {
301
+ editingRecord = undefined;
302
+ elements.titleInput.value = "";
303
+ elements.noteInput.value = "";
304
+ elements.cancelEditButton.hidden = true;
305
+ clearFormError();
306
+ renderEntryForm();
307
+ }
308
+
309
+ function renderEntryForm(): void {
310
+ elements.entryFormTitle.textContent = text(editingRecord ? "form.editRow" : "form.insertRow");
311
+ elements.insertButton.textContent = text(editingRecord ? "action.save" : "action.insert");
312
+ }
313
+
314
+ function setBusy(nextBusy: boolean, labelKey: MessageKey): void {
315
+ busy = nextBusy;
316
+ busyLabelKey = labelKey;
317
+ renderBusyState();
318
+ }
319
+
320
+ function renderBusyState(): void {
321
+ elements.loadingState.textContent = text(busyLabelKey);
322
+ elements.refreshButton.disabled = busy;
323
+ elements.insertButton.disabled = busy;
324
+ elements.recordsBody.querySelectorAll<HTMLButtonElement>("button").forEach((button) => {
325
+ button.disabled = busy;
326
+ });
327
+ }
328
+
329
+ function showFormErrorMessage(key: MessageKey, params?: MessageParams): void {
330
+ formErrorState = { kind: "message", key, params };
331
+ renderFormError();
332
+ }
333
+
334
+ function showFormErrorLiteral(value: string): void {
335
+ formErrorState = { kind: "literal", value };
336
+ renderFormError();
337
+ }
338
+
339
+ function renderFormError(): void {
340
+ if (!formErrorState) {
341
+ elements.formError.textContent = "";
342
+ elements.formError.hidden = true;
343
+ return;
344
+ }
345
+ elements.formError.textContent = renderState(formErrorState);
346
+ elements.formError.hidden = false;
347
+ }
348
+
349
+ function clearFormError(): void {
350
+ formErrorState = undefined;
351
+ renderFormError();
352
+ }
353
+
354
+ function writeStatusMessage(key: MessageKey, params?: MessageParams): void {
355
+ statusState = { kind: "message", key, params };
356
+ renderStatus();
357
+ }
358
+
359
+ function renderStatus(): void {
360
+ elements.statusLog.textContent = renderState(statusState);
361
+ }
362
+
363
+ function renderDatabasePath(): void {
364
+ elements.databasePath.textContent = databasePath || text("status.connecting");
365
+ elements.databasePath.title = databasePath;
366
+ }
367
+
368
+ function applyLanguage(nextLanguage: SupportedLanguage): void {
369
+ language = nextLanguage;
370
+ document.documentElement.lang = language;
371
+ document.querySelectorAll<HTMLElement>("[data-i18n]").forEach((element) => {
372
+ const key = element.dataset.i18n;
373
+ if (key && isMessageKey(key)) {
374
+ element.textContent = text(key);
375
+ }
376
+ });
377
+ document.querySelectorAll<HTMLElement>("[data-i18n-aria-label]").forEach((element) => {
378
+ const key = element.dataset.i18nAriaLabel;
379
+ if (key && isMessageKey(key)) {
380
+ element.setAttribute("aria-label", text(key));
381
+ }
382
+ });
383
+ renderDatabasePath();
384
+ renderEntryForm();
385
+ renderRecords();
386
+ renderBusyState();
387
+ renderStatus();
388
+ renderFormError();
389
+ client.notifyResize();
390
+ }
391
+
392
+ function renderState(state: LocalizedState): string {
393
+ return state.kind === "message" ? text(state.key, state.params) : state.value;
394
+ }
395
+
396
+ function resolveLanguage(value: string): SupportedLanguage {
397
+ return value.toLowerCase().startsWith("zh") ? "zh-CN" : "en-US";
398
+ }
399
+
400
+ function text(key: MessageKey, params: MessageParams = {}): string {
401
+ return messages[language][key].replace(/\{([a-zA-Z0-9_]+)\}/g, (match, paramKey: string) => {
402
+ return Object.prototype.hasOwnProperty.call(params, paramKey) ? String(params[paramKey]) : match;
403
+ });
404
+ }
405
+
406
+ function isMessageKey(value: string): value is MessageKey {
407
+ return Object.prototype.hasOwnProperty.call(englishMessages, value);
408
+ }
409
+
410
+ async function invoke<TResult>(operation: string, input: Record<string, unknown> = {}): Promise<TResult> {
411
+ const result = await client.invokeMain<Record<string, unknown>, TResult>(operation, input);
412
+ if (!result.ok) {
413
+ throw new Error(result.error.code + ": " + result.error.message);
414
+ }
415
+ return result.value;
416
+ }
417
+
418
+ function requireElement<T extends HTMLElement>(id: string): T {
419
+ const element = document.getElementById(id);
420
+ if (!element) {
421
+ throw new Error("Required element is missing: " + id);
422
+ }
423
+ return element as T;
424
+ }
425
+
426
+ function errorMessage(error: unknown): string {
427
+ return error instanceof Error ? error.message : String(error);
428
+ }
@@ -0,0 +1,57 @@
1
+ <!doctype html>
2
+ <html lang="en-US">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title data-i18n="document.title">Plugin SQLite</title>
7
+ <link rel="stylesheet" href="assets/plugin.css">
8
+ </head>
9
+ <body>
10
+ <main class="plugin-shell">
11
+ <header class="plugin-header">
12
+ <div>
13
+ <h1 data-i18n="heading.records">SQLite Records</h1>
14
+ <p id="databasePath" data-i18n="status.connecting">Connecting</p>
15
+ </div>
16
+ <button id="refreshButton" class="button-secondary" type="button" data-i18n="action.refresh">Refresh</button>
17
+ </header>
18
+
19
+ <section class="summary" aria-label="Database summary" data-i18n-aria-label="summary.aria">
20
+ <div><span data-i18n="summary.database">Database</span><strong id="databaseName">plugin.db</strong></div>
21
+ <div><span data-i18n="summary.table">Table</span><strong id="tableName">plugin_records</strong></div>
22
+ <div><span data-i18n="summary.rows">Rows</span><strong id="recordCount">0</strong></div>
23
+ <div><span data-i18n="summary.status">Status</span><strong id="loadingState" data-i18n="status.waiting">Waiting</strong></div>
24
+ </section>
25
+
26
+ <section class="panel">
27
+ <h2 id="entryFormTitle" data-i18n="form.insertRow">Insert Row</h2>
28
+ <div class="entry-form">
29
+ <label><span data-i18n="field.title">Title</span><input id="titleInput" type="text" maxlength="80" autocomplete="off"></label>
30
+ <label><span data-i18n="field.note">Note</span><textarea id="noteInput" maxlength="500" rows="2"></textarea></label>
31
+ <div class="entry-actions">
32
+ <button id="insertButton" type="button" data-i18n="action.insert">Insert</button>
33
+ <button id="cancelEditButton" class="button-secondary" type="button" data-i18n="action.cancel" hidden>Cancel</button>
34
+ </div>
35
+ </div>
36
+ <p id="formError" class="error" hidden></p>
37
+ </section>
38
+
39
+ <section class="panel">
40
+ <div class="panel-heading">
41
+ <h2 data-i18n="section.recentRows">Recent Rows</h2>
42
+ <span id="statusLog" data-i18n="status.idle">Idle</span>
43
+ </div>
44
+ <div id="emptyState" class="empty-state" data-i18n="empty.noRows" hidden>No rows yet.</div>
45
+ <div class="table-wrap">
46
+ <table>
47
+ <thead>
48
+ <tr><th data-i18n="table.id">ID</th><th data-i18n="table.title">Title</th><th data-i18n="table.note">Note</th><th data-i18n="table.created">Created</th><th class="actions-column" data-i18n="table.actions">Actions</th></tr>
49
+ </thead>
50
+ <tbody id="recordsBody"></tbody>
51
+ </table>
52
+ </div>
53
+ </section>
54
+ </main>
55
+ <script type="module" src="assets/app.js"></script>
56
+ </body>
57
+ </html>