diff --git a/apps/backend/openapi.yaml b/apps/backend/openapi.yaml index 776b2efe..7eedfc88 100644 --- a/apps/backend/openapi.yaml +++ b/apps/backend/openapi.yaml @@ -17,7 +17,7 @@ tags: - name: news - name: notifications - name: pageConfig - - name: search + - name: shortcuts - name: sessions - name: test - name: wallpapers @@ -392,11 +392,11 @@ paths: responses: "200": $ref: "#/components/responses/JsonOk" - /jobs/searchItems: + /jobs/shortcuts: get: tags: - jobs - summary: Search items job + summary: Shortcuts indexing job responses: "200": $ref: "#/components/responses/JsonOk" @@ -824,11 +824,11 @@ paths: responses: "200": $ref: "#/components/responses/JsonOk" - /searchItems: + /shortcuts: get: tags: - - search - summary: Search items + - shortcuts + summary: List shortcuts responses: "200": $ref: "#/components/responses/JsonOk" @@ -1370,19 +1370,57 @@ paths: responses: "200": $ref: "#/components/responses/JsonOk" - /searchItems/frequentlyUsed: + /shortcuts/apps: + post: + tags: + - shortcuts + summary: Create an on-demand shortcut app + requestBody: + $ref: "#/components/requestBodies/JsonBody" + responses: + "200": + $ref: "#/components/responses/JsonOk" + "400": + $ref: "#/components/responses/JsonBadRequest" + "401": + $ref: "#/components/responses/JsonUnauthorized" + /shortcuts/on-demand/{appId}: + put: + tags: + - shortcuts + summary: Replace an on-demand app's shortcuts + parameters: + - name: appId + in: path + required: true + schema: + type: string + requestBody: + $ref: "#/components/requestBodies/JsonBody" + responses: + "200": + $ref: "#/components/responses/JsonOk" + "400": + $ref: "#/components/responses/JsonBadRequest" + "401": + $ref: "#/components/responses/JsonUnauthorized" + "404": + $ref: "#/components/responses/JsonNotFound" + "409": + $ref: "#/components/responses/JsonConflict" + /shortcuts/frequentlyUsed: get: tags: - - search - summary: List frequently used search items + - shortcuts + summary: List frequently used shortcuts responses: "200": $ref: "#/components/responses/JsonOk" - /searchItems/usageStats: + /shortcuts/usageStats: post: tags: - - search - summary: Log search item usage + - shortcuts + summary: Log shortcut usage requestBody: $ref: "#/components/requestBodies/JsonBody" responses: @@ -1429,6 +1467,18 @@ components: application/json: schema: $ref: "#/components/schemas/Error" + JsonNotFound: + description: Not Found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + JsonConflict: + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/Error" securitySchemes: bearerAuth: type: http diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 12941bdf..8d0b095f 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -6,7 +6,11 @@ import { cors } from "hono/cors"; import { Client as SshClient } from "ssh2"; import { config } from "./lib/config"; -import { subscribeActivity } from "./lib/activity"; +import { + handleActivityMessage, + registerSessionConnection, + subscribeActivity, +} from "./lib/activity"; import { ensureSession } from "./lib/data/sessions"; import { jobsApi, registerJobsCron } from "./jobs/index"; import { startPocketbase } from "./pocketbase"; @@ -92,6 +96,9 @@ app.get("/health", (c) => c.json({ status: "ok" })); app.get("/api/v1/activity", upgradeWebSocket((c) => { let refreshTimer: ReturnType | undefined; let unsubscribeActivity: (() => void) | undefined; + let unregisterSessionConnection: (() => void) | undefined; + let connectedUserId = ""; + let connectedSessionId = ""; return { async onOpen(_event, ws) { @@ -99,21 +106,41 @@ app.get("/api/v1/activity", upgradeWebSocket((c) => { const sessionId = c.req.query("sessionId") || c.req.header("x-session-id") || null; try { const { userId, pb } = await requireAuth({ token, sessionId }); - await ensureSession(pb, userId, sessionId, readSessionMetadata(c)); + const session = await ensureSession(pb, userId, sessionId, readSessionMetadata(c)); + if (!session) throw new Error("A valid session id is required"); + connectedUserId = userId; + connectedSessionId = session.sessionId; + unregisterSessionConnection = registerSessionConnection(userId, session.sessionId, ws); + let calendarEvents: Array> = []; + let calendarRefreshedAt = 0; + let calendarRefresh: Promise | null = null; + const refreshCalendarEvents = async () => { + if (Date.now() - calendarRefreshedAt < 5 * 60 * 1000) return; + if (calendarRefresh) return calendarRefresh; + + calendarRefresh = (async () => { + const integrationResult = await listIntegrations(userId); + calendarEvents = (await Promise.all( + integrationResult.integrations + .filter((integration) => integration.type === "caldav") + .map((integration) => getUpcomingEvents( + integration.environment, + integration.localData, + (localData) => pb.collection("integrations").update(integration.id, { localData }).then(() => undefined), + ).then((events) => events.map((event) => ({ ...event, id: `${integration.id}:${event.id}` }))).catch(() => [])), + )).flat().filter((event) => new Date(event.start).getTime() >= new Date().setHours(0, 0, 0, 0)); + calendarRefreshedAt = Date.now(); + })().finally(() => { + calendarRefresh = null; + }); + + return calendarRefresh; + }; const sendSnapshot = async () => { - const [notificationResult, integrationResult] = await Promise.all([ + const [notificationResult] = await Promise.all([ getNotifications(userId), - listIntegrations(userId), + refreshCalendarEvents(), ]); - const calendarEvents = (await Promise.all( - integrationResult.integrations - .filter((integration) => integration.type === "caldav") - .map((integration) => getUpcomingEvents( - integration.environment, - integration.localData, - (localData) => pb.collection("integrations").update(integration.id, { localData }).then(() => undefined), - ).then((events) => events.map((event) => ({ ...event, id: `${integration.id}:${event.id}` }))).catch(() => [])), - )).flat().filter((event) => new Date(event.start).getTime() >= new Date().setHours(0, 0, 0, 0)); ws.send(JSON.stringify({ type: "activity:snapshot", notifications: notificationResult.items, calendarEvents })); }; @@ -128,6 +155,12 @@ app.get("/api/v1/activity", upgradeWebSocket((c) => { onMessage(event, ws) { try { const message = JSON.parse(String(event.data)); + if (connectedUserId && connectedSessionId && handleActivityMessage( + connectedUserId, + connectedSessionId, + ws, + message, + )) return; if (message.type === "activity:subscribe" || message.type === "activity:refresh") { void (ws as typeof ws & { data?: { sendSnapshot: () => Promise } }).data?.sendSnapshot(); } @@ -138,6 +171,7 @@ app.get("/api/v1/activity", upgradeWebSocket((c) => { onClose() { if (refreshTimer) clearInterval(refreshTimer); unsubscribeActivity?.(); + unregisterSessionConnection?.(); }, }; })); diff --git a/apps/backend/src/jobs/index.ts b/apps/backend/src/jobs/index.ts index 99e7fa97..6b10d758 100644 --- a/apps/backend/src/jobs/index.ts +++ b/apps/backend/src/jobs/index.ts @@ -5,7 +5,7 @@ import { promisify } from "node:util"; import { runJob } from "./job-logger"; import { config } from "../lib/config"; import { _d } from "../lib/sdk"; -import { runSearchItemsIndexing } from "./search-indexer"; +import { runShortcutsIndexing } from "./shortcuts-indexer"; import indexStatusMonitoringJobs from "./monitoring/indexer"; import { runStatusMonitoringJobs, @@ -23,8 +23,8 @@ import { getSuperuserPB } from "../lib/pb/pocketbase"; const execFileAsync = promisify(execFile); const logger = createLogger("Jobs"); -async function runSearchIndexerScript() { - await runSearchItemsIndexing(); +async function runShortcutsIndexerScript() { + await runShortcutsIndexing(); } async function runPullIconsScript() { @@ -34,11 +34,11 @@ async function runPullIconsScript() { }); } -const runSearchItemsJob = (source: string) => - runJob("searchItemsIndexer", runSearchIndexerScript, { +const runShortcutsJob = (source: string) => + runJob("shortcutsIndexer", runShortcutsIndexerScript, { startMessage: `Triggered by ${source}`, - successMessage: "Search items indexing completed", - errorMessage: "Search items indexing failed", + successMessage: "Shortcuts indexing completed", + errorMessage: "Shortcuts indexing failed", }); const runPullIconsJob = (source: string) => @@ -157,9 +157,9 @@ export function registerJobsCron() { logger.debug("Dashwise SDK app config", _d.getAppConfig()); void runLegacyUserConfigsMigrationJob("server start"); - void runSearchItemsJob("server start"); - Bun.cron(config.SEARCHITEMS_SCHEDULE, async () => { - await runSearchItemsJob("cron schedule"); + void runShortcutsJob("server start"); + Bun.cron(config.SHORTCUTS_SCHEDULE, async () => { + await runShortcutsJob("cron schedule"); }); if (config.ENABLE_ICONS_REFRESH) { @@ -199,7 +199,7 @@ export function registerJobsCron() { } export const jobsApi = { - runSearchItemsJob, + runShortcutsJob, runPullIconsJob, runMonitoringIndexerJob, runMonitoringRunnerJob, diff --git a/apps/backend/src/jobs/search-indexer.ts b/apps/backend/src/jobs/shortcuts-indexer.ts similarity index 79% rename from apps/backend/src/jobs/search-indexer.ts rename to apps/backend/src/jobs/shortcuts-indexer.ts index 9ee0e95f..8132041d 100644 --- a/apps/backend/src/jobs/search-indexer.ts +++ b/apps/backend/src/jobs/shortcuts-indexer.ts @@ -3,8 +3,9 @@ import type { HomeLink } from "@dashwise/types"; import { getHomeLinks } from "../lib/data/links"; import { config } from "../lib/config"; import { getSuperuserPB } from "../lib/pb/pocketbase"; +import { ensureShortcutsApp, escapeFilter, parseTags } from "../lib/data/shortcuts"; -type SearchItemRow = { +type ShortcutRow = { name: string; icon: string; secondary: string; @@ -32,8 +33,8 @@ type ShortcutDefaultsRow = { tags?: unknown; }; -export async function runSearchItemsIndexing() { - console.log("Starting search items indexing job..."); +export async function runShortcutsIndexing() { + console.log("Starting shortcuts indexing job..."); const pb = await getSuperuserPB(); const users = await pb.collection("users").getFullList<{ id: string }>(500, { fields: "id", @@ -43,7 +44,7 @@ export async function runSearchItemsIndexing() { const userId = user.id; if (!userId) continue; - const rows: SearchItemRow[] = buildDefaultShortcutSearchRows(); + const rows: ShortcutRow[] = buildDefaultShortcutRows(); const links = await getHomeLinks(userId).catch(() => [] as HomeLink[]); for (const link of links) { const name = String(link?.title ?? "").trim(); @@ -124,7 +125,7 @@ export async function runSearchItemsIndexing() { continue; } try { - const integrationRows = await buildIntegrationSearchRows(integration); + const integrationRows = await buildIntegrationShortcutRows(pb, userId, integration); rows.push(...integrationRows); } catch { // If one integration fails to resolve endpoints/search mappings, @@ -133,16 +134,16 @@ export async function runSearchItemsIndexing() { } } - await rebuildUserSearchItems(pb, userId, rows); + await rebuildUserShortcuts(pb, userId, rows); } } -function buildDefaultShortcutSearchRows(): SearchItemRow[] { +function buildDefaultShortcutRows(): ShortcutRow[] { const shortcuts = Array.isArray(defaultShortcutsManifest) ? (defaultShortcutsManifest as ShortcutDefaultsRow[]) : []; - const rows: SearchItemRow[] = []; + const rows: ShortcutRow[] = []; for (const shortcut of shortcuts) { const name = String(shortcut?.name ?? "").trim(); const action = String(shortcut?.action ?? "").trim(); @@ -165,10 +166,6 @@ function buildDefaultShortcutSearchRows(): SearchItemRow[] { return rows; } -function escapeFilter(value: string) { - return value.replace(/"/g, '\\"'); -} - function normalizeObject(raw: unknown): Record { if (!raw) return {}; if (typeof raw === "object" && !Array.isArray(raw)) return raw as Record; @@ -197,18 +194,6 @@ function normalizeObject(raw: unknown): Record { return {}; } -function parseTags(value: unknown) { - if (!value) return [] as unknown[]; - if (Array.isArray(value)) return value; - if (typeof value !== "string") return [] as unknown[]; - try { - const parsed = JSON.parse(value); - return Array.isArray(parsed) ? parsed : []; - } catch { - return [] as unknown[]; - } -} - function normalizeKey(value: string) { return String(value || "") .trim() @@ -287,9 +272,11 @@ function isIntegrationEnabled( return candidates.some((candidate) => enabledMap[normalizeKey(candidate)] === true); } -async function buildIntegrationSearchRows( +async function buildIntegrationShortcutRows( + pb: any, + userId: string, integration: SearchIndexIntegrationRecord, -): Promise { +): Promise { const integrationConfig = normalizeObject(integration.config); const searchDefinitions = Array.isArray(integrationConfig?.configuration?.shortcuts) ? (integrationConfig.configuration.shortcuts as Array>) @@ -317,12 +304,16 @@ async function buildIntegrationSearchRows( })); const appId = `integration:${integration.id}`; + const shortcutApp = await ensureShortcutsApp(pb, userId, appId, { + name: integrationName, + icon: integrationIcon, + }); const rows = shortcutRows.map((item) => ({ name: item.name, icon: item.icon || integrationIcon, secondary: item.secondaryInfo || integrationName, action: serializeShortcutAction(item.action), - app: appId, + app: shortcutApp.id, tags: item.tags, sourceId: integration.id, sourceUpdated: (integration as any).updated as string, @@ -333,7 +324,7 @@ async function buildIntegrationSearchRows( name: integrationName, icon: integrationIcon, secondary: "Integration", - action: `app:${appId}`, + action: `app:${shortcutApp.id}`, app: "", tags: [integrationName, "integration"], sourceId: integration.id, @@ -343,33 +334,45 @@ async function buildIntegrationSearchRows( ]; } -async function rebuildUserSearchItems(pb: any, userId: string, rows: SearchItemRow[]) { - const existing = await pb.collection("searchItems").getFullList(1000, { +async function rebuildUserShortcuts(pb: any, userId: string, rows: ShortcutRow[]) { + const onDemandApps = await pb.collection("shortcutsApps").getFullList(1000, { + filter: `user="${escapeFilter(userId)}" && type="on-demand"`, + fields: "id", + }).catch(() => [] as Array<{ id: string }>); + const onDemandAppIds = new Set(onDemandApps.map((record: { id: string }) => record.id)); + const existing = await pb.collection("shortcuts").getFullList(1000, { filter: `user="${escapeFilter(userId)}"`, }); const existingBySource = new Map(); for (const record of existing) { - const sid = record.sourceId || "legacy"; + const sourceId = String(record.sourceId ?? ""); + const isOnDemandShortcut = typeof record.app === "string" && onDemandAppIds.has(record.app); + const isOnDemandAppShortcut = !record.app && sourceId.startsWith("shortcuts-app:") && + onDemandAppIds.has(sourceId.slice("shortcuts-app:".length)); + if (isOnDemandShortcut || isOnDemandAppShortcut) { + continue; + } + const sid = sourceId || "legacy"; if (!existingBySource.has(sid)) existingBySource.set(sid, []); existingBySource.get(sid)!.push(record); } - const newBySource = new Map(); + const newBySource = new Map(); for (const row of rows) { const sid = row.sourceId || "unknown"; if (!newBySource.has(sid)) newBySource.set(sid, []); newBySource.get(sid)!.push(row); } - // 1. Clean up search items whose sources no longer exist + // 1. Clean up just-in-time shortcuts whose sources no longer exist. for (const [sid, records] of existingBySource.entries()) { if (sid === "legacy") { - for (const r of records) await pb.collection("searchItems").delete(r.id).catch(() => {}); + for (const r of records) await pb.collection("shortcuts").delete(r.id).catch(() => {}); continue; } if (!newBySource.has(sid)) { - for (const r of records) await pb.collection("searchItems").delete(r.id).catch(() => {}); + for (const r of records) await pb.collection("shortcuts").delete(r.id).catch(() => {}); } } @@ -385,7 +388,7 @@ async function rebuildUserSearchItems(pb: any, userId: string, rows: SearchItemR const existingRecord = existingRecords[0]; if (existingRecord) { - // "check if the parent link has been updated since the search item has lastly been updated. if yes replace, else discard" + // Keep the existing shortcut when its source has not changed. const sourceUpdated = new Date(newRow.sourceUpdated || 0).getTime(); const itemUpdated = new Date(existingRecord.updated).getTime(); @@ -395,10 +398,10 @@ async function rebuildUserSearchItems(pb: any, userId: string, rows: SearchItemR } // Replace - await pb.collection("searchItems").delete(existingRecord.id).catch(() => {}); + await pb.collection("shortcuts").delete(existingRecord.id).catch(() => {}); } - await pb.collection("searchItems").create({ + await pb.collection("shortcuts").create({ user: userId, name: newRow.name, icon: newRow.icon, @@ -411,14 +414,25 @@ async function rebuildUserSearchItems(pb: any, userId: string, rows: SearchItemR }); } else { // Integration logic: "regenerate every time and check whether the output differs" - const existingData = existingRecords.map(r => ({ - name: r.name, - icon: r.icon, - secondary: r.secondary, - action: r.action, - app: r.app, - tags: parseTags(r.tags), - })).sort((a, b) => a.action.localeCompare(b.action)); + const appRelationId = newRows + .map((row) => row.app || (row.action.startsWith("app:") ? row.action.slice(4) : "")) + .find(Boolean) || ""; + const migratedParentRecords: Array<{ id: string; action: string }> = []; + const existingData = existingRecords.map(r => { + let action = r.action; + if (!r.app && appRelationId && typeof action === "string" && action.startsWith("app:integration:")) { + action = `app:${appRelationId}`; + migratedParentRecords.push({ id: r.id, action }); + } + return { + name: r.name, + icon: r.icon, + secondary: r.secondary, + action, + app: r.app, + tags: parseTags(r.tags), + }; + }).sort((a, b) => a.action.localeCompare(b.action)); const newData = newRows.map(r => ({ name: r.name, @@ -430,13 +444,16 @@ async function rebuildUserSearchItems(pb: any, userId: string, rows: SearchItemR })).sort((a, b) => a.action.localeCompare(b.action)); if (JSON.stringify(existingData) === JSON.stringify(newData)) { + for (const record of migratedParentRecords) { + await pb.collection("shortcuts").update(record.id, { action: record.action }); + } continue; } // Replace all for this source - for (const r of existingRecords) await pb.collection("searchItems").delete(r.id).catch(() => {}); + for (const r of existingRecords) await pb.collection("shortcuts").delete(r.id).catch(() => {}); for (const row of newRows) { - await pb.collection("searchItems").create({ + await pb.collection("shortcuts").create({ user: userId, name: row.name, icon: row.icon, diff --git a/apps/backend/src/lib/activity.test.ts b/apps/backend/src/lib/activity.test.ts new file mode 100644 index 00000000..f982ad26 --- /dev/null +++ b/apps/backend/src/lib/activity.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; + +import { + handleActivityMessage, + isSessionConnected, + registerSessionConnection, + requestShortcutExecution, +} from "./activity"; + +type FakeConnection = { + sent: string[]; + send: (payload: string) => void; +}; + +function fakeConnection(): FakeConnection { + const connection: FakeConnection = { + sent: [], + send(payload) { + this.sent.push(payload); + }, + }; + return connection; +} + +describe("activity shortcut routing", () => { + test("routes a request and resolves the matching result", async () => { + const connection = fakeConnection(); + const unregister = registerSessionConnection("user-a", "session-a", connection); + + const request = requestShortcutExecution("user-a", "session-a", "open-terminal"); + expect(isSessionConnected("user-a", "session-a")).toBe(true); + expect(connection.sent).toHaveLength(1); + expect(JSON.parse(connection.sent[0])).toMatchObject({ + type: "shortcut:execute", + shortcutId: "open-terminal", + }); + + const requestId = JSON.parse(connection.sent[0]).requestId; + expect(handleActivityMessage("user-a", "session-a", connection, { + type: "shortcut:result", + requestId, + success: true, + })).toBe(true); + await expect(request).resolves.toMatchObject({ success: true, requestId }); + + unregister(); + expect(isSessionConnected("user-a", "session-a")).toBe(false); + }); + + test("rejects a result from another user or unregistered connection", async () => { + const connection = fakeConnection(); + const otherConnection = fakeConnection(); + const unregister = registerSessionConnection("user-b", "session-b", connection); + const unregisterOther = registerSessionConnection("user-a", "session-b", otherConnection); + + const request = requestShortcutExecution("user-b", "session-b", "open-terminal"); + const requestId = JSON.parse(connection.sent[0]).requestId; + expect(handleActivityMessage("user-a", "session-b", otherConnection, { + type: "shortcut:result", + requestId, + success: true, + })).toBe(false); + expect(handleActivityMessage("user-b", "session-b", otherConnection, { + type: "shortcut:result", + requestId, + success: true, + })).toBe(false); + expect(handleActivityMessage("user-b", "session-b", connection, { + type: "shortcut:result", + requestId, + success: false, + error: "Not exposed", + })).toBe(true); + await expect(request).resolves.toMatchObject({ + success: false, + error: "Not exposed", + }); + + unregister(); + unregisterOther(); + }); + + test("fails immediately when the target session has no connection", async () => { + await expect(requestShortcutExecution("user-a", "offline", "shortcut-id")).resolves.toMatchObject({ + success: false, + error: "Target session is offline", + }); + }); + + test("fails pending requests when the last connection closes", async () => { + const connection = fakeConnection(); + const unregister = registerSessionConnection("user-a", "session-c", connection); + const request = requestShortcutExecution("user-a", "session-c", "shortcut-id"); + + unregister(); + + await expect(request).resolves.toMatchObject({ + success: false, + error: "Target session disconnected before the shortcut completed", + }); + expect(isSessionConnected("user-a", "session-c")).toBe(false); + }); +}); diff --git a/apps/backend/src/lib/activity.ts b/apps/backend/src/lib/activity.ts index 7905b5a2..92e6f95d 100644 --- a/apps/backend/src/lib/activity.ts +++ b/apps/backend/src/lib/activity.ts @@ -1,6 +1,25 @@ type ActivitySubscriber = () => Promise; +export type ActivityConnection = { + send: (data: string) => void; +}; + +export type ShortcutExecutionResult = { + success: boolean; + requestId: string; + error?: string; +}; + const subscribers = new Map>(); +const sessionConnections = new Map>>(); +const pendingShortcutRequests = new Map void; + timer: ReturnType; +}>(); + +const SHORTCUT_RESULT_TIMEOUT_MS = 10_000; export function subscribeActivity(userId: string, subscriber: ActivitySubscriber) { const userSubscribers = subscribers.get(userId) ?? new Set(); @@ -18,3 +37,134 @@ export function broadcastActivity(userId: string) { void subscriber().catch(() => undefined); } } + +export function registerSessionConnection( + userId: string, + sessionId: string, + connection: ActivityConnection, +) { + const userSessions = sessionConnections.get(userId) ?? new Map(); + const connections = userSessions.get(sessionId) ?? new Set(); + connections.add(connection); + userSessions.set(sessionId, connections); + sessionConnections.set(userId, userSessions); + + return () => unregisterSessionConnection(userId, sessionId, connection); +} + +export function unregisterSessionConnection( + userId: string, + sessionId: string, + connection: ActivityConnection, +) { + const userSessions = sessionConnections.get(userId); + const connections = userSessions?.get(sessionId); + if (!connections) return; + + connections.delete(connection); + if (connections.size > 0) return; + + userSessions?.delete(sessionId); + if (userSessions && userSessions.size === 0) sessionConnections.delete(userId); + + for (const [requestId, request] of pendingShortcutRequests) { + if (request.userId !== userId || request.sessionId !== sessionId) continue; + finishShortcutRequest(requestId, { + success: false, + requestId, + error: "Target session disconnected before the shortcut completed", + }); + } +} + +export function isSessionConnected(userId: string, sessionId: string) { + return (sessionConnections.get(userId)?.get(sessionId)?.size ?? 0) > 0; +} + +export function sendToSession( + userId: string, + sessionId: string, + message: Record, +) { + const connections = sessionConnections.get(userId)?.get(sessionId); + if (!connections?.size) return false; + + const payload = JSON.stringify(message); + let sent = false; + for (const connection of connections) { + try { + connection.send(payload); + sent = true; + } catch { + unregisterSessionConnection(userId, sessionId, connection); + } + } + return sent; +} + +export function handleActivityMessage( + userId: string, + sessionId: string, + connection: ActivityConnection, + message: unknown, +) { + if (!message || typeof message !== "object") return false; + const payload = message as Record; + if (payload.type !== "shortcut:result" || typeof payload.requestId !== "string") return false; + + const request = pendingShortcutRequests.get(payload.requestId); + if (!request || request.userId !== userId || request.sessionId !== sessionId) return false; + if (!sessionConnections.get(userId)?.get(sessionId)?.has(connection)) return false; + + const error = typeof payload.error === "string" ? payload.error.trim().slice(0, 500) : ""; + finishShortcutRequest(payload.requestId, { + success: payload.success === true, + requestId: payload.requestId, + ...(payload.success === true ? {} : { error: error || "The client failed to execute the shortcut" }), + }); + return true; +} + +export function requestShortcutExecution( + userId: string, + sessionId: string, + shortcutId: string, +): Promise { + const requestId = crypto.randomUUID(); + return new Promise((resolve) => { + const timer = setTimeout(() => { + finishShortcutRequest(requestId, { + success: false, + requestId, + error: "Timed out waiting for the target session to execute the shortcut", + }); + }, SHORTCUT_RESULT_TIMEOUT_MS); + + pendingShortcutRequests.set(requestId, { + userId, + sessionId, + resolve, + timer, + }); + + if (!sendToSession(userId, sessionId, { + type: "shortcut:execute", + requestId, + shortcutId, + })) { + finishShortcutRequest(requestId, { + success: false, + requestId, + error: "Target session is offline", + }); + } + }); +} + +function finishShortcutRequest(requestId: string, result: ShortcutExecutionResult) { + const request = pendingShortcutRequests.get(requestId); + if (!request) return; + pendingShortcutRequests.delete(requestId); + clearTimeout(request.timer); + request.resolve(result); +} diff --git a/apps/backend/src/lib/config.ts b/apps/backend/src/lib/config.ts index 5043a05e..8dd637e6 100644 --- a/apps/backend/src/lib/config.ts +++ b/apps/backend/src/lib/config.ts @@ -54,7 +54,7 @@ export const config = { processStartPocketBase == null ? true : !(processStartPocketBase === "false" || processStartPocketBase === "0"), - SEARCHITEMS_SCHEDULE: env.SEARCHITEMS_SCHEDULE || "*/10 * * * *", + SHORTCUTS_SCHEDULE: env.SHORTCUTS_SCHEDULE || "*/10 * * * *", ENABLE_ICONS_REFRESH: env.ENABLE_ICONS_REFRESH === "true", PULL_ICONS_SCHEDULE: env.PULL_ICONS_SCHEDULE || "0 */18 * * *", MONITORING_INDEXER_SCHEDULE: env.MONITORING_INDEXER_SCHEDULE || "*/10 * * * *", diff --git a/apps/backend/src/lib/data/searchItems.ts b/apps/backend/src/lib/data/searchItems.ts deleted file mode 100644 index 7ec2366c..00000000 --- a/apps/backend/src/lib/data/searchItems.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { getSuperuserPB } from "../pb/pocketbase"; -import type { SearchItemsResponse } from "@dashwise/types"; - -function parseTags(value: unknown): string[] { - if (Array.isArray(value)) { - return value - .map((entry) => String(entry ?? "").trim()) - .filter((entry): entry is string => entry.length > 0); - } - - if (typeof value === "string") { - const trimmed = value.trim(); - if (!trimmed) return []; - try { - const parsed = JSON.parse(trimmed); - if (Array.isArray(parsed)) { - return parsed - .map((entry) => String(entry ?? "").trim()) - .filter((entry): entry is string => entry.length > 0); - } - } catch { - return [trimmed]; - } - } - - return []; -} - -export async function getSearchItems(userId: string) { - const pb = await getSuperuserPB(); - const records = (await pb.collection("searchItems").getFullList(1000, { - filter: `user=\"${userId.replace(/"/g, '\\"')}\"`, - sort: "name", - })) as Array; - - return records.map((record) => { - const action = parseAction(record.action); - const actionString = typeof action === "string" ? action : ""; - return { - id: record.id, - parentId: - typeof record.app === "string" && record.app.trim().length > 0 - ? record.app.trim() - : undefined, - name: String(record.name ?? ""), - icon: String(record.icon ?? ""), - secondaryInfo: String(record.secondary ?? ""), - type: actionString.startsWith("app:") ? "app" : "link", - action, - tags: parseTags(record.tags), - isPinned: Boolean(record.isPinned), - usageStats: record.usageStats, - }; - }); -} - -type SearchItemAction = string | { - type: string; - url?: string; - proxy?: boolean; -}; - -function parseAction(raw: unknown): SearchItemAction { - if (typeof raw !== "string") return ""; - const trimmed = raw.trim(); - if (!trimmed) return ""; - - if (trimmed.toLowerCase().startsWith("post:")) { - const url = trimmed.slice(5).trim(); - return { type: "post", url, proxy: true }; - } - - if (trimmed.startsWith("{") || trimmed.startsWith("[")) { - try { - const parsed = JSON.parse(trimmed); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - const type = String((parsed as any).type ?? "").trim().toLowerCase(); - if (type) { - const url = typeof (parsed as any).url === "string" ? (parsed as any).url : undefined; - if (type === "post") { - return { type: "post", url, proxy: true }; - } - return { type, url }; - } - } - } catch { - } - } - - return trimmed; -} diff --git a/apps/backend/src/lib/data/sessions.ts b/apps/backend/src/lib/data/sessions.ts index 04e677e7..07492549 100644 --- a/apps/backend/src/lib/data/sessions.ts +++ b/apps/backend/src/lib/data/sessions.ts @@ -27,6 +27,23 @@ export function normalizeSessionId(value: unknown) { return SESSION_ID_PATTERN.test(sessionId) ? sessionId : null; } +export async function getSessionById( + pb: { collection: (name: "sessions") => any }, + userId: string, + rawSessionId: unknown, +) { + const sessionId = normalizeSessionId(rawSessionId); + if (!sessionId) return null; + + try { + return toSessionRecord(await pb.collection("sessions").getFirstListItem( + `user = "${escapeFilter(userId)}" && sessionId = "${escapeFilter(sessionId)}"`, + )); + } catch { + return null; + } +} + function normalizeMetadata(metadata?: SessionMetadata) { return { ...(metadata?.clientType?.trim() ? { clientType: metadata.clientType.trim().slice(0, 100) } : {}), @@ -49,7 +66,7 @@ export async function ensureSession( const now = new Date().toISOString(); const collection = pb.collection("sessions"); - const filter = `user = "${userId}" && sessionId = "${sessionId}"`; + const filter = `user = "${escapeFilter(userId)}" && sessionId = "${escapeFilter(sessionId)}"`; const normalizedMetadata = normalizeMetadata(metadata); let session: RecordModel | null = null; @@ -88,6 +105,10 @@ export async function ensureSession( } } +function escapeFilter(value: string) { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + export async function getCurrentSession( pb: { collection: (name: "sessions") => any }, userId: string, diff --git a/apps/backend/src/lib/data/shortcuts.ts b/apps/backend/src/lib/data/shortcuts.ts new file mode 100644 index 00000000..fcf67616 --- /dev/null +++ b/apps/backend/src/lib/data/shortcuts.ts @@ -0,0 +1,409 @@ +import { randomUUID } from "node:crypto"; + +import { ApiActionError } from "./auth"; +import { + isSessionConnected, + requestShortcutExecution, + type ShortcutExecutionResult, +} from "../activity"; +import { getSessionById } from "./sessions"; +import { getSuperuserPB } from "../pb/pocketbase"; +import type { ShortcutsResponse } from "@dashwise/types"; + +export type ShortcutAppType = "just-in-time" | "on-demand"; + +export type ShortcutAppDetails = { + name: string; + icon?: string; +}; + +export type OnDemandShortcutInput = { + sourceId: string; + name: string; + icon?: string; + secondary?: string; + action: string; + tags?: string[]; +}; + +export type RoutedShortcutAction = { + sessionId: string; + shortcutId: string; +}; + +export function escapeFilter(value: string) { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +export function parseTags(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .map((entry) => String(entry ?? "").trim()) + .filter((entry): entry is string => entry.length > 0); + } + + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return []; + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed + .map((entry) => String(entry ?? "").trim()) + .filter((entry): entry is string => entry.length > 0); + } + } catch { + return [trimmed]; + } + } + + return []; +} + +export function parseRoutedShortcutAction(value: unknown): RoutedShortcutAction | null { + if (typeof value !== "string") return null; + const match = /^shortcut:([^\.]+)\.(.+)$/i.exec(value.trim()); + if (!match) return null; + + const sessionId = match[1].trim(); + const shortcutId = match[2].trim(); + if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(sessionId)) return null; + if (!shortcutId || shortcutId.length > 512 || hasControlCharacter(shortcutId)) return null; + return { sessionId, shortcutId }; +} + +function hasControlCharacter(value: string) { + return [...value].some((character) => { + const code = character.charCodeAt(0); + return code < 32 || code === 127; + }); +} + +export async function executeRoutedShortcut(userId: string, action: unknown) { + const target = parseRoutedShortcutAction(action); + if (!target) { + throw new ApiActionError("Invalid shortcut action", 400, { + error: "Invalid shortcut action", + }); + } + + const pb = await getSuperuserPB(); + const session = await getSessionById(pb, userId, target.sessionId); + if (!session) { + throw new ApiActionError("Target session is unavailable", 404, { + error: "Target session is unavailable", + }); + } + if (!isSessionConnected(userId, target.sessionId)) { + throw new ApiActionError("Target session is offline", 503, { + error: "Target session is offline", + }); + } + + const result: ShortcutExecutionResult = await requestShortcutExecution( + userId, + target.sessionId, + target.shortcutId, + ); + if (!result.success) { + throw new ApiActionError(result.error ?? "Shortcut execution failed", 502, { + error: result.error ?? "Shortcut execution failed", + requestId: result.requestId, + }); + } + + return result; +} + +export async function getShortcuts(userId: string) { + const pb = await getSuperuserPB(); + const records = (await pb.collection("shortcuts").getFullList(1000, { + filter: `user="${escapeFilter(userId)}"`, + sort: "name", + })) as Array; + + return records.map((record) => { + const action = parseAction(record.action); + const actionString = typeof action === "string" ? action : ""; + return { + id: record.id, + parentId: + typeof record.app === "string" && record.app.trim().length > 0 + ? record.app.trim() + : undefined, + name: String(record.name ?? ""), + icon: String(record.icon ?? ""), + secondaryInfo: String(record.secondary ?? ""), + type: actionString.startsWith("app:") ? "app" : "link", + action, + tags: parseTags(record.tags), + isPinned: Boolean(record.isPinned), + usageStats: record.usageStats, + }; + }); +} + +export async function ensureShortcutsApp( + pb: any, + userId: string, + sourceId: string, + details: ShortcutAppDetails, + type: ShortcutAppType = "just-in-time", +) { + const filter = `user="${escapeFilter(userId)}" && sourceId="${escapeFilter(sourceId)}"`; + const records = await pb.collection("shortcutsApps").getFullList(10, { + filter, + sort: "created", + }); + const existing = records[0]; + if (existing) { + const updates: Record = {}; + if (details.name && existing.name !== details.name) updates.name = details.name; + if (details.icon !== undefined && existing.icon !== details.icon) updates.icon = details.icon; + if (Object.keys(updates).length > 0) { + return pb.collection("shortcutsApps").update(existing.id, updates); + } + return existing; + } + + return pb.collection("shortcutsApps").create({ + user: userId, + sourceId, + name: details.name, + type, + icon: details.icon ?? "", + }); +} + +export async function getOnDemandShortcutApp(userId: string, appId: string) { + const pb = await getSuperuserPB(); + let appRecord: any; + try { + appRecord = await pb.collection("shortcutsApps").getOne(appId); + } catch { + throw new ApiActionError("Shortcut app not found", 404, { + error: "Shortcut app not found", + }); + } + + if (String(appRecord.user ?? "") !== userId) { + throw new ApiActionError("Shortcut app not found", 404, { + error: "Shortcut app not found", + }); + } + if (appRecord.type !== "on-demand") { + throw new ApiActionError("Shortcut app is not on-demand", 409, { + error: "Shortcut app is not on-demand", + }); + } + + return { pb, appRecord }; +} + +export async function createOnDemandShortcutApp( + userId: string, + input: { name?: unknown; type?: unknown; icon?: unknown }, +) { + const name = typeof input.name === "string" ? input.name.trim() : ""; + if (!name) { + throw new ApiActionError("A shortcut app name is required", 400, { + error: "A shortcut app name is required", + }); + } + if (input.type !== "on-demand") { + throw new ApiActionError("Only on-demand shortcut apps can be created here", 400, { + error: "type must be on-demand", + }); + } + + const icon = typeof input.icon === "string" ? input.icon.trim() : ""; + const sourceId = `on-demand:${randomUUID()}`; + const pb = await getSuperuserPB(); + const record = await pb.collection("shortcutsApps").create({ + user: userId, + sourceId, + name, + type: "on-demand", + icon, + }); + await pb.collection("shortcuts").create({ + user: userId, + name, + icon, + secondary: "Shortcut app", + action: `app:${record.id}`, + app: null, + sourceId: `shortcuts-app:${record.id}`, + tags: [name, "shortcut app"], + }); + + return { + appId: record.id, + id: record.id, + name: record.name, + type: record.type, + }; +} + +export async function syncOnDemandShortcuts( + userId: string, + appId: string, + rawShortcuts: unknown, +) { + if (!Array.isArray(rawShortcuts)) { + throw new ApiActionError("shortcuts must be an array", 400, { + error: "shortcuts must be an array", + }); + } + + const { pb } = await getOnDemandShortcutApp(userId, appId); + const shortcuts = rawShortcuts.map(normalizeOnDemandShortcut); + const sourceIds = new Set(); + for (const shortcut of shortcuts) { + if (sourceIds.has(shortcut.sourceId)) { + throw new ApiActionError("Shortcut sourceId values must be unique", 400, { + error: `Duplicate shortcut sourceId: ${shortcut.sourceId}`, + }); + } + sourceIds.add(shortcut.sourceId); + } + + const filter = `user="${escapeFilter(userId)}" && app="${escapeFilter(appId)}"`; + const existing = await pb.collection("shortcuts").getFullList(10000, { + filter, + }); + const existingBySourceId = new Map(); + for (const record of existing) { + const sourceId = String(record.sourceId ?? ""); + if (sourceId) { + existingBySourceId.set(sourceId, record); + } else { + await pb.collection("shortcuts").delete(record.id); + } + } + + let created = 0; + let updated = 0; + for (const shortcut of shortcuts) { + const current = existingBySourceId.get(shortcut.sourceId); + const data = { + user: userId, + app: appId, + sourceId: shortcut.sourceId, + name: shortcut.name, + icon: shortcut.icon, + secondary: shortcut.secondary, + action: shortcut.action, + tags: shortcut.tags, + }; + + if (!current) { + await pb.collection("shortcuts").create(data); + created += 1; + continue; + } + + if (!sameShortcutData(current, shortcut)) { + await pb.collection("shortcuts").update(current.id, { + name: data.name, + icon: data.icon, + secondary: data.secondary, + action: data.action, + tags: data.tags, + }); + updated += 1; + } + existingBySourceId.delete(shortcut.sourceId); + } + + let deleted = 0; + for (const record of existingBySourceId.values()) { + await pb.collection("shortcuts").delete(record.id); + deleted += 1; + } + + return { appId, total: shortcuts.length, created, updated, deleted }; +} + +function normalizeOnDemandShortcut(raw: unknown): Required { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new ApiActionError("Each shortcut must be an object", 400, { + error: "Each shortcut must be an object", + }); + } + + const input = raw as Record; + const sourceId = stringInput(input.sourceId); + const name = stringInput(input.name); + const action = stringInput(input.action); + if (!sourceId || !name || !action) { + throw new ApiActionError("Each shortcut requires sourceId, name, and action", 400, { + error: "Each shortcut requires sourceId, name, and action", + }); + } + + const tags = input.tags === undefined ? [] : parseTags(input.tags); + if (input.tags !== undefined && !Array.isArray(input.tags) && typeof input.tags !== "string") { + throw new ApiActionError("Shortcut tags must be an array", 400, { + error: "Shortcut tags must be an array", + }); + } + + return { + sourceId, + name, + icon: stringInput(input.icon), + secondary: stringInput(input.secondary), + action, + tags, + }; +} + +function stringInput(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +function sameShortcutData(record: any, shortcut: Required) { + return String(record.name ?? "") === shortcut.name && + String(record.icon ?? "") === shortcut.icon && + String(record.secondary ?? "") === shortcut.secondary && + String(record.action ?? "") === shortcut.action && + JSON.stringify(parseTags(record.tags)) === JSON.stringify(shortcut.tags); +} + +type ShortcutAction = string | { + type: string; + url?: string; + proxy?: boolean; +}; + +function parseAction(raw: unknown): ShortcutAction { + if (typeof raw !== "string") return ""; + const trimmed = raw.trim(); + if (!trimmed) return ""; + + if (trimmed.toLowerCase().startsWith("post:")) { + const url = trimmed.slice(5).trim(); + return { type: "post", url, proxy: true }; + } + + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + const parsed = JSON.parse(trimmed); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const type = String((parsed as any).type ?? "").trim().toLowerCase(); + if (type) { + const url = typeof (parsed as any).url === "string" ? (parsed as any).url : undefined; + if (type === "post") { + return { type: "post", url, proxy: true }; + } + return { type, url }; + } + } + } catch { + // Keep malformed action JSON as a literal string. + } + } + + return trimmed; +} diff --git a/apps/backend/src/routes/data.route.ts b/apps/backend/src/routes/data.route.ts index c0b123da..bc40b420 100644 --- a/apps/backend/src/routes/data.route.ts +++ b/apps/backend/src/routes/data.route.ts @@ -8,7 +8,7 @@ import notificationsRoute from "./notifications.route"; import pageConfigRoute from "./pageConfig.route"; import wallpapersRoute from "./wallpapers.route"; import widgetsRoute from "./widgets.route"; -import searchItemsRoute from "./searchItems.route"; +import shortcutsRoute from "./shortcuts.route"; const dataRoute = new Hono(); @@ -20,6 +20,6 @@ dataRoute.route("/", newsRoute); dataRoute.route("/", notificationsRoute); dataRoute.route("/", monitoringRoute); dataRoute.route("/", wallpapersRoute); -dataRoute.route("/", searchItemsRoute); +dataRoute.route("/", shortcutsRoute); export default dataRoute; diff --git a/apps/backend/src/routes/integrations.route.ts b/apps/backend/src/routes/integrations.route.ts index d3f0736e..31b036d4 100644 --- a/apps/backend/src/routes/integrations.route.ts +++ b/apps/backend/src/routes/integrations.route.ts @@ -15,6 +15,7 @@ import { updateIntegration, } from "../lib/data/integrations"; import { ApiActionError } from "../lib/data/auth"; +import { executeRoutedShortcut } from "../lib/data/shortcuts"; import { getSuperuserPB } from "../lib/pb/pocketbase"; import { flattenToEnv, @@ -119,20 +120,24 @@ integrationsRoute withJson(async (c) => { const body = await readJsonBody(c); const { userId } = await requireAuth({ token: readAuthToken(c) }); - const searchItemId = String(body?.searchItemId ?? body?.id ?? "").trim(); + const shortcutId = String(body?.shortcutId ?? body?.id ?? "").trim(); - if (!searchItemId) { - throw new ApiActionError("Missing searchItemId", 400, { - error: "Missing searchItemId", + if (!shortcutId) { + throw new ApiActionError("Missing shortcutId", 400, { + error: "Missing shortcutId", }); } const pb = await getSuperuserPB(); - const record = await pb.collection("searchItems").getOne(searchItemId); + const record = await pb.collection("shortcuts").getOne(shortcutId); if (!record || record.user !== userId) { throw new ApiActionError("Unauthorized", 403, { error: "Unauthorized" }); } + if (typeof record.action === "string" && /^shortcut:/i.test(record.action.trim())) { + return executeRoutedShortcut(userId, record.action); + } + const action = parseProxyAction(record.action); if (!action?.url) { throw new ApiActionError("Unsupported proxy action", 400, { diff --git a/apps/backend/src/routes/searchItems.route.ts b/apps/backend/src/routes/searchItems.route.ts deleted file mode 100644 index 669a10af..00000000 --- a/apps/backend/src/routes/searchItems.route.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Hono } from "hono"; -import { getSuperuserPB } from "../lib/pb/pocketbase"; -import { getSearchItems } from "../lib/data/searchItems"; - -import { readAuthToken, readJsonBody, requireAuth, withJson } from "./shared"; - -const searchItemsRoute = new Hono(); - -searchItemsRoute - .get( - "/api/v1/searchItems", - withJson(async (c) => { - const { userId } = await requireAuth({ token: readAuthToken(c) }); - return getSearchItems(userId); - }), - ) - .post("/api/v1/searchItems/usageStats", withJson(async (c) => { - const body = await readJsonBody<{ id: string; timestamp: string; auth?: any }>(c); - const { userId } = await requireAuth({ token: readAuthToken(c) }); - - if (!body.id || !body.timestamp) { - return { success: false, error: "Missing id or timestamp" }; - } - - const pb = await getSuperuserPB(); - const record = await pb.collection("searchItems").getOne(body.id); - - if (record.user !== userId) { - return { success: false, error: "Unauthorized" }; - } - - const usageStats = Array.isArray(record.usageStats) ? record.usageStats : []; - usageStats.push({ timestamp: body.timestamp }); - - // Keep only last 100 usages to prevent infinite growth - if (usageStats.length > 100) { - usageStats.shift(); - } - - await pb.collection("searchItems").update(body.id, { usageStats }); - - return { success: true }; - })) - .get("/api/v1/searchItems/frequentlyUsed", withJson(async (c) => { - const { userId } = await requireAuth({ token: readAuthToken(c) }); - - const pb = await getSuperuserPB(); - const records = await pb.collection("searchItems").getFullList(1000, { - filter: `user="${userId.replace(/"/g, '\\"')}"`, - }); - - const sorted = records - .map(record => ({ - id: record.id, - usageCount: Array.isArray(record.usageStats) ? record.usageStats.length : 0, - })) - .filter(record => record.usageCount > 0) - .sort((a, b) => b.usageCount - a.usageCount) - .slice(0, 5); - - return sorted.map(item => ({ id: item.id })); - })); - -export default searchItemsRoute; diff --git a/apps/backend/src/routes/shortcuts.route.ts b/apps/backend/src/routes/shortcuts.route.ts new file mode 100644 index 00000000..dbc511b9 --- /dev/null +++ b/apps/backend/src/routes/shortcuts.route.ts @@ -0,0 +1,87 @@ +import { Hono } from "hono"; + +import { + createOnDemandShortcutApp, + getShortcuts, + syncOnDemandShortcuts, +} from "../lib/data/shortcuts"; +import { getSuperuserPB } from "../lib/pb/pocketbase"; +import { ApiActionError } from "../lib/data/auth"; +import { readAuthToken, readJsonBody, requireAuth, withJson } from "./shared"; + +const shortcutsRoute = new Hono(); + +shortcutsRoute + .get( + "/api/v1/shortcuts", + withJson(async (c) => { + const { userId } = await requireAuth({ token: readAuthToken(c) }); + return getShortcuts(userId); + }), + ) + .post( + "/api/v1/shortcuts/apps", + withJson(async (c) => { + const body = await readJsonBody<{ name?: unknown; type?: unknown; icon?: unknown }>(c); + const { userId } = await requireAuth({ token: readAuthToken(c) }); + return createOnDemandShortcutApp(userId, body ?? {}); + }), + ) + .put( + "/api/v1/shortcuts/on-demand/:appId", + withJson(async (c) => { + const body = await readJsonBody<{ shortcuts?: unknown }>(c); + const { userId } = await requireAuth({ token: readAuthToken(c) }); + return syncOnDemandShortcuts(userId, String(c.req.param("appId") ?? ""), body?.shortcuts); + }), + ) + .post( + "/api/v1/shortcuts/usageStats", + withJson(async (c) => { + const body = await readJsonBody<{ id?: string; timestamp?: string }>(c); + const { userId } = await requireAuth({ token: readAuthToken(c) }); + const id = String(body?.id ?? "").trim(); + const timestamp = String(body?.timestamp ?? "").trim(); + + if (!id || !timestamp) { + throw new ApiActionError("Missing id or timestamp", 400, { + error: "Missing id or timestamp", + }); + } + + const pb = await getSuperuserPB(); + const record = await pb.collection("shortcuts").getOne(id); + if (record.user !== userId) { + throw new ApiActionError("Unauthorized", 403, { error: "Unauthorized" }); + } + + const usageStats = Array.isArray(record.usageStats) ? record.usageStats : []; + usageStats.push({ timestamp }); + if (usageStats.length > 100) usageStats.shift(); + + await pb.collection("shortcuts").update(id, { usageStats }); + return { success: true }; + }), + ) + .get( + "/api/v1/shortcuts/frequentlyUsed", + withJson(async (c) => { + const { userId } = await requireAuth({ token: readAuthToken(c) }); + const pb = await getSuperuserPB(); + const records = await pb.collection("shortcuts").getFullList(1000, { + filter: `user="${userId.replace(/"/g, '\\"')}"`, + }); + + return records + .map((record) => ({ + id: record.id, + usageCount: Array.isArray(record.usageStats) ? record.usageStats.length : 0, + })) + .filter((record) => record.usageCount > 0) + .sort((a, b) => b.usageCount - a.usageCount) + .slice(0, 5) + .map((item) => ({ id: item.id })); + }), + ); + +export default shortcutsRoute; diff --git a/apps/backend/src/routes/system.route.ts b/apps/backend/src/routes/system.route.ts index cb4ea1de..74cd6f09 100644 --- a/apps/backend/src/routes/system.route.ts +++ b/apps/backend/src/routes/system.route.ts @@ -25,12 +25,12 @@ systemRoute.get( }), ); -systemRoute.get("/api/v1/jobs/searchItems", async (c) => { +systemRoute.get("/api/v1/jobs/shortcuts", async (c) => { if (!validateJobsBasicAuth(c.req.header("authorization"))) { return c.json({ status: "error", message: "Unauthorized" }, 401); } - await jobsApi.runSearchItemsJob("api"); + await jobsApi.runShortcutsJob("api"); return c.json({ status: "success" }); }); systemRoute.get("/api/v1/jobs/pullIcons", async (c) => { diff --git a/apps/web/public/openapi.json b/apps/web/public/openapi.json index 8a37f1e8..73120529 100644 --- a/apps/web/public/openapi.json +++ b/apps/web/public/openapi.json @@ -45,7 +45,7 @@ "name": "pageConfig" }, { - "name": "search" + "name": "shortcuts" }, { "name": "sessions" @@ -646,12 +646,12 @@ } } }, - "/jobs/searchItems": { + "/jobs/shortcuts": { "get": { "tags": [ "jobs" ], - "summary": "Search items job", + "summary": "Shortcuts indexing job", "responses": { "200": { "$ref": "#/components/responses/JsonOk" @@ -1351,12 +1351,12 @@ } } }, - "/searchItems": { + "/shortcuts": { "get": { "tags": [ - "search" + "shortcuts" ], - "summary": "Search items", + "summary": "List shortcuts", "responses": { "200": { "$ref": "#/components/responses/JsonOk" @@ -2231,12 +2231,72 @@ } } }, - "/searchItems/frequentlyUsed": { + "/shortcuts/apps": { + "post": { + "tags": [ + "shortcuts" + ], + "summary": "Create an on-demand shortcut app", + "requestBody": { + "$ref": "#/components/requestBodies/JsonBody" + }, + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + }, + "400": { + "$ref": "#/components/responses/JsonBadRequest" + }, + "401": { + "$ref": "#/components/responses/JsonUnauthorized" + } + } + } + }, + "/shortcuts/on-demand/{appId}": { + "put": { + "tags": [ + "shortcuts" + ], + "summary": "Replace an on-demand app's shortcuts", + "parameters": [ + { + "name": "appId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/JsonBody" + }, + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + }, + "400": { + "$ref": "#/components/responses/JsonBadRequest" + }, + "401": { + "$ref": "#/components/responses/JsonUnauthorized" + }, + "404": { + "$ref": "#/components/responses/JsonNotFound" + }, + "409": { + "$ref": "#/components/responses/JsonConflict" + } + } + } + }, + "/shortcuts/frequentlyUsed": { "get": { "tags": [ - "search" + "shortcuts" ], - "summary": "List frequently used search items", + "summary": "List frequently used shortcuts", "responses": { "200": { "$ref": "#/components/responses/JsonOk" @@ -2244,12 +2304,12 @@ } } }, - "/searchItems/usageStats": { + "/shortcuts/usageStats": { "post": { "tags": [ - "search" + "shortcuts" ], - "summary": "Log search item usage", + "summary": "Log shortcut usage", "requestBody": { "$ref": "#/components/requestBodies/JsonBody" }, @@ -2324,6 +2384,26 @@ } } } + }, + "JsonNotFound": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "JsonConflict": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } }, "securitySchemes": { diff --git a/apps/web/src/app/(authenticated)/settings/apps/page.tsx b/apps/web/src/app/(authenticated)/settings/apps/page.tsx index d2ca8db5..d0015293 100644 --- a/apps/web/src/app/(authenticated)/settings/apps/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/apps/page.tsx @@ -17,13 +17,16 @@ type EmptyAppSectionProps = { icon: string; description?: string; children?: ReactNode; + withTopOutline?: boolean; }; -function EmptyAppSection({ title, icon, description, children }: EmptyAppSectionProps) { +function EmptyAppSection({ title, icon, description, children, withTopOutline = false }: EmptyAppSectionProps) { return (
-

- +

+ + + {title}

{description && ( @@ -306,18 +309,22 @@ export default function AppsSettingsPage() {
-

- +

+ + + Links

diff --git a/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx b/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx index 3ea50690..88eaeac4 100644 --- a/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx +++ b/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx @@ -301,8 +301,11 @@ export default function DashboardLayoutTemplate({ useEffect(() => { let cancelled = false; + let refreshInFlight = false; const primeIntegrationData = async () => { + if (refreshInFlight || document.visibilityState !== "visible") return; + refreshInFlight = true; try { await refreshPageIntegrationData(); if (cancelled) return; @@ -310,6 +313,8 @@ export default function DashboardLayoutTemplate({ if (!cancelled) { console.error("Failed to prime page integration data", error); } + } finally { + refreshInFlight = false; } }; @@ -321,25 +326,30 @@ export default function DashboardLayoutTemplate({ const startPolling = () => { if (intervalId) return; - intervalId = window.setInterval(async () => { - try { - await refreshPageIntegrationData(); - if (cancelled) return; - } catch (error) { - if (!cancelled) console.error("Failed to poll page integration data", error); - } - }, POLL_INTERVAL_MS); + intervalId = window.setInterval(() => void primeIntegrationData(), POLL_INTERVAL_MS); + }; + + const stopPolling = () => { + if (intervalId) window.clearInterval(intervalId); + intervalId = null; + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === "visible" && token) { + void primeIntegrationData(); + startPolling(); + } else { + stopPolling(); + } }; - // Start polling if we have a token (otherwise polling is a no-op) - if (token) startPolling(); + if (token && document.visibilityState === "visible") startPolling(); + document.addEventListener("visibilitychange", handleVisibilityChange); return () => { cancelled = true; - if (intervalId) { - clearInterval(intervalId); - intervalId = null; - } + document.removeEventListener("visibilitychange", handleVisibilityChange); + stopPolling(); }; }, [refreshPageIntegrationData, token]); diff --git a/apps/web/src/components/widgets/CommandBar.tsx b/apps/web/src/components/widgets/CommandBar.tsx index 92be1c4c..3d6f6ac4 100644 --- a/apps/web/src/components/widgets/CommandBar.tsx +++ b/apps/web/src/components/widgets/CommandBar.tsx @@ -10,7 +10,7 @@ import { DialogTitle } from "@radix-ui/react-dialog"; import { Icon as IconifyIcon } from "@iconify-icon/react"; import AppIcon from "@dashwise/app-icon"; import QRCode from "qrcode"; -import { getFrequentlyUsedSearchItemsAction, logSearchItemUsageAction } from '@/lib/apiClient'; +import { getFrequentlyUsedShortcutsAction, logShortcutUsageAction } from '@/lib/apiClient'; import { proxyIntegrationAction } from '@/lib/apiClient'; // --- Types --- @@ -45,7 +45,7 @@ type SearchEngine = { url_params?: string; }; -type IncomingSearchItem = { +type IncomingShortcut = { id?: string; parentId?: string; name?: string; @@ -68,11 +68,11 @@ type ProxyAction = { type CommandBarProps = { open: boolean; setOpen: React.Dispatch>; - searchItems: IncomingSearchItem[]; + shortcuts: IncomingShortcut[]; config: Record; }; -function normalizeConfigLinks(input: IncomingSearchItem[] = []): LinkItem[] { +function normalizeConfigLinks(input: IncomingShortcut[] = []): LinkItem[] { return input .filter((it) => !it.type || it.type === "link" || it.type === "app" || @@ -147,7 +147,7 @@ function normalizeConfigLinks(input: IncomingSearchItem[] = []): LinkItem[] { } export default function CommandBar( - { open, setOpen, searchItems, config }: CommandBarProps, + { open, setOpen, shortcuts, config }: CommandBarProps, ) { const { user, @@ -162,8 +162,8 @@ export default function CommandBar( (searchPreferences.searchEngines || []) as SearchEngine[]; const links: LinkItem[] = React.useMemo( - () => normalizeConfigLinks(searchItems || []), - [searchItems], + () => normalizeConfigLinks(shortcuts || []), + [shortcuts], ); const defaultEngine = searchEngines.find((se) => se.status === "default") || @@ -187,7 +187,7 @@ export default function CommandBar( return; } - void getFrequentlyUsedSearchItemsAction({ token }) + void getFrequentlyUsedShortcutsAction({ token }) .then((data) => { if (Array.isArray(data)) { setFrequentlyUsedIds(data.map((item: any) => item.id)); @@ -552,29 +552,32 @@ export default function CommandBar( } else if (a.url === "__qr_action__") { return; } else if (a.url === "__proxy_action__") { - logSearchItemUsage(a); + logShortcutUsage(a); + void triggerProxyAction(a); + } else if (a.url.toLowerCase().startsWith("shortcut:")) { + logShortcutUsage(a); void triggerProxyAction(a); } else if (a.url === "__logout_action__") { - logSearchItemUsage(a); + logShortcutUsage(a); logout(); setOpen(false); } else if (a.url === "__privacy_action__") { - logSearchItemUsage(a); + logShortcutUsage(a); void togglePrivacyMode(); } else if (a.url === "__toggle_theme__") { - logSearchItemUsage(a); + logShortcutUsage(a); void toggleThemePreference(); } else if (a.url === "__toggle_link_tile_layout__") { - logSearchItemUsage(a); + logShortcutUsage(a); void toggleLinkTileLayoutPreference(); } else if (a.url.startsWith("__engine_search__:")) { const slug = a.url.split(":", 2)[1]; openEngineSearch(slug, query); } else if (a.url.startsWith("command:")) { - logSearchItemUsage(a); + logShortcutUsage(a); openCommandClient(a.url); } else { - logSearchItemUsage(a); + logShortcutUsage(a); openUrl(a.url, config?.global?.linkOpenBehaviour); } } @@ -614,9 +617,9 @@ export default function CommandBar( } } - function logSearchItemUsage(item?: LinkItem) { + function logShortcutUsage(item?: LinkItem) { if (!token || !item?.id) return; - void logSearchItemUsageAction({ token }, item.id, new Date().toISOString()).catch(() => {}); + void logShortcutUsageAction({ token }, item.id, new Date().toISOString()).catch(() => {}); } async function triggerProxyAction(item: LinkItem) { diff --git a/apps/web/src/components/widgets/SearchBar.tsx b/apps/web/src/components/widgets/SearchBar.tsx index edac1a62..67ddc567 100644 --- a/apps/web/src/components/widgets/SearchBar.tsx +++ b/apps/web/src/components/widgets/SearchBar.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import useAuth, { COMMAND_BAR_OPEN_EVENT } from "@/context/useAuth"; import CommandBar from './CommandBar'; -import { getSearchItemsAction } from '@/lib/apiClient'; +import { getShortcutsAction } from '@/lib/apiClient'; import { useApiQuery } from "@/hooks/useApiQuery"; import { queryKeys } from "@/lib/queryClient"; @@ -19,7 +19,7 @@ type ProxyAction = { proxy?: boolean; }; -type SearchItem = { +type Shortcut = { id?: string; parentId?: string; name: string; @@ -30,9 +30,9 @@ type SearchItem = { tags?: string[]; }; -function normalizeSearchItems(raw: unknown): SearchItem[] { +function normalizeShortcuts(raw: unknown): Shortcut[] { if (Array.isArray(raw)) { - return raw.filter((item): item is SearchItem => !!item && typeof item === "object"); + return raw.filter((item): item is Shortcut => !!item && typeof item === "object"); } if (typeof raw !== "string") { @@ -42,7 +42,7 @@ function normalizeSearchItems(raw: unknown): SearchItem[] { try { const parsed = JSON.parse(raw); return Array.isArray(parsed) - ? parsed.filter((item): item is SearchItem => !!item && typeof item === "object") + ? parsed.filter((item): item is Shortcut => !!item && typeof item === "object") : []; } catch { return []; @@ -61,9 +61,8 @@ export default function SearchBar({ const didMountRef = useRef(false); const { user } = useAuth(); - // fetched items from /api/v1/searchItems - const searchItemsQuery = useApiQuery(queryKeys.links.search, getSearchItemsAction, { enabled: open }); - const searchItems = normalizeSearchItems(searchItemsQuery.data); + const shortcutsQuery = useApiQuery(queryKeys.links.search, getShortcutsAction, { enabled: open }); + const shortcuts = normalizeShortcuts(shortcutsQuery.data); useEffect(() => { if (!didMountRef.current) { @@ -124,7 +123,7 @@ export default function SearchBar({ )} - + ); } diff --git a/apps/web/src/components/widgets/ShortcutsPicker.tsx b/apps/web/src/components/widgets/ShortcutsPicker.tsx index 9604355c..22ac5aa2 100644 --- a/apps/web/src/components/widgets/ShortcutsPicker.tsx +++ b/apps/web/src/components/widgets/ShortcutsPicker.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import AppIcon from "@dashwise/app-icon"; import useAuth from "@/context/useAuth"; -import { getSearchItemsAction } from "@/lib/apiClient"; +import { getShortcutsAction } from "@/lib/apiClient"; import { Input } from "@/components/ui/input"; type Shortcut = { @@ -21,7 +21,7 @@ export default function ShortcutsPicker( const [items, setItems] = useState([]); const [query, setQuery] = useState(""); useEffect(() => { - void withAuth((auth) => getSearchItemsAction(auth)).then((data) => { + void withAuth((auth) => getShortcutsAction(auth)).then((data) => { if (Array.isArray(data)) setItems(data as Shortcut[]); }).catch(() => setItems([])); }, [withAuth]); diff --git a/apps/web/src/components/widgets/ShortcutsWidget.tsx b/apps/web/src/components/widgets/ShortcutsWidget.tsx index 5279791f..0f5217ad 100644 --- a/apps/web/src/components/widgets/ShortcutsWidget.tsx +++ b/apps/web/src/components/widgets/ShortcutsWidget.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import AppIcon from "@dashwise/app-icon"; import WidgetColumnTemplate from "@dashwise/integrationskit/templates/WidgetColumn"; import useAuth from "@/context/useAuth"; -import { getSearchItemsAction, logSearchItemUsageAction, proxyIntegrationAction } from "@/lib/apiClient"; +import { getShortcutsAction, logShortcutUsageAction, proxyIntegrationAction } from "@/lib/apiClient"; type Shortcut = { id: string; name: string; icon?: string; action: string | { type: string; url?: string } }; @@ -14,7 +14,7 @@ export default function ShortcutsWidget({ shortcutIds = [], className = "" }: { useEffect(() => { let cancelled = false; - void withAuth((auth) => getSearchItemsAction(auth)).then((items) => { + void withAuth((auth) => getShortcutsAction(auth)).then((items) => { if (cancelled || !Array.isArray(items)) return; const selected = new Map((items as Shortcut[]).map((item) => [item.id, item])); setShortcuts(shortcutIds.map((id) => selected.get(id)).filter((item): item is Shortcut => Boolean(item))); @@ -32,12 +32,16 @@ export default function ShortcutsWidget({ shortcutIds = [], className = "" }: { if (!value) return; if (value.toLowerCase().startsWith("theme:")) { await toggleTheme(); - void withAuth((auth) => logSearchItemUsageAction(auth, shortcut.id, new Date().toISOString())); + void withAuth((auth) => logShortcutUsageAction(auth, shortcut.id, new Date().toISOString())); return; } if (value.toLowerCase().startsWith("link-tile-layout:")) { await toggleLinkTileLayout(); - void withAuth((auth) => logSearchItemUsageAction(auth, shortcut.id, new Date().toISOString())); + void withAuth((auth) => logShortcutUsageAction(auth, shortcut.id, new Date().toISOString())); + return; + } + if (value.toLowerCase().startsWith("shortcut:")) { + await withAuth((auth) => proxyIntegrationAction(auth, shortcut.id)); return; } if (value.startsWith("command:")) { @@ -45,7 +49,7 @@ export default function ShortcutsWidget({ shortcutIds = [], className = "" }: { return; } window.open(value.startsWith("url:") ? value.slice(4) : value, "_self"); - void withAuth((auth) => logSearchItemUsageAction(auth, shortcut.id, new Date().toISOString())); + void withAuth((auth) => logShortcutUsageAction(auth, shortcut.id, new Date().toISOString())); }; return diff --git a/apps/web/src/context/ActivityContext.tsx b/apps/web/src/context/ActivityContext.tsx index d3963a1e..d2014dea 100644 --- a/apps/web/src/context/ActivityContext.tsx +++ b/apps/web/src/context/ActivityContext.tsx @@ -6,6 +6,7 @@ import { backendUrl } from "@/lib/apiClient"; import { useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "@/lib/queryClient"; import { getClientSessionId } from "@/lib/session"; +import { executeRegisteredActivityShortcut } from "@/lib/activityShortcuts"; export type ActivityNotification = { id: string; @@ -68,6 +69,18 @@ export function ActivityProvider({ children }: { children: ReactNode }) { nextSocket.onmessage = (event) => { try { const message = JSON.parse(String(event.data)); + if (message.type === "shortcut:execute" && typeof message.requestId === "string" && typeof message.shortcutId === "string") { + void executeRegisteredActivityShortcut(message.shortcutId).then((result) => { + if (nextSocket.readyState !== WebSocket.OPEN) return; + nextSocket.send(JSON.stringify({ + type: "shortcut:result", + requestId: message.requestId, + success: result.success, + ...(result.error ? { error: result.error } : {}), + })); + }); + return; + } if (message.type !== "activity:snapshot") return; const nextNotifications = Array.isArray(message.notifications) ? message.notifications : []; setNotifications(nextNotifications); diff --git a/apps/web/src/lib/activityShortcuts.ts b/apps/web/src/lib/activityShortcuts.ts new file mode 100644 index 00000000..f8b3a6d4 --- /dev/null +++ b/apps/web/src/lib/activityShortcuts.ts @@ -0,0 +1,41 @@ +export type ActivityShortcutResult = { + success: boolean; + error?: string; +}; + +export type ActivityShortcutHandler = () => Promise | ActivityShortcutResult | boolean; + +const handlers = new Map(); + +export function registerActivityShortcut(shortcutId: string, handler: ActivityShortcutHandler) { + const normalizedId = shortcutId.trim(); + if (!normalizedId) throw new Error("A shortcut id is required"); + + handlers.set(normalizedId, handler); + return () => { + if (handlers.get(normalizedId) === handler) handlers.delete(normalizedId); + }; +} + +export async function executeRegisteredActivityShortcut(shortcutId: string): Promise { + const handler = handlers.get(shortcutId); + if (!handler) { + return { + success: false, + error: "Shortcut is not registered on this client", + }; + } + + try { + const result = await handler(); + if (typeof result === "boolean") return { success: result }; + return result?.success === true + ? { success: true } + : { success: false, error: result?.error || "The client failed to execute the shortcut" }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "The client failed to execute the shortcut", + }; + } +} diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts index 5a4e0316..95d041ec 100644 --- a/apps/web/src/lib/api/index.ts +++ b/apps/web/src/lib/api/index.ts @@ -1,5 +1,4 @@ -// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts -export { deleteAuthDeleteAccount, deleteIntegrationsById, deleteLinksDevUserLinks, deleteLinksItemsByLinkId, deleteMonitoringHostsById, deleteMonitoringSshHostsById, deleteMonitorsById, deleteNewsFeedRecordsById, deleteNotificationsForwarders, deleteNotificationsTopics, deleteNotificationsTopicTokens, getAppConfig, getAppInfo, getAuthCallback, getAuthSso, getGlanceables, getGlanceablesByIntegration, getIntegrations, getIntegrationsCaldavEvents, getIntegrationsConsumerData, getIntegrationsWidgetProperties, getJobsPullIcons, getJobsSearchItems, getLinksCollections, getLinksFolders, getLinksHome, getLinksHomeGroups, getLinksItems, getLinksTags, getLocations, getMonitoringHosts, getMonitoringHostsById, getMonitoringHostsByIdHistory, getMonitoringHostsByIdStats, getMonitoringSshHosts, getMonitoringStatus, getMonitors, getMonitorsById, getNews, getNewsFeed, getNewsFeedMetadata, getNewsFeedRecordsById, getNewsFeedRefresh, getNewsFeeds, getNewsFeedsById, getNewsSubscriptions, getNewsSubscriptionsByIdJson, getNotifications, getNotificationsForwarders, getNotificationsTopics, getNotificationsTopicTokens, getPageConfig, getPageConfigUserPages, getSearchItems, getSearchItemsFrequentlyUsed, getTestBookmarks, getWallpapers, getWeather, getWidgets, getWidgetsByIntegration, getWidgetsGlanceable, getWidgetsGlanceables, type Options, patchAuthUpdateUserProperty, postAuthChangePassword, postAuthLogin, postAuthMfa, postAuthSignup, postAuthValidateAuth, postIntegrations, postIntegrationsConsumerData, postIntegrationsProxyAction, postIntegrationsTestEndpoint, postLinksCollections, postLinksFolders, postLinksHomeGroups, postLinksItems, postLinksReorder, postLinksTags, postMonitoringHosts, postMonitoringHostsByIdRefresh, postMonitoringSshHosts, postMonitoringStatus, postMonitors, postNewsFeedRecords, postNewsFeedRecordsById, postNewsFeedRefresh, postNewsFeedSubscribe, postNewsFeedUnsubscribe, postNewsFeedUpdate, postNewsFixMissingTitles, postNotifications, postNotificationsByTopic, postNotificationsForwarders, postNotificationsForwardersTest, postNotificationsMarkAsRead, postNotificationsTest, postNotificationsTopics, postNotificationsTopicTokens, postPageConfigHome, postPageConfigIntegrationData, postPageConfigMigrateLegacy, postSearchItemsUsageStats, postWallpapers, putIntegrationsById, putLinksCollectionsByCollectionId, putLinksFoldersByFolderIdIcon, putLinksItemsByLinkId, putLinksTagsByTagId, putMonitoringHostsById, putMonitoringSshHostsById, putMonitorsById, putNotificationsForwarders, putNotificationsTopicTokens, putPageConfig, getSessionsCurrent, patchSessionsCurrent } from './sdk.gen'; -export type { ClientOptions, DeleteAuthDeleteAccountData, DeleteAuthDeleteAccountError, DeleteAuthDeleteAccountErrors, DeleteAuthDeleteAccountResponse, DeleteAuthDeleteAccountResponses, DeleteIntegrationsByIdData, DeleteIntegrationsByIdResponse, DeleteIntegrationsByIdResponses, DeleteLinksDevUserLinksData, DeleteLinksDevUserLinksResponse, DeleteLinksDevUserLinksResponses, DeleteLinksItemsByLinkIdData, DeleteLinksItemsByLinkIdResponse, DeleteLinksItemsByLinkIdResponses, DeleteMonitoringHostsByIdData, DeleteMonitoringHostsByIdResponse, DeleteMonitoringHostsByIdResponses, DeleteMonitoringSshHostsByIdData, DeleteMonitoringSshHostsByIdResponse, DeleteMonitoringSshHostsByIdResponses, DeleteMonitorsByIdData, DeleteMonitorsByIdResponse, DeleteMonitorsByIdResponses, DeleteNewsFeedRecordsByIdData, DeleteNewsFeedRecordsByIdResponse, DeleteNewsFeedRecordsByIdResponses, DeleteNotificationsForwardersData, DeleteNotificationsForwardersResponse, DeleteNotificationsForwardersResponses, DeleteNotificationsTopicsData, DeleteNotificationsTopicsResponse, DeleteNotificationsTopicsResponses, DeleteNotificationsTopicTokensData, DeleteNotificationsTopicTokensResponse, DeleteNotificationsTopicTokensResponses, Error, GenericObject, GetAppConfigData, GetAppConfigResponse, GetAppConfigResponses, GetAppInfoData, GetAppInfoResponse, GetAppInfoResponses, GetAuthCallbackData, GetAuthCallbackResponse, GetAuthCallbackResponses, GetAuthSsoData, GetAuthSsoResponse, GetAuthSsoResponses, GetGlanceablesByIntegrationData, GetGlanceablesByIntegrationResponse, GetGlanceablesByIntegrationResponses, GetGlanceablesData, GetGlanceablesResponse, GetGlanceablesResponses, GetIntegrationsCaldavEventsData, GetIntegrationsCaldavEventsResponse, GetIntegrationsCaldavEventsResponses, GetIntegrationsConsumerDataData, GetIntegrationsConsumerDataResponse, GetIntegrationsConsumerDataResponses, GetIntegrationsData, GetIntegrationsResponse, GetIntegrationsResponses, GetIntegrationsWidgetPropertiesData, GetIntegrationsWidgetPropertiesResponse, GetIntegrationsWidgetPropertiesResponses, GetJobsPullIconsData, GetJobsPullIconsError, GetJobsPullIconsErrors, GetJobsPullIconsResponse, GetJobsPullIconsResponses, GetJobsSearchItemsData, GetJobsSearchItemsResponse, GetJobsSearchItemsResponses, GetLinksCollectionsData, GetLinksCollectionsResponse, GetLinksCollectionsResponses, GetLinksFoldersData, GetLinksFoldersResponse, GetLinksFoldersResponses, GetLinksHomeData, GetLinksHomeGroupsData, GetLinksHomeGroupsResponse, GetLinksHomeGroupsResponses, GetLinksHomeResponse, GetLinksHomeResponses, GetLinksItemsData, GetLinksItemsResponse, GetLinksItemsResponses, GetLinksTagsData, GetLinksTagsResponse, GetLinksTagsResponses, GetLocationsData, GetLocationsResponse, GetLocationsResponses, GetMonitoringHostsByIdData, GetMonitoringHostsByIdHistoryData, GetMonitoringHostsByIdHistoryResponse, GetMonitoringHostsByIdHistoryResponses, GetMonitoringHostsByIdResponse, GetMonitoringHostsByIdResponses, GetMonitoringHostsByIdStatsData, GetMonitoringHostsByIdStatsResponse, GetMonitoringHostsByIdStatsResponses, GetMonitoringHostsData, GetMonitoringHostsResponse, GetMonitoringHostsResponses, GetMonitoringSshHostsData, GetMonitoringSshHostsResponse, GetMonitoringSshHostsResponses, GetMonitoringStatusData, GetMonitoringStatusResponse, GetMonitoringStatusResponses, GetMonitorsByIdData, GetMonitorsByIdResponse, GetMonitorsByIdResponses, GetMonitorsData, GetMonitorsResponse, GetMonitorsResponses, GetNewsData, GetNewsFeedData, GetNewsFeedMetadataData, GetNewsFeedMetadataResponse, GetNewsFeedMetadataResponses, GetNewsFeedRecordsByIdData, GetNewsFeedRecordsByIdResponse, GetNewsFeedRecordsByIdResponses, GetNewsFeedRefreshData, GetNewsFeedRefreshResponse, GetNewsFeedRefreshResponses, GetNewsFeedResponse, GetNewsFeedResponses, GetNewsFeedsByIdData, GetNewsFeedsByIdResponse, GetNewsFeedsByIdResponses, GetNewsFeedsData, GetNewsFeedsResponse, GetNewsFeedsResponses, GetNewsResponse, GetNewsResponses, GetNewsSubscriptionsByIdJsonData, GetNewsSubscriptionsByIdJsonResponse, GetNewsSubscriptionsByIdJsonResponses, GetNewsSubscriptionsData, GetNewsSubscriptionsResponse, GetNewsSubscriptionsResponses, GetNotificationsData, GetNotificationsForwardersData, GetNotificationsForwardersResponse, GetNotificationsForwardersResponses, GetNotificationsResponse, GetNotificationsResponses, GetNotificationsTopicsData, GetNotificationsTopicsResponse, GetNotificationsTopicsResponses, GetNotificationsTopicTokensData, GetNotificationsTopicTokensResponse, GetNotificationsTopicTokensResponses, GetPageConfigData, GetPageConfigResponse, GetPageConfigResponses, GetPageConfigUserPagesData, GetPageConfigUserPagesResponse, GetPageConfigUserPagesResponses, GetSearchItemsData, GetSearchItemsFrequentlyUsedData, GetSearchItemsFrequentlyUsedResponse, GetSearchItemsFrequentlyUsedResponses, GetSearchItemsResponse, GetSearchItemsResponses, GetTestBookmarksData, GetTestBookmarksResponse, GetTestBookmarksResponses, GetWallpapersData, GetWallpapersResponse, GetWallpapersResponses, GetWeatherData, GetWeatherResponse, GetWeatherResponses, GetWidgetsByIntegrationData, GetWidgetsByIntegrationResponse, GetWidgetsByIntegrationResponses, GetWidgetsData, GetWidgetsGlanceableData, GetWidgetsGlanceableResponse, GetWidgetsGlanceableResponses, GetWidgetsGlanceablesData, GetWidgetsGlanceablesResponse, GetWidgetsGlanceablesResponses, GetWidgetsResponse, GetWidgetsResponses, Id, JsonBody, PatchAuthUpdateUserPropertyData, PatchAuthUpdateUserPropertyError, PatchAuthUpdateUserPropertyErrors, PatchAuthUpdateUserPropertyResponse, PatchAuthUpdateUserPropertyResponses, PostAuthChangePasswordData, PostAuthChangePasswordError, PostAuthChangePasswordErrors, PostAuthChangePasswordResponse, PostAuthChangePasswordResponses, PostAuthLoginData, PostAuthLoginError, PostAuthLoginErrors, PostAuthLoginResponse, PostAuthLoginResponses, PostAuthMfaData, PostAuthMfaError, PostAuthMfaErrors, PostAuthMfaResponse, PostAuthMfaResponses, PostAuthSignupData, PostAuthSignupError, PostAuthSignupErrors, PostAuthSignupResponse, PostAuthSignupResponses, PostAuthValidateAuthData, PostAuthValidateAuthError, PostAuthValidateAuthErrors, PostAuthValidateAuthResponse, PostAuthValidateAuthResponses, PostIntegrationsConsumerDataData, PostIntegrationsConsumerDataResponse, PostIntegrationsConsumerDataResponses, PostIntegrationsData, PostIntegrationsProxyActionData, PostIntegrationsProxyActionResponse, PostIntegrationsProxyActionResponses, PostIntegrationsResponse, PostIntegrationsResponses, PostIntegrationsTestEndpointData, PostIntegrationsTestEndpointResponse, PostIntegrationsTestEndpointResponses, PostLinksCollectionsData, PostLinksCollectionsResponse, PostLinksCollectionsResponses, PostLinksFoldersData, PostLinksFoldersResponse, PostLinksFoldersResponses, PostLinksHomeGroupsData, PostLinksHomeGroupsResponse, PostLinksHomeGroupsResponses, PostLinksItemsData, PostLinksItemsResponse, PostLinksItemsResponses, PostLinksReorderData, PostLinksReorderResponse, PostLinksReorderResponses, PostLinksTagsData, PostLinksTagsResponse, PostLinksTagsResponses, PostMonitoringHostsByIdRefreshData, PostMonitoringHostsByIdRefreshError, PostMonitoringHostsByIdRefreshErrors, PostMonitoringHostsByIdRefreshResponse, PostMonitoringHostsByIdRefreshResponses, PostMonitoringHostsData, PostMonitoringHostsResponse, PostMonitoringHostsResponses, PostMonitoringSshHostsData, PostMonitoringSshHostsResponse, PostMonitoringSshHostsResponses, PostMonitoringStatusData, PostMonitoringStatusError, PostMonitoringStatusErrors, PostMonitoringStatusResponse, PostMonitoringStatusResponses, PostMonitorsData, PostMonitorsResponse, PostMonitorsResponses, PostNewsFeedRecordsByIdData, PostNewsFeedRecordsByIdResponse, PostNewsFeedRecordsByIdResponses, PostNewsFeedRecordsData, PostNewsFeedRecordsResponse, PostNewsFeedRecordsResponses, PostNewsFeedRefreshData, PostNewsFeedRefreshResponse, PostNewsFeedRefreshResponses, PostNewsFeedSubscribeData, PostNewsFeedSubscribeResponse, PostNewsFeedSubscribeResponses, PostNewsFeedUnsubscribeData, PostNewsFeedUnsubscribeResponse, PostNewsFeedUnsubscribeResponses, PostNewsFeedUpdateData, PostNewsFeedUpdateResponse, PostNewsFeedUpdateResponses, PostNewsFixMissingTitlesData, PostNewsFixMissingTitlesResponse, PostNewsFixMissingTitlesResponses, PostNotificationsByTopicData, PostNotificationsByTopicResponse, PostNotificationsByTopicResponses, PostNotificationsData, PostNotificationsForwardersData, PostNotificationsForwardersResponse, PostNotificationsForwardersResponses, PostNotificationsForwardersTestData, PostNotificationsForwardersTestResponse, PostNotificationsForwardersTestResponses, PostNotificationsMarkAsReadData, PostNotificationsMarkAsReadResponse, PostNotificationsMarkAsReadResponses, PostNotificationsResponse, PostNotificationsResponses, PostNotificationsTestData, PostNotificationsTestResponse, PostNotificationsTestResponses, PostNotificationsTopicsData, PostNotificationsTopicsResponse, PostNotificationsTopicsResponses, PostNotificationsTopicTokensData, PostNotificationsTopicTokensResponse, PostNotificationsTopicTokensResponses, PostPageConfigHomeData, PostPageConfigHomeResponse, PostPageConfigHomeResponses, PostPageConfigIntegrationDataData, PostPageConfigIntegrationDataResponse, PostPageConfigIntegrationDataResponses, PostPageConfigMigrateLegacyData, PostPageConfigMigrateLegacyResponse, PostPageConfigMigrateLegacyResponses, PostSearchItemsUsageStatsData, PostSearchItemsUsageStatsResponse, PostSearchItemsUsageStatsResponses, PostWallpapersData, PostWallpapersResponse, PostWallpapersResponses, PutIntegrationsByIdData, PutIntegrationsByIdResponse, PutIntegrationsByIdResponses, PutLinksCollectionsByCollectionIdData, PutLinksCollectionsByCollectionIdResponse, PutLinksCollectionsByCollectionIdResponses, PutLinksFoldersByFolderIdIconData, PutLinksFoldersByFolderIdIconResponse, PutLinksFoldersByFolderIdIconResponses, PutLinksItemsByLinkIdData, PutLinksItemsByLinkIdResponse, PutLinksItemsByLinkIdResponses, PutLinksTagsByTagIdData, PutLinksTagsByTagIdResponse, PutLinksTagsByTagIdResponses, PutMonitoringHostsByIdData, PutMonitoringHostsByIdResponse, PutMonitoringHostsByIdResponses, PutMonitoringSshHostsByIdData, PutMonitoringSshHostsByIdResponse, PutMonitoringSshHostsByIdResponses, PutMonitorsByIdData, PutMonitorsByIdResponse, PutMonitorsByIdResponses, PutNotificationsForwardersData, PutNotificationsForwardersResponse, PutNotificationsForwardersResponses, PutNotificationsTopicTokensData, PutNotificationsTopicTokensResponse, PutNotificationsTopicTokensResponses, PutPageConfigData, PutPageConfigResponse, PutPageConfigResponses, GetSessionsCurrentData, GetSessionsCurrentError, GetSessionsCurrentErrors, GetSessionsCurrentResponse, GetSessionsCurrentResponses, PatchSessionsCurrentData, PatchSessionsCurrentError, PatchSessionsCurrentErrors, PatchSessionsCurrentResponse, PatchSessionsCurrentResponses } from './types.gen'; +export { deleteAuthDeleteAccount, deleteIntegrationsById, deleteLinksDevUserLinks, deleteLinksItemsByLinkId, deleteMonitoringHostsById, deleteMonitoringSshHostsById, deleteMonitorsById, deleteNewsFeedRecordsById, deleteNotificationsForwarders, deleteNotificationsTopics, deleteNotificationsTopicTokens, getAppConfig, getAppInfo, getAuthCallback, getAuthSso, getGlanceables, getGlanceablesByIntegration, getIntegrations, getIntegrationsCaldavEvents, getIntegrationsConsumerData, getIntegrationsWidgetProperties, getJobsPullIcons, getJobsShortcuts, getLinksCollections, getLinksFolders, getLinksHome, getLinksHomeGroups, getLinksItems, getLinksTags, getLocations, getMonitoringHosts, getMonitoringHostsById, getMonitoringHostsByIdHistory, getMonitoringHostsByIdStats, getMonitoringSshHosts, getMonitoringStatus, getMonitors, getMonitorsById, getNews, getNewsFeed, getNewsFeedMetadata, getNewsFeedRecordsById, getNewsFeedRefresh, getNewsFeeds, getNewsFeedsById, getNewsSubscriptions, getNewsSubscriptionsByIdJson, getNotifications, getNotificationsForwarders, getNotificationsTopics, getNotificationsTopicTokens, getPageConfig, getPageConfigUserPages, getSessionsCurrent, getShortcuts, getShortcutsFrequentlyUsed, getTestBookmarks, getWallpapers, getWeather, getWidgets, getWidgetsByIntegration, getWidgetsGlanceable, getWidgetsGlanceables, type Options, patchAuthUpdateUserProperty, patchSessionsCurrent, postAuthChangePassword, postAuthLogin, postAuthMfa, postAuthSignup, postAuthValidateAuth, postIntegrations, postIntegrationsConsumerData, postIntegrationsProxyAction, postIntegrationsTestEndpoint, postLinksCollections, postLinksFolders, postLinksHomeGroups, postLinksItems, postLinksReorder, postLinksTags, postMonitoringHosts, postMonitoringHostsByIdRefresh, postMonitoringSshHosts, postMonitoringStatus, postMonitors, postNewsFeedRecords, postNewsFeedRecordsById, postNewsFeedRefresh, postNewsFeedSubscribe, postNewsFeedUnsubscribe, postNewsFeedUpdate, postNewsFixMissingTitles, postNotifications, postNotificationsByTopic, postNotificationsForwarders, postNotificationsForwardersTest, postNotificationsMarkAsRead, postNotificationsTest, postNotificationsTopics, postNotificationsTopicTokens, postPageConfigHome, postPageConfigIntegrationData, postPageConfigMigrateLegacy, postShortcutsApps, postShortcutsUsageStats, postWallpapers, putIntegrationsById, putLinksCollectionsByCollectionId, putLinksFoldersByFolderIdIcon, putLinksItemsByLinkId, putLinksTagsByTagId, putMonitoringHostsById, putMonitoringSshHostsById, putMonitorsById, putNotificationsForwarders, putNotificationsTopicTokens, putPageConfig, putShortcutsOnDemandByAppId } from './sdk.gen'; +export type { ClientOptions, DeleteAuthDeleteAccountData, DeleteAuthDeleteAccountError, DeleteAuthDeleteAccountErrors, DeleteAuthDeleteAccountResponse, DeleteAuthDeleteAccountResponses, DeleteIntegrationsByIdData, DeleteIntegrationsByIdResponse, DeleteIntegrationsByIdResponses, DeleteLinksDevUserLinksData, DeleteLinksDevUserLinksResponse, DeleteLinksDevUserLinksResponses, DeleteLinksItemsByLinkIdData, DeleteLinksItemsByLinkIdResponse, DeleteLinksItemsByLinkIdResponses, DeleteMonitoringHostsByIdData, DeleteMonitoringHostsByIdResponse, DeleteMonitoringHostsByIdResponses, DeleteMonitoringSshHostsByIdData, DeleteMonitoringSshHostsByIdResponse, DeleteMonitoringSshHostsByIdResponses, DeleteMonitorsByIdData, DeleteMonitorsByIdResponse, DeleteMonitorsByIdResponses, DeleteNewsFeedRecordsByIdData, DeleteNewsFeedRecordsByIdResponse, DeleteNewsFeedRecordsByIdResponses, DeleteNotificationsForwardersData, DeleteNotificationsForwardersResponse, DeleteNotificationsForwardersResponses, DeleteNotificationsTopicsData, DeleteNotificationsTopicsResponse, DeleteNotificationsTopicsResponses, DeleteNotificationsTopicTokensData, DeleteNotificationsTopicTokensResponse, DeleteNotificationsTopicTokensResponses, Error, GenericObject, GetAppConfigData, GetAppConfigResponse, GetAppConfigResponses, GetAppInfoData, GetAppInfoResponse, GetAppInfoResponses, GetAuthCallbackData, GetAuthCallbackResponse, GetAuthCallbackResponses, GetAuthSsoData, GetAuthSsoResponse, GetAuthSsoResponses, GetGlanceablesByIntegrationData, GetGlanceablesByIntegrationResponse, GetGlanceablesByIntegrationResponses, GetGlanceablesData, GetGlanceablesResponse, GetGlanceablesResponses, GetIntegrationsCaldavEventsData, GetIntegrationsCaldavEventsResponse, GetIntegrationsCaldavEventsResponses, GetIntegrationsConsumerDataData, GetIntegrationsConsumerDataResponse, GetIntegrationsConsumerDataResponses, GetIntegrationsData, GetIntegrationsResponse, GetIntegrationsResponses, GetIntegrationsWidgetPropertiesData, GetIntegrationsWidgetPropertiesResponse, GetIntegrationsWidgetPropertiesResponses, GetJobsPullIconsData, GetJobsPullIconsError, GetJobsPullIconsErrors, GetJobsPullIconsResponse, GetJobsPullIconsResponses, GetJobsShortcutsData, GetJobsShortcutsResponse, GetJobsShortcutsResponses, GetLinksCollectionsData, GetLinksCollectionsResponse, GetLinksCollectionsResponses, GetLinksFoldersData, GetLinksFoldersResponse, GetLinksFoldersResponses, GetLinksHomeData, GetLinksHomeGroupsData, GetLinksHomeGroupsResponse, GetLinksHomeGroupsResponses, GetLinksHomeResponse, GetLinksHomeResponses, GetLinksItemsData, GetLinksItemsResponse, GetLinksItemsResponses, GetLinksTagsData, GetLinksTagsResponse, GetLinksTagsResponses, GetLocationsData, GetLocationsResponse, GetLocationsResponses, GetMonitoringHostsByIdData, GetMonitoringHostsByIdHistoryData, GetMonitoringHostsByIdHistoryResponse, GetMonitoringHostsByIdHistoryResponses, GetMonitoringHostsByIdResponse, GetMonitoringHostsByIdResponses, GetMonitoringHostsByIdStatsData, GetMonitoringHostsByIdStatsResponse, GetMonitoringHostsByIdStatsResponses, GetMonitoringHostsData, GetMonitoringHostsResponse, GetMonitoringHostsResponses, GetMonitoringSshHostsData, GetMonitoringSshHostsResponse, GetMonitoringSshHostsResponses, GetMonitoringStatusData, GetMonitoringStatusResponse, GetMonitoringStatusResponses, GetMonitorsByIdData, GetMonitorsByIdResponse, GetMonitorsByIdResponses, GetMonitorsData, GetMonitorsResponse, GetMonitorsResponses, GetNewsData, GetNewsFeedData, GetNewsFeedMetadataData, GetNewsFeedMetadataResponse, GetNewsFeedMetadataResponses, GetNewsFeedRecordsByIdData, GetNewsFeedRecordsByIdResponse, GetNewsFeedRecordsByIdResponses, GetNewsFeedRefreshData, GetNewsFeedRefreshResponse, GetNewsFeedRefreshResponses, GetNewsFeedResponse, GetNewsFeedResponses, GetNewsFeedsByIdData, GetNewsFeedsByIdResponse, GetNewsFeedsByIdResponses, GetNewsFeedsData, GetNewsFeedsResponse, GetNewsFeedsResponses, GetNewsResponse, GetNewsResponses, GetNewsSubscriptionsByIdJsonData, GetNewsSubscriptionsByIdJsonResponse, GetNewsSubscriptionsByIdJsonResponses, GetNewsSubscriptionsData, GetNewsSubscriptionsResponse, GetNewsSubscriptionsResponses, GetNotificationsData, GetNotificationsForwardersData, GetNotificationsForwardersResponse, GetNotificationsForwardersResponses, GetNotificationsResponse, GetNotificationsResponses, GetNotificationsTopicsData, GetNotificationsTopicsResponse, GetNotificationsTopicsResponses, GetNotificationsTopicTokensData, GetNotificationsTopicTokensResponse, GetNotificationsTopicTokensResponses, GetPageConfigData, GetPageConfigResponse, GetPageConfigResponses, GetPageConfigUserPagesData, GetPageConfigUserPagesResponse, GetPageConfigUserPagesResponses, GetSessionsCurrentData, GetSessionsCurrentError, GetSessionsCurrentErrors, GetSessionsCurrentResponse, GetSessionsCurrentResponses, GetShortcutsData, GetShortcutsFrequentlyUsedData, GetShortcutsFrequentlyUsedResponse, GetShortcutsFrequentlyUsedResponses, GetShortcutsResponse, GetShortcutsResponses, GetTestBookmarksData, GetTestBookmarksResponse, GetTestBookmarksResponses, GetWallpapersData, GetWallpapersResponse, GetWallpapersResponses, GetWeatherData, GetWeatherResponse, GetWeatherResponses, GetWidgetsByIntegrationData, GetWidgetsByIntegrationResponse, GetWidgetsByIntegrationResponses, GetWidgetsData, GetWidgetsGlanceableData, GetWidgetsGlanceableResponse, GetWidgetsGlanceableResponses, GetWidgetsGlanceablesData, GetWidgetsGlanceablesResponse, GetWidgetsGlanceablesResponses, GetWidgetsResponse, GetWidgetsResponses, Id, JsonBody, PatchAuthUpdateUserPropertyData, PatchAuthUpdateUserPropertyError, PatchAuthUpdateUserPropertyErrors, PatchAuthUpdateUserPropertyResponse, PatchAuthUpdateUserPropertyResponses, PatchSessionsCurrentData, PatchSessionsCurrentError, PatchSessionsCurrentErrors, PatchSessionsCurrentResponse, PatchSessionsCurrentResponses, PostAuthChangePasswordData, PostAuthChangePasswordError, PostAuthChangePasswordErrors, PostAuthChangePasswordResponse, PostAuthChangePasswordResponses, PostAuthLoginData, PostAuthLoginError, PostAuthLoginErrors, PostAuthLoginResponse, PostAuthLoginResponses, PostAuthMfaData, PostAuthMfaError, PostAuthMfaErrors, PostAuthMfaResponse, PostAuthMfaResponses, PostAuthSignupData, PostAuthSignupError, PostAuthSignupErrors, PostAuthSignupResponse, PostAuthSignupResponses, PostAuthValidateAuthData, PostAuthValidateAuthError, PostAuthValidateAuthErrors, PostAuthValidateAuthResponse, PostAuthValidateAuthResponses, PostIntegrationsConsumerDataData, PostIntegrationsConsumerDataResponse, PostIntegrationsConsumerDataResponses, PostIntegrationsData, PostIntegrationsProxyActionData, PostIntegrationsProxyActionResponse, PostIntegrationsProxyActionResponses, PostIntegrationsResponse, PostIntegrationsResponses, PostIntegrationsTestEndpointData, PostIntegrationsTestEndpointResponse, PostIntegrationsTestEndpointResponses, PostLinksCollectionsData, PostLinksCollectionsResponse, PostLinksCollectionsResponses, PostLinksFoldersData, PostLinksFoldersResponse, PostLinksFoldersResponses, PostLinksHomeGroupsData, PostLinksHomeGroupsResponse, PostLinksHomeGroupsResponses, PostLinksItemsData, PostLinksItemsResponse, PostLinksItemsResponses, PostLinksReorderData, PostLinksReorderResponse, PostLinksReorderResponses, PostLinksTagsData, PostLinksTagsResponse, PostLinksTagsResponses, PostMonitoringHostsByIdRefreshData, PostMonitoringHostsByIdRefreshError, PostMonitoringHostsByIdRefreshErrors, PostMonitoringHostsByIdRefreshResponse, PostMonitoringHostsByIdRefreshResponses, PostMonitoringHostsData, PostMonitoringHostsResponse, PostMonitoringHostsResponses, PostMonitoringSshHostsData, PostMonitoringSshHostsResponse, PostMonitoringSshHostsResponses, PostMonitoringStatusData, PostMonitoringStatusError, PostMonitoringStatusErrors, PostMonitoringStatusResponse, PostMonitoringStatusResponses, PostMonitorsData, PostMonitorsResponse, PostMonitorsResponses, PostNewsFeedRecordsByIdData, PostNewsFeedRecordsByIdResponse, PostNewsFeedRecordsByIdResponses, PostNewsFeedRecordsData, PostNewsFeedRecordsResponse, PostNewsFeedRecordsResponses, PostNewsFeedRefreshData, PostNewsFeedRefreshResponse, PostNewsFeedRefreshResponses, PostNewsFeedSubscribeData, PostNewsFeedSubscribeResponse, PostNewsFeedSubscribeResponses, PostNewsFeedUnsubscribeData, PostNewsFeedUnsubscribeResponse, PostNewsFeedUnsubscribeResponses, PostNewsFeedUpdateData, PostNewsFeedUpdateResponse, PostNewsFeedUpdateResponses, PostNewsFixMissingTitlesData, PostNewsFixMissingTitlesResponse, PostNewsFixMissingTitlesResponses, PostNotificationsByTopicData, PostNotificationsByTopicResponse, PostNotificationsByTopicResponses, PostNotificationsData, PostNotificationsForwardersData, PostNotificationsForwardersResponse, PostNotificationsForwardersResponses, PostNotificationsForwardersTestData, PostNotificationsForwardersTestResponse, PostNotificationsForwardersTestResponses, PostNotificationsMarkAsReadData, PostNotificationsMarkAsReadResponse, PostNotificationsMarkAsReadResponses, PostNotificationsResponse, PostNotificationsResponses, PostNotificationsTestData, PostNotificationsTestResponse, PostNotificationsTestResponses, PostNotificationsTopicsData, PostNotificationsTopicsResponse, PostNotificationsTopicsResponses, PostNotificationsTopicTokensData, PostNotificationsTopicTokensResponse, PostNotificationsTopicTokensResponses, PostPageConfigHomeData, PostPageConfigHomeResponse, PostPageConfigHomeResponses, PostPageConfigIntegrationDataData, PostPageConfigIntegrationDataResponse, PostPageConfigIntegrationDataResponses, PostPageConfigMigrateLegacyData, PostPageConfigMigrateLegacyResponse, PostPageConfigMigrateLegacyResponses, PostShortcutsAppsData, PostShortcutsAppsError, PostShortcutsAppsErrors, PostShortcutsAppsResponse, PostShortcutsAppsResponses, PostShortcutsUsageStatsData, PostShortcutsUsageStatsResponse, PostShortcutsUsageStatsResponses, PostWallpapersData, PostWallpapersResponse, PostWallpapersResponses, PutIntegrationsByIdData, PutIntegrationsByIdResponse, PutIntegrationsByIdResponses, PutLinksCollectionsByCollectionIdData, PutLinksCollectionsByCollectionIdResponse, PutLinksCollectionsByCollectionIdResponses, PutLinksFoldersByFolderIdIconData, PutLinksFoldersByFolderIdIconResponse, PutLinksFoldersByFolderIdIconResponses, PutLinksItemsByLinkIdData, PutLinksItemsByLinkIdResponse, PutLinksItemsByLinkIdResponses, PutLinksTagsByTagIdData, PutLinksTagsByTagIdResponse, PutLinksTagsByTagIdResponses, PutMonitoringHostsByIdData, PutMonitoringHostsByIdResponse, PutMonitoringHostsByIdResponses, PutMonitoringSshHostsByIdData, PutMonitoringSshHostsByIdResponse, PutMonitoringSshHostsByIdResponses, PutMonitorsByIdData, PutMonitorsByIdResponse, PutMonitorsByIdResponses, PutNotificationsForwardersData, PutNotificationsForwardersResponse, PutNotificationsForwardersResponses, PutNotificationsTopicTokensData, PutNotificationsTopicTokensResponse, PutNotificationsTopicTokensResponses, PutPageConfigData, PutPageConfigResponse, PutPageConfigResponses, PutShortcutsOnDemandByAppIdData, PutShortcutsOnDemandByAppIdError, PutShortcutsOnDemandByAppIdErrors, PutShortcutsOnDemandByAppIdResponse, PutShortcutsOnDemandByAppIdResponses } from './types.gen'; diff --git a/apps/web/src/lib/api/sdk.gen.ts b/apps/web/src/lib/api/sdk.gen.ts index a7bc387a..8e4d932e 100644 --- a/apps/web/src/lib/api/sdk.gen.ts +++ b/apps/web/src/lib/api/sdk.gen.ts @@ -1,9 +1,8 @@ -// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; import { client } from './client.gen'; -import type { DeleteAuthDeleteAccountData, DeleteAuthDeleteAccountErrors, DeleteAuthDeleteAccountResponses, DeleteIntegrationsByIdData, DeleteIntegrationsByIdResponses, DeleteLinksDevUserLinksData, DeleteLinksDevUserLinksResponses, DeleteLinksItemsByLinkIdData, DeleteLinksItemsByLinkIdResponses, DeleteMonitoringHostsByIdData, DeleteMonitoringHostsByIdResponses, DeleteMonitoringSshHostsByIdData, DeleteMonitoringSshHostsByIdResponses, DeleteMonitorsByIdData, DeleteMonitorsByIdResponses, DeleteNewsFeedRecordsByIdData, DeleteNewsFeedRecordsByIdResponses, DeleteNotificationsForwardersData, DeleteNotificationsForwardersResponses, DeleteNotificationsTopicsData, DeleteNotificationsTopicsResponses, DeleteNotificationsTopicTokensData, DeleteNotificationsTopicTokensResponses, GetAppConfigData, GetAppConfigResponses, GetAppInfoData, GetAppInfoResponses, GetAuthCallbackData, GetAuthCallbackResponses, GetAuthSsoData, GetAuthSsoResponses, GetGlanceablesByIntegrationData, GetGlanceablesByIntegrationResponses, GetGlanceablesData, GetGlanceablesResponses, GetIntegrationsCaldavEventsData, GetIntegrationsCaldavEventsResponses, GetIntegrationsConsumerDataData, GetIntegrationsConsumerDataResponses, GetIntegrationsData, GetIntegrationsResponses, GetIntegrationsWidgetPropertiesData, GetIntegrationsWidgetPropertiesResponses, GetJobsPullIconsData, GetJobsPullIconsErrors, GetJobsPullIconsResponses, GetJobsSearchItemsData, GetJobsSearchItemsResponses, GetLinksCollectionsData, GetLinksCollectionsResponses, GetLinksFoldersData, GetLinksFoldersResponses, GetLinksHomeData, GetLinksHomeGroupsData, GetLinksHomeGroupsResponses, GetLinksHomeResponses, GetLinksItemsData, GetLinksItemsResponses, GetLinksTagsData, GetLinksTagsResponses, GetLocationsData, GetLocationsResponses, GetMonitoringHostsByIdData, GetMonitoringHostsByIdHistoryData, GetMonitoringHostsByIdHistoryResponses, GetMonitoringHostsByIdResponses, GetMonitoringHostsByIdStatsData, GetMonitoringHostsByIdStatsResponses, GetMonitoringHostsData, GetMonitoringHostsResponses, GetMonitoringSshHostsData, GetMonitoringSshHostsResponses, GetMonitoringStatusData, GetMonitoringStatusResponses, GetMonitorsByIdData, GetMonitorsByIdResponses, GetMonitorsData, GetMonitorsResponses, GetNewsData, GetNewsFeedData, GetNewsFeedMetadataData, GetNewsFeedMetadataResponses, GetNewsFeedRecordsByIdData, GetNewsFeedRecordsByIdResponses, GetNewsFeedRefreshData, GetNewsFeedRefreshResponses, GetNewsFeedResponses, GetNewsFeedsByIdData, GetNewsFeedsByIdResponses, GetNewsFeedsData, GetNewsFeedsResponses, GetNewsResponses, GetNewsSubscriptionsByIdJsonData, GetNewsSubscriptionsByIdJsonResponses, GetNewsSubscriptionsData, GetNewsSubscriptionsResponses, GetNotificationsData, GetNotificationsForwardersData, GetNotificationsForwardersResponses, GetNotificationsResponses, GetNotificationsTopicsData, GetNotificationsTopicsResponses, GetNotificationsTopicTokensData, GetNotificationsTopicTokensResponses, GetPageConfigData, GetPageConfigResponses, GetPageConfigUserPagesData, GetPageConfigUserPagesResponses, GetSearchItemsData, GetSearchItemsFrequentlyUsedData, GetSearchItemsFrequentlyUsedResponses, GetSearchItemsResponses, GetTestBookmarksData, GetTestBookmarksResponses, GetWallpapersData, GetWallpapersResponses, GetWeatherData, GetWeatherResponses, GetWidgetsByIntegrationData, GetWidgetsByIntegrationResponses, GetWidgetsData, GetWidgetsGlanceableData, GetWidgetsGlanceableResponses, GetWidgetsGlanceablesData, GetWidgetsGlanceablesResponses, GetWidgetsResponses, PatchAuthUpdateUserPropertyData, PatchAuthUpdateUserPropertyErrors, PatchAuthUpdateUserPropertyResponses, PostAuthChangePasswordData, PostAuthChangePasswordErrors, PostAuthChangePasswordResponses, PostAuthLoginData, PostAuthLoginErrors, PostAuthLoginResponses, PostAuthMfaData, PostAuthMfaErrors, PostAuthMfaResponses, PostAuthSignupData, PostAuthSignupErrors, PostAuthSignupResponses, PostAuthValidateAuthData, PostAuthValidateAuthErrors, PostAuthValidateAuthResponses, PostIntegrationsConsumerDataData, PostIntegrationsConsumerDataResponses, PostIntegrationsData, PostIntegrationsProxyActionData, PostIntegrationsProxyActionResponses, PostIntegrationsResponses, PostIntegrationsTestEndpointData, PostIntegrationsTestEndpointResponses, PostLinksCollectionsData, PostLinksCollectionsResponses, PostLinksFoldersData, PostLinksFoldersResponses, PostLinksHomeGroupsData, PostLinksHomeGroupsResponses, PostLinksItemsData, PostLinksItemsResponses, PostLinksReorderData, PostLinksReorderResponses, PostLinksTagsData, PostLinksTagsResponses, PostMonitoringHostsByIdRefreshData, PostMonitoringHostsByIdRefreshErrors, PostMonitoringHostsByIdRefreshResponses, PostMonitoringHostsData, PostMonitoringHostsResponses, PostMonitoringSshHostsData, PostMonitoringSshHostsResponses, PostMonitoringStatusData, PostMonitoringStatusErrors, PostMonitoringStatusResponses, PostMonitorsData, PostMonitorsResponses, PostNewsFeedRecordsByIdData, PostNewsFeedRecordsByIdResponses, PostNewsFeedRecordsData, PostNewsFeedRecordsResponses, PostNewsFeedRefreshData, PostNewsFeedRefreshResponses, PostNewsFeedSubscribeData, PostNewsFeedSubscribeResponses, PostNewsFeedUnsubscribeData, PostNewsFeedUnsubscribeResponses, PostNewsFeedUpdateData, PostNewsFeedUpdateResponses, PostNewsFixMissingTitlesData, PostNewsFixMissingTitlesResponses, PostNotificationsByTopicData, PostNotificationsByTopicResponses, PostNotificationsData, PostNotificationsForwardersData, PostNotificationsForwardersResponses, PostNotificationsForwardersTestData, PostNotificationsForwardersTestResponses, PostNotificationsMarkAsReadData, PostNotificationsMarkAsReadResponses, PostNotificationsResponses, PostNotificationsTestData, PostNotificationsTestResponses, PostNotificationsTopicsData, PostNotificationsTopicsResponses, PostNotificationsTopicTokensData, PostNotificationsTopicTokensResponses, PostPageConfigHomeData, PostPageConfigHomeResponses, PostPageConfigIntegrationDataData, PostPageConfigIntegrationDataResponses, PostPageConfigMigrateLegacyData, PostPageConfigMigrateLegacyResponses, PostSearchItemsUsageStatsData, PostSearchItemsUsageStatsResponses, PostWallpapersData, PostWallpapersResponses, PutIntegrationsByIdData, PutIntegrationsByIdResponses, PutLinksCollectionsByCollectionIdData, PutLinksCollectionsByCollectionIdResponses, PutLinksFoldersByFolderIdIconData, PutLinksFoldersByFolderIdIconResponses, PutLinksItemsByLinkIdData, PutLinksItemsByLinkIdResponses, PutLinksTagsByTagIdData, PutLinksTagsByTagIdResponses, PutMonitoringHostsByIdData, PutMonitoringHostsByIdResponses, PutMonitoringSshHostsByIdData, PutMonitoringSshHostsByIdResponses, PutMonitorsByIdData, PutMonitorsByIdResponses, PutNotificationsForwardersData, PutNotificationsForwardersResponses, PutNotificationsTopicTokensData, PutNotificationsTopicTokensResponses, PutPageConfigData, PutPageConfigResponses, GetSessionsCurrentData, GetSessionsCurrentErrors, GetSessionsCurrentResponses, PatchSessionsCurrentData, PatchSessionsCurrentErrors, PatchSessionsCurrentResponses } from './types.gen'; +import type { DeleteAuthDeleteAccountData, DeleteAuthDeleteAccountErrors, DeleteAuthDeleteAccountResponses, DeleteIntegrationsByIdData, DeleteIntegrationsByIdResponses, DeleteLinksDevUserLinksData, DeleteLinksDevUserLinksResponses, DeleteLinksItemsByLinkIdData, DeleteLinksItemsByLinkIdResponses, DeleteMonitoringHostsByIdData, DeleteMonitoringHostsByIdResponses, DeleteMonitoringSshHostsByIdData, DeleteMonitoringSshHostsByIdResponses, DeleteMonitorsByIdData, DeleteMonitorsByIdResponses, DeleteNewsFeedRecordsByIdData, DeleteNewsFeedRecordsByIdResponses, DeleteNotificationsForwardersData, DeleteNotificationsForwardersResponses, DeleteNotificationsTopicsData, DeleteNotificationsTopicsResponses, DeleteNotificationsTopicTokensData, DeleteNotificationsTopicTokensResponses, GetAppConfigData, GetAppConfigResponses, GetAppInfoData, GetAppInfoResponses, GetAuthCallbackData, GetAuthCallbackResponses, GetAuthSsoData, GetAuthSsoResponses, GetGlanceablesByIntegrationData, GetGlanceablesByIntegrationResponses, GetGlanceablesData, GetGlanceablesResponses, GetIntegrationsCaldavEventsData, GetIntegrationsCaldavEventsResponses, GetIntegrationsConsumerDataData, GetIntegrationsConsumerDataResponses, GetIntegrationsData, GetIntegrationsResponses, GetIntegrationsWidgetPropertiesData, GetIntegrationsWidgetPropertiesResponses, GetJobsPullIconsData, GetJobsPullIconsErrors, GetJobsPullIconsResponses, GetJobsShortcutsData, GetJobsShortcutsResponses, GetLinksCollectionsData, GetLinksCollectionsResponses, GetLinksFoldersData, GetLinksFoldersResponses, GetLinksHomeData, GetLinksHomeGroupsData, GetLinksHomeGroupsResponses, GetLinksHomeResponses, GetLinksItemsData, GetLinksItemsResponses, GetLinksTagsData, GetLinksTagsResponses, GetLocationsData, GetLocationsResponses, GetMonitoringHostsByIdData, GetMonitoringHostsByIdHistoryData, GetMonitoringHostsByIdHistoryResponses, GetMonitoringHostsByIdResponses, GetMonitoringHostsByIdStatsData, GetMonitoringHostsByIdStatsResponses, GetMonitoringHostsData, GetMonitoringHostsResponses, GetMonitoringSshHostsData, GetMonitoringSshHostsResponses, GetMonitoringStatusData, GetMonitoringStatusResponses, GetMonitorsByIdData, GetMonitorsByIdResponses, GetMonitorsData, GetMonitorsResponses, GetNewsData, GetNewsFeedData, GetNewsFeedMetadataData, GetNewsFeedMetadataResponses, GetNewsFeedRecordsByIdData, GetNewsFeedRecordsByIdResponses, GetNewsFeedRefreshData, GetNewsFeedRefreshResponses, GetNewsFeedResponses, GetNewsFeedsByIdData, GetNewsFeedsByIdResponses, GetNewsFeedsData, GetNewsFeedsResponses, GetNewsResponses, GetNewsSubscriptionsByIdJsonData, GetNewsSubscriptionsByIdJsonResponses, GetNewsSubscriptionsData, GetNewsSubscriptionsResponses, GetNotificationsData, GetNotificationsForwardersData, GetNotificationsForwardersResponses, GetNotificationsResponses, GetNotificationsTopicsData, GetNotificationsTopicsResponses, GetNotificationsTopicTokensData, GetNotificationsTopicTokensResponses, GetPageConfigData, GetPageConfigResponses, GetPageConfigUserPagesData, GetPageConfigUserPagesResponses, GetSessionsCurrentData, GetSessionsCurrentErrors, GetSessionsCurrentResponses, GetShortcutsData, GetShortcutsFrequentlyUsedData, GetShortcutsFrequentlyUsedResponses, GetShortcutsResponses, GetTestBookmarksData, GetTestBookmarksResponses, GetWallpapersData, GetWallpapersResponses, GetWeatherData, GetWeatherResponses, GetWidgetsByIntegrationData, GetWidgetsByIntegrationResponses, GetWidgetsData, GetWidgetsGlanceableData, GetWidgetsGlanceableResponses, GetWidgetsGlanceablesData, GetWidgetsGlanceablesResponses, GetWidgetsResponses, PatchAuthUpdateUserPropertyData, PatchAuthUpdateUserPropertyErrors, PatchAuthUpdateUserPropertyResponses, PatchSessionsCurrentData, PatchSessionsCurrentErrors, PatchSessionsCurrentResponses, PostAuthChangePasswordData, PostAuthChangePasswordErrors, PostAuthChangePasswordResponses, PostAuthLoginData, PostAuthLoginErrors, PostAuthLoginResponses, PostAuthMfaData, PostAuthMfaErrors, PostAuthMfaResponses, PostAuthSignupData, PostAuthSignupErrors, PostAuthSignupResponses, PostAuthValidateAuthData, PostAuthValidateAuthErrors, PostAuthValidateAuthResponses, PostIntegrationsConsumerDataData, PostIntegrationsConsumerDataResponses, PostIntegrationsData, PostIntegrationsProxyActionData, PostIntegrationsProxyActionResponses, PostIntegrationsResponses, PostIntegrationsTestEndpointData, PostIntegrationsTestEndpointResponses, PostLinksCollectionsData, PostLinksCollectionsResponses, PostLinksFoldersData, PostLinksFoldersResponses, PostLinksHomeGroupsData, PostLinksHomeGroupsResponses, PostLinksItemsData, PostLinksItemsResponses, PostLinksReorderData, PostLinksReorderResponses, PostLinksTagsData, PostLinksTagsResponses, PostMonitoringHostsByIdRefreshData, PostMonitoringHostsByIdRefreshErrors, PostMonitoringHostsByIdRefreshResponses, PostMonitoringHostsData, PostMonitoringHostsResponses, PostMonitoringSshHostsData, PostMonitoringSshHostsResponses, PostMonitoringStatusData, PostMonitoringStatusErrors, PostMonitoringStatusResponses, PostMonitorsData, PostMonitorsResponses, PostNewsFeedRecordsByIdData, PostNewsFeedRecordsByIdResponses, PostNewsFeedRecordsData, PostNewsFeedRecordsResponses, PostNewsFeedRefreshData, PostNewsFeedRefreshResponses, PostNewsFeedSubscribeData, PostNewsFeedSubscribeResponses, PostNewsFeedUnsubscribeData, PostNewsFeedUnsubscribeResponses, PostNewsFeedUpdateData, PostNewsFeedUpdateResponses, PostNewsFixMissingTitlesData, PostNewsFixMissingTitlesResponses, PostNotificationsByTopicData, PostNotificationsByTopicResponses, PostNotificationsData, PostNotificationsForwardersData, PostNotificationsForwardersResponses, PostNotificationsForwardersTestData, PostNotificationsForwardersTestResponses, PostNotificationsMarkAsReadData, PostNotificationsMarkAsReadResponses, PostNotificationsResponses, PostNotificationsTestData, PostNotificationsTestResponses, PostNotificationsTopicsData, PostNotificationsTopicsResponses, PostNotificationsTopicTokensData, PostNotificationsTopicTokensResponses, PostPageConfigHomeData, PostPageConfigHomeResponses, PostPageConfigIntegrationDataData, PostPageConfigIntegrationDataResponses, PostPageConfigMigrateLegacyData, PostPageConfigMigrateLegacyResponses, PostShortcutsAppsData, PostShortcutsAppsErrors, PostShortcutsAppsResponses, PostShortcutsUsageStatsData, PostShortcutsUsageStatsResponses, PostWallpapersData, PostWallpapersResponses, PutIntegrationsByIdData, PutIntegrationsByIdResponses, PutLinksCollectionsByCollectionIdData, PutLinksCollectionsByCollectionIdResponses, PutLinksFoldersByFolderIdIconData, PutLinksFoldersByFolderIdIconResponses, PutLinksItemsByLinkIdData, PutLinksItemsByLinkIdResponses, PutLinksTagsByTagIdData, PutLinksTagsByTagIdResponses, PutMonitoringHostsByIdData, PutMonitoringHostsByIdResponses, PutMonitoringSshHostsByIdData, PutMonitoringSshHostsByIdResponses, PutMonitorsByIdData, PutMonitorsByIdResponses, PutNotificationsForwardersData, PutNotificationsForwardersResponses, PutNotificationsTopicTokensData, PutNotificationsTopicTokensResponses, PutPageConfigData, PutPageConfigResponses, PutShortcutsOnDemandByAppIdData, PutShortcutsOnDemandByAppIdErrors, PutShortcutsOnDemandByAppIdResponses } from './types.gen'; export type Options = Options2 & { /** @@ -294,9 +293,9 @@ export const postLinksTags = (options?: Op }); /** - * Search items job + * Shortcuts indexing job */ -export const getJobsSearchItems = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/jobs/searchItems', ...options }); +export const getJobsShortcuts = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/jobs/shortcuts', ...options }); /** * Pull icons job @@ -642,9 +641,9 @@ export const putNotificationsTopicTokens = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/searchItems', ...options }); +export const getShortcuts = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/shortcuts', ...options }); /** * Get page config @@ -976,15 +975,39 @@ export const postNotificationsTest = (opti }); /** - * List frequently used search items + * Create an on-demand shortcut app */ -export const getSearchItemsFrequentlyUsed = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/searchItems/frequentlyUsed', ...options }); +export const postShortcutsApps = (options?: Options): RequestResult => (options?.client ?? client).post({ + url: '/shortcuts/apps', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Replace an on-demand app's shortcuts + */ +export const putShortcutsOnDemandByAppId = (options: Options): RequestResult => (options.client ?? client).put({ + url: '/shortcuts/on-demand/{appId}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * List frequently used shortcuts + */ +export const getShortcutsFrequentlyUsed = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/shortcuts/frequentlyUsed', ...options }); /** - * Log search item usage + * Log shortcut usage */ -export const postSearchItemsUsageStats = (options?: Options): RequestResult => (options?.client ?? client).post({ - url: '/searchItems/usageStats', +export const postShortcutsUsageStats = (options?: Options): RequestResult => (options?.client ?? client).post({ + url: '/shortcuts/usageStats', ...options, headers: { 'Content-Type': 'application/json', diff --git a/apps/web/src/lib/api/types.gen.ts b/apps/web/src/lib/api/types.gen.ts index ebc2ebc6..5ba09df8 100644 --- a/apps/web/src/lib/api/types.gen.ts +++ b/apps/web/src/lib/api/types.gen.ts @@ -618,21 +618,21 @@ export type PostLinksTagsResponses = { export type PostLinksTagsResponse = PostLinksTagsResponses[keyof PostLinksTagsResponses]; -export type GetJobsSearchItemsData = { +export type GetJobsShortcutsData = { body?: never; path?: never; query?: never; - url: '/jobs/searchItems'; + url: '/jobs/shortcuts'; }; -export type GetJobsSearchItemsResponses = { +export type GetJobsShortcutsResponses = { /** * OK */ 200: GenericObject; }; -export type GetJobsSearchItemsResponse = GetJobsSearchItemsResponses[keyof GetJobsSearchItemsResponses]; +export type GetJobsShortcutsResponse = GetJobsShortcutsResponses[keyof GetJobsShortcutsResponses]; export type GetJobsPullIconsData = { body?: never; @@ -1319,21 +1319,21 @@ export type PutNotificationsTopicTokensResponses = { export type PutNotificationsTopicTokensResponse = PutNotificationsTopicTokensResponses[keyof PutNotificationsTopicTokensResponses]; -export type GetSearchItemsData = { +export type GetShortcutsData = { body?: never; path?: never; query?: never; - url: '/searchItems'; + url: '/shortcuts'; }; -export type GetSearchItemsResponses = { +export type GetShortcutsResponses = { /** * OK */ 200: GenericObject; }; -export type GetSearchItemsResponse = GetSearchItemsResponses[keyof GetSearchItemsResponses]; +export type GetShortcutsResponse = GetShortcutsResponses[keyof GetShortcutsResponses]; export type GetPageConfigData = { body?: never; @@ -2060,34 +2060,102 @@ export type PostNotificationsTestResponses = { export type PostNotificationsTestResponse = PostNotificationsTestResponses[keyof PostNotificationsTestResponses]; -export type GetSearchItemsFrequentlyUsedData = { +export type PostShortcutsAppsData = { + body?: JsonBody; + path?: never; + query?: never; + url: '/shortcuts/apps'; +}; + +export type PostShortcutsAppsErrors = { + /** + * Bad Request + */ + 400: Error; + /** + * Unauthorized + */ + 401: Error; +}; + +export type PostShortcutsAppsError = PostShortcutsAppsErrors[keyof PostShortcutsAppsErrors]; + +export type PostShortcutsAppsResponses = { + /** + * OK + */ + 200: GenericObject; +}; + +export type PostShortcutsAppsResponse = PostShortcutsAppsResponses[keyof PostShortcutsAppsResponses]; + +export type PutShortcutsOnDemandByAppIdData = { + body?: JsonBody; + path: { + appId: string; + }; + query?: never; + url: '/shortcuts/on-demand/{appId}'; +}; + +export type PutShortcutsOnDemandByAppIdErrors = { + /** + * Bad Request + */ + 400: Error; + /** + * Unauthorized + */ + 401: Error; + /** + * Not Found + */ + 404: Error; + /** + * Conflict + */ + 409: Error; +}; + +export type PutShortcutsOnDemandByAppIdError = PutShortcutsOnDemandByAppIdErrors[keyof PutShortcutsOnDemandByAppIdErrors]; + +export type PutShortcutsOnDemandByAppIdResponses = { + /** + * OK + */ + 200: GenericObject; +}; + +export type PutShortcutsOnDemandByAppIdResponse = PutShortcutsOnDemandByAppIdResponses[keyof PutShortcutsOnDemandByAppIdResponses]; + +export type GetShortcutsFrequentlyUsedData = { body?: never; path?: never; query?: never; - url: '/searchItems/frequentlyUsed'; + url: '/shortcuts/frequentlyUsed'; }; -export type GetSearchItemsFrequentlyUsedResponses = { +export type GetShortcutsFrequentlyUsedResponses = { /** * OK */ 200: GenericObject; }; -export type GetSearchItemsFrequentlyUsedResponse = GetSearchItemsFrequentlyUsedResponses[keyof GetSearchItemsFrequentlyUsedResponses]; +export type GetShortcutsFrequentlyUsedResponse = GetShortcutsFrequentlyUsedResponses[keyof GetShortcutsFrequentlyUsedResponses]; -export type PostSearchItemsUsageStatsData = { +export type PostShortcutsUsageStatsData = { body?: JsonBody; path?: never; query?: never; - url: '/searchItems/usageStats'; + url: '/shortcuts/usageStats'; }; -export type PostSearchItemsUsageStatsResponses = { +export type PostShortcutsUsageStatsResponses = { /** * OK */ 200: GenericObject; }; -export type PostSearchItemsUsageStatsResponse = PostSearchItemsUsageStatsResponses[keyof PostSearchItemsUsageStatsResponses]; +export type PostShortcutsUsageStatsResponse = PostShortcutsUsageStatsResponses[keyof PostShortcutsUsageStatsResponses]; diff --git a/apps/web/src/lib/apiClient.ts b/apps/web/src/lib/apiClient.ts index ffe5fb8a..af706eb6 100644 --- a/apps/web/src/lib/apiClient.ts +++ b/apps/web/src/lib/apiClient.ts @@ -20,7 +20,7 @@ import { getClientSessionHeaders } from "@/lib/session"; import { client } from "./api/client.gen"; import * as sdk from "./api/sdk.gen"; -const { getAppConfig, getAppInfo, postAuthLogin, postAuthChangePassword, postAuthSignup, postAuthValidateAuth, deleteAuthDeleteAccount, patchAuthUpdateUserProperty, getLinksCollections, postLinksCollections, putLinksCollectionsByCollectionId, postLinksTags, putLinksTagsByTagId, getLinksHomeGroups, postLinksHomeGroups, putLinksFoldersByFolderIdIcon, getLinksHome, getLinksFolders, postLinksFolders, getLinksItems, getLinksTags, postLinksItems, putLinksItemsByLinkId, deleteLinksItemsByLinkId, postLinksReorder, getIntegrations, postIntegrations, putIntegrationsById, deleteIntegrationsById, postIntegrationsTestEndpoint, getIntegrationsWidgetProperties, getWidgetsByIntegration, postIntegrationsConsumerData, getIntegrationsCaldavEvents, postIntegrationsProxyAction, getWidgets, getGlanceables, getGlanceablesByIntegration, getMonitoringStatus, postMonitoringStatus, getMonitoringSshHosts, postMonitoringSshHosts, putMonitoringSshHostsById, getMonitoringHosts, postMonitoringHosts, getMonitoringHostsByIdHistory, getMonitors, getMonitorsById, putMonitorsById, postMonitors, deleteMonitorsById, getNewsFeedRecordsById, postNewsFeedRecords, getNewsSubscriptions, getNewsFeeds, getNewsFeedMetadata, postNewsFeedRefresh, postNewsFeedSubscribe, postNewsFeedUnsubscribe, postNewsFeedUpdate, postNewsFeedRecordsById, postNewsFixMissingTitles, getPageConfig, getPageConfigUserPages, putPageConfig, postPageConfigHome, postPageConfigMigrateLegacy, postPageConfigIntegrationData, getSearchItems, getSearchItemsFrequentlyUsed, postSearchItemsUsageStats, getLocations, getJobsPullIcons, postWallpapers, getNotifications, getNotificationsTopics, postNotificationsTopics, deleteNotificationsTopics, postNotificationsMarkAsRead, postNotificationsTest, getNotificationsTopicTokens, postNotificationsTopicTokens, deleteNotificationsTopicTokens, putNotificationsTopicTokens, getNotificationsForwarders, postNotificationsForwarders, putNotificationsForwarders, deleteNotificationsForwarders, postNotificationsForwardersTest } = sdk; +const { getAppConfig, getAppInfo, postAuthLogin, postAuthChangePassword, postAuthSignup, postAuthValidateAuth, deleteAuthDeleteAccount, patchAuthUpdateUserProperty, getLinksCollections, postLinksCollections, putLinksCollectionsByCollectionId, postLinksTags, putLinksTagsByTagId, getLinksHomeGroups, postLinksHomeGroups, putLinksFoldersByFolderIdIcon, getLinksHome, getLinksFolders, postLinksFolders, getLinksItems, getLinksTags, postLinksItems, putLinksItemsByLinkId, deleteLinksItemsByLinkId, postLinksReorder, getIntegrations, postIntegrations, putIntegrationsById, deleteIntegrationsById, postIntegrationsTestEndpoint, getIntegrationsWidgetProperties, getWidgetsByIntegration, postIntegrationsConsumerData, getIntegrationsCaldavEvents, postIntegrationsProxyAction, getWidgets, getGlanceables, getGlanceablesByIntegration, getMonitoringStatus, postMonitoringStatus, getMonitoringSshHosts, postMonitoringSshHosts, putMonitoringSshHostsById, getMonitoringHosts, postMonitoringHosts, getMonitoringHostsByIdHistory, getMonitors, getMonitorsById, putMonitorsById, postMonitors, deleteMonitorsById, getNewsFeedRecordsById, postNewsFeedRecords, getNewsSubscriptions, getNewsFeeds, getNewsFeedMetadata, postNewsFeedRefresh, postNewsFeedSubscribe, postNewsFeedUnsubscribe, postNewsFeedUpdate, postNewsFeedRecordsById, postNewsFixMissingTitles, getPageConfig, getPageConfigUserPages, putPageConfig, postPageConfigHome, postPageConfigMigrateLegacy, postPageConfigIntegrationData, getShortcuts, getShortcutsFrequentlyUsed, postShortcutsUsageStats, getLocations, getJobsPullIcons, getNotifications, getNotificationsTopics, postNotificationsTopics, deleteNotificationsTopics, postNotificationsMarkAsRead, postNotificationsTest, getNotificationsTopicTokens, postNotificationsTopicTokens, deleteNotificationsTopicTokens, putNotificationsTopicTokens, getNotificationsForwarders, postNotificationsForwarders, putNotificationsForwarders, deleteNotificationsForwarders, postNotificationsForwardersTest } = sdk; export * from "./api/sdk.gen"; export type { GenericObject, Error } from "./api/types.gen"; @@ -431,8 +431,8 @@ export async function getIntegrationCalendarEventsAction(auth: ActionAuth, integ return extractData(await getIntegrationsCaldavEvents({ query: { integrationId }, headers: authHeaders(auth) })); } -export async function proxyIntegrationAction(auth: ActionAuth, searchItemId: string) { - return extractData(await postIntegrationsProxyAction({ body: { auth, searchItemId }, headers: authHeaders(auth) })); +export async function proxyIntegrationAction(auth: ActionAuth, shortcutId: string) { + return extractData(await postIntegrationsProxyAction({ body: { auth, shortcutId }, headers: authHeaders(auth) })); } // --- Widgets/Glanceables actions --- @@ -657,18 +657,40 @@ export async function getPageIntegrationDataAction(auth: ActionAuth, pageName?: return extractData(await postPageConfigIntegrationData({ query: { page: pageName }, headers: authHeaders(auth) })) as Promise; } -// --- SearchItems actions --- +// --- Shortcuts actions --- -export async function getSearchItemsAction(auth: ActionAuth) { - return extractData(await getSearchItems({ headers: authHeaders(auth) })); +export async function getShortcutsAction(auth: ActionAuth) { + return extractData(await getShortcuts({ headers: authHeaders(auth) })); } -export async function getFrequentlyUsedSearchItemsAction(auth: ActionAuth) { - return extractData(await getSearchItemsFrequentlyUsed({ headers: authHeaders(auth) })); +export async function getFrequentlyUsedShortcutsAction(auth: ActionAuth) { + return extractData(await getShortcutsFrequentlyUsed({ headers: authHeaders(auth) })); } -export async function logSearchItemUsageAction(auth: ActionAuth, id: string, timestamp: string) { - return extractData(await postSearchItemsUsageStats({ body: { id, timestamp }, headers: authHeaders(auth) })); +export async function logShortcutUsageAction(auth: ActionAuth, id: string, timestamp: string) { + return extractData(await postShortcutsUsageStats({ body: { id, timestamp }, headers: authHeaders(auth) })); +} + +export async function createShortcutAppAction( + auth: ActionAuth, + input: { name: string; type: "on-demand"; icon?: string }, +) { + return extractData(await sdk.postShortcutsApps({ + body: input, + headers: authHeaders(auth), + })); +} + +export async function syncOnDemandShortcutsAction( + auth: ActionAuth, + appId: string, + shortcuts: Array>, +) { + return extractData(await sdk.putShortcutsOnDemandByAppId({ + path: { appId }, + body: { shortcuts }, + headers: authHeaders(auth), + })); } // --- Misc actions --- diff --git a/docs/Configuration.md b/docs/Configuration.md index ede976ea..641ee5a6 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -60,7 +60,7 @@ You can use the following environment variables for the all-in-one container; th | Name | Required | Default Value | Description | | --- | --- | --- | --- | -| SEARCHITEMS_SCHEDULE | No | `*/10 * * * *` | Interval for search item indexing job | +| SHORTCUTS_SCHEDULE | No | `*/10 * * * *` | Interval for shortcut indexing job | | ENABLE_ICONS_REFRESH | No | `false` | Enable automatic icon refresh job. Only the literal value `true` enables it; `1` does not. | | PULL_ICONS_SCHEDULE | No | `0 */18 * * *` | How often the icons refresh job runs | | MONITORING_INDEXER_SCHEDULE | No | `*/10 * * * *` | How often the monitoring indexer runs | diff --git a/packages/api-types/src/openapi.ts b/packages/api-types/src/openapi.ts index bb93df2f..674f70c6 100644 --- a/packages/api-types/src/openapi.ts +++ b/packages/api-types/src/openapi.ts @@ -805,14 +805,14 @@ export interface paths { patch?: never; trace?: never; }; - "/jobs/searchItems": { + "/jobs/shortcuts": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Search items job */ + /** Shortcuts indexing job */ get: { parameters: { query?: never; @@ -1710,14 +1710,14 @@ export interface paths { patch?: never; trace?: never; }; - "/searchItems": { + "/shortcuts": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Search items */ + /** List shortcuts */ get: { parameters: { query?: never; @@ -2835,14 +2835,78 @@ export interface paths { patch?: never; trace?: never; }; - "/searchItems/frequentlyUsed": { + "/shortcuts/apps": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** List frequently used search items */ + get?: never; + put?: never; + /** Create an on-demand shortcut app */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: components["requestBodies"]["JsonBody"]; + responses: { + 200: components["responses"]["JsonOk"]; + 400: components["responses"]["JsonBadRequest"]; + 401: components["responses"]["JsonUnauthorized"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/shortcuts/on-demand/{appId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Replace an on-demand app's shortcuts */ + put: { + parameters: { + query?: never; + header?: never; + path: { + appId: string; + }; + cookie?: never; + }; + requestBody?: components["requestBodies"]["JsonBody"]; + responses: { + 200: components["responses"]["JsonOk"]; + 400: components["responses"]["JsonBadRequest"]; + 401: components["responses"]["JsonUnauthorized"]; + 404: components["responses"]["JsonNotFound"]; + 409: components["responses"]["JsonConflict"]; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/shortcuts/frequentlyUsed": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List frequently used shortcuts */ get: { parameters: { query?: never; @@ -2863,7 +2927,7 @@ export interface paths { patch?: never; trace?: never; }; - "/searchItems/usageStats": { + "/shortcuts/usageStats": { parameters: { query?: never; header?: never; @@ -2872,7 +2936,7 @@ export interface paths { }; get?: never; put?: never; - /** Log search item usage */ + /** Log shortcut usage */ post: { parameters: { query?: never; @@ -2930,6 +2994,24 @@ export interface components { "application/json": components["schemas"]["Error"]; }; }; + /** @description Not Found */ + JsonNotFound: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Conflict */ + JsonConflict: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; parameters: { Id: string; diff --git a/packages/integrationskit/data/getEndpointData.tsx b/packages/integrationskit/data/getEndpointData.tsx index 78d72d90..3b470a95 100644 --- a/packages/integrationskit/data/getEndpointData.tsx +++ b/packages/integrationskit/data/getEndpointData.tsx @@ -29,6 +29,7 @@ export type EndpointRateLimitConfig = { const inFlightEndpointRequests = new Map>(); const endpointRateLimitQueues = new Map>(); const endpointRateLimitNextAt = new Map(); +const endpointBackoffUntil = new Map(); export type EndpointCurlRequest = { url: string; @@ -205,6 +206,11 @@ export async function getEndpointData( requestKey, async () => { try { + const backoffUntil = endpointBackoffUntil.get(requestKey) ?? 0; + if (backoffUntil > Date.now()) { + throw new Error(`Endpoint rate limited; retry after ${new Date(backoffUntil).toISOString()}`); + } + endpointBackoffUntil.delete(requestKey); const now = new Date().toLocaleTimeString("en-GB", { hour12: false }); const fetchOptions: RequestInit = { method, @@ -249,6 +255,7 @@ export async function getEndpointData( }; } + recordEndpointBackoff(requestKey, retryResponse, retryRawResponse); return throwEndpointFetchError({ endpointLabel, method, @@ -259,6 +266,7 @@ export async function getEndpointData( } if (!response.ok) { + recordEndpointBackoff(requestKey, response, rawResponse); return throwEndpointFetchError({ endpointLabel, method, @@ -680,6 +688,29 @@ function withoutAuthorizationHeader(headers: Record) { ); } +function recordEndpointBackoff( + requestKey: string, + response: Response, + rawResponse: unknown, +) { + const isRateLimited = response.status === 429 || + (response.status === 403 && ( + response.headers.get("x-ratelimit-remaining") === "0" || + JSON.stringify(rawResponse).toLowerCase().includes("rate limit") + )); + if (!isRateLimited) return; + + const now = Date.now(); + const retryAfter = Number(response.headers.get("retry-after")); + const resetAt = Number(response.headers.get("x-ratelimit-reset")) * 1000; + const backoffUntil = Number.isFinite(retryAfter) && retryAfter > 0 + ? now + retryAfter * 1000 + : Number.isFinite(resetAt) && resetAt > now + ? resetAt + : now + 60_000; + endpointBackoffUntil.set(requestKey, backoffUntil); +} + function throwEndpointFetchError(input: { endpointLabel: string; method: string; diff --git a/packages/types/pocketbase/pocketbase-types.ts b/packages/types/pocketbase/pocketbase-types.ts index b1e3df98..04102df8 100644 --- a/packages/types/pocketbase/pocketbase-types.ts +++ b/packages/types/pocketbase/pocketbase-types.ts @@ -26,7 +26,8 @@ export const Collections = { NotificationTopics: "notificationTopics", NotificationTopicTokens: "notificationTopicTokens", PageConfig: "pageConfig", - SearchItems: "searchItems", + Shortcuts: "shortcuts", + ShortcutsApps: "shortcutsApps", Sessions: "sessions", Users: "users", WallpaperStore: "wallpaperStore", @@ -311,7 +312,7 @@ export type PageConfigRecord = { updated: IsoAutoDateString } -export type SearchItemsRecord = { +export type ShortcutsRecord = { action?: string app?: string created: IsoAutoDateString @@ -328,6 +329,22 @@ export type SearchItemsRecord = { user?: RecordIdString } +export const ShortcutsAppsTypeOptions = { + "just-in-time": "just-in-time", + "on-demand": "on-demand", +} as const +export type ShortcutsAppsTypeOptions = typeof ShortcutsAppsTypeOptions[keyof typeof ShortcutsAppsTypeOptions] +export type ShortcutsAppsRecord = { + created: IsoAutoDateString + icon?: string + id: string + name: string + sourceId: string + type: ShortcutsAppsTypeOptions + updated: IsoAutoDateString + user: RecordIdString +} + export type SessionsRecord = { clientType?: string created: IsoAutoDateString @@ -388,7 +405,8 @@ export type NotificationItemsResponse = R export type NotificationTopicsResponse = Required & BaseSystemFields export type NotificationTopicTokensResponse = Required & BaseSystemFields export type PageConfigResponse = Required> & BaseSystemFields -export type SearchItemsResponse = Required> & BaseSystemFields +export type ShortcutsResponse = Required> & BaseSystemFields +export type ShortcutsAppsResponse = Required & BaseSystemFields export type SessionsResponse = Required & BaseSystemFields export type UsersResponse = Required> & AuthSystemFields export type WallpaperStoreResponse = Required & BaseSystemFields @@ -416,7 +434,8 @@ export type CollectionRecords = { notificationTopics: NotificationTopicsRecord notificationTopicTokens: NotificationTopicTokensRecord pageConfig: PageConfigRecord - searchItems: SearchItemsRecord + shortcuts: ShortcutsRecord + shortcutsApps: ShortcutsAppsRecord sessions: SessionsRecord users: UsersRecord wallpaperStore: WallpaperStoreRecord @@ -443,7 +462,8 @@ export type CollectionResponses = { notificationTopics: NotificationTopicsResponse notificationTopicTokens: NotificationTopicTokensResponse pageConfig: PageConfigResponse - searchItems: SearchItemsResponse + shortcuts: ShortcutsResponse + shortcutsApps: ShortcutsAppsResponse sessions: SessionsResponse users: UsersResponse wallpaperStore: WallpaperStoreResponse diff --git a/pocketbase/migrations/1785000004_shortcuts_registration.js b/pocketbase/migrations/1785000004_shortcuts_registration.js new file mode 100644 index 00000000..5c8c3e6c --- /dev/null +++ b/pocketbase/migrations/1785000004_shortcuts_registration.js @@ -0,0 +1,299 @@ +/// + +const shortcutsAppsId = "pbc_342shortcutapp"; +const shortcutsId = "pbc_3591471183"; +const legacyAppFieldId = "text3379458255"; +const appRelationFieldId = "relation342shortcutapp"; + +migrate((app) => { + const shortcutsApps = new Collection({ + "createRule": "@request.auth.id = user", + "deleteRule": "@request.auth.id = user", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": true, + "collectionId": "_pb_users_auth_", + "hidden": false, + "id": "relation342shortcutuser", + "maxSelect": 1, + "minSelect": 1, + "name": "user", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text342shortcutsource", + "max": 0, + "min": 1, + "name": "sourceId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text342shortcutname", + "max": 255, + "min": 1, + "name": "name", + "pattern": "", + "presentable": true, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select342shortcuttype", + "maxSelect": 1, + "name": "type", + "presentable": false, + "required": true, + "system": false, + "type": "select", + "values": ["just-in-time", "on-demand"] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text342shortcuticon", + "max": 0, + "min": 0, + "name": "icon", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": shortcutsAppsId, + "indexes": [ + "CREATE UNIQUE INDEX `idx_shortcutsApps_user_sourceId` ON `shortcutsApps` (`user`, `sourceId`)" + ], + "listRule": "@request.auth.id = user", + "name": "shortcutsApps", + "system": false, + "type": "base", + "updateRule": "@request.auth.id = user", + "viewRule": "@request.auth.id = user" + }); + + app.save(shortcutsApps); + + const shortcuts = app.findCollectionByNameOrId(shortcutsId); + shortcuts.name = "shortcuts"; + + const legacyAppField = shortcuts.fields.getByName("app"); + legacyAppField.name = "legacyApp"; + app.save(shortcuts); + + shortcuts.fields.addAt(shortcuts.fields.length, new Field({ + "cascadeDelete": false, + "collectionId": shortcutsAppsId, + "hidden": false, + "id": appRelationFieldId, + "maxSelect": 1, + "minSelect": 0, + "name": "app", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + })); + app.save(shortcuts); + + const records = app.findRecordsByFilter("shortcuts", "legacyApp != \"\"", "created", 100000, 0); + const appsByKey = {}; + for (const record of records) { + const userId = String(record.get("user") || "").trim(); + const sourceId = String(record.get("legacyApp") || "").trim(); + if (!userId || !sourceId) { + record.set("app", null); + app.save(record); + continue; + } + + const key = userId + "\u0000" + sourceId; + let shortcutApp = appsByKey[key]; + if (!shortcutApp) { + const existing = app.findRecordsByFilter( + "shortcutsApps", + "user = \"" + escapeFilter(userId) + "\" && sourceId = \"" + escapeFilter(sourceId) + "\"", + "created", + 1, + 0, + ); + shortcutApp = existing.length > 0 ? existing[0] : null; + } + + if (!shortcutApp) { + const appName = resolveAppName(app, sourceId); + shortcutApp = new Record(shortcutsApps); + shortcutApp.set("user", userId); + shortcutApp.set("sourceId", sourceId); + shortcutApp.set("name", appName.name); + shortcutApp.set("type", "just-in-time"); + shortcutApp.set("icon", appName.icon); + app.save(shortcutApp); + } + + appsByKey[key] = shortcutApp; + record.set("app", shortcutApp.id); + app.save(record); + } + + // Existing integration group entries point at the legacy text identifier + // in their app: action. Point those group entries at the new relation + // record as well so grouping works immediately after the migration. + const allShortcuts = app.findRecordsByFilter("shortcuts", "", "created", 100000, 0); + for (const record of allShortcuts) { + const action = String(record.get("action") || ""); + const userId = String(record.get("user") || "").trim(); + if (!action.startsWith("app:") || !userId) continue; + + const shortcutApp = appsByKey[userId + "\u0000" + action.slice(4).trim()]; + if (!shortcutApp) continue; + record.set("action", "app:" + shortcutApp.id); + app.save(record); + } + + shortcuts.fields.removeById(legacyAppFieldId); + return app.save(shortcuts); +}, (app) => { + const shortcuts = app.findCollectionByNameOrId(shortcutsId); + const shortcutsApps = app.findCollectionByNameOrId(shortcutsAppsId); + const records = app.findRecordsByFilter("shortcuts", "", "created", 100000, 0); + + for (const record of records) { + const appId = String(record.get("app") || "").trim(); + const action = String(record.get("action") || ""); + if (!appId) { + if (action.startsWith("app:")) { + try { + const shortcutApp = app.findRecordById(shortcutsAppsId, action.slice(4).trim()); + record.set("action", "app:" + String(shortcutApp.get("sourceId") || "")); + } catch (_) { + // Leave unrelated app actions unchanged. + } + } + record.set("legacyApp", ""); + app.save(record); + continue; + } + + try { + const shortcutApp = app.findRecordById(shortcutsAppsId, appId); + record.set("legacyApp", String(shortcutApp.get("sourceId") || "")); + } catch (_) { + record.set("legacyApp", ""); + } + app.save(record); + } + + shortcuts.fields.removeById(appRelationFieldId); + shortcuts.fields.addAt(shortcuts.fields.length, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": legacyAppFieldId, + "max": 0, + "min": 0, + "name": "app", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })); + shortcuts.name = "searchItems"; + app.save(shortcuts); + return app.delete(shortcutsApps); +}); + +function escapeFilter(value) { + return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +function resolveAppName(app, sourceId) { + if (sourceId.startsWith("integration:")) { + const integrationId = sourceId.slice("integration:".length).trim(); + try { + const integration = app.findRecordById("integrations", integrationId); + const config = parseObject(integration.get("config")); + const name = firstString( + integration.get("name"), + config.details && config.details.name, + integration.get("source"), + ); + const icon = firstString(config.details && config.details.icon); + if (name) return { name, icon }; + } catch (_) { + // Use the source identifier when its integration was removed already. + } + } + + return { name: sourceId, icon: "" }; +} + +function parseObject(value) { + if (value && typeof value === "object" && !Array.isArray(value)) return value; + if (typeof value !== "string") return {}; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch (_) { + return {}; + } +} + +function firstString(...values) { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value.trim(); + } + return ""; +}