diff --git a/packages/api/src/router/widgets/anchor-notes.ts b/packages/api/src/router/widgets/anchor-notes.ts new file mode 100644 index 000000000..7e5b7043c --- /dev/null +++ b/packages/api/src/router/widgets/anchor-notes.ts @@ -0,0 +1,114 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod/v4"; + +import { ResponseError } from "@homarr/common/server"; +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema, createIntegrationAsync } from "@homarr/integrations"; +import { anchorNoteRequestHandler, anchorNotesListRequestHandler } from "@homarr/request-handler/anchor-notes"; + +import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; + +const noteIdInput = z.object({ + noteId: z.string(), +}); + +const logger = createLogger({ module: "anchorNotesRouter" }); + +const isJsonDeltaString = (value: string) => { + try { + const parsed: unknown = JSON.parse(value); + return typeof parsed === "object" && parsed !== null && Array.isArray((parsed as { ops?: unknown }).ops); + } catch { + return false; + } +}; + +const normalizeAnchorContent = (content: string | undefined) => { + if (content === undefined) return undefined; + + const trimmed = content.trim(); + if (!trimmed) { + return JSON.stringify({ ops: [{ insert: "\n" }] }); + } + + if (isJsonDeltaString(content)) return content; + + const normalizedText = content.endsWith("\n") ? content : `${content}\n`; + return JSON.stringify({ ops: [{ insert: normalizedText }] }); +}; + +export const anchorNotesRouter = createTRPCRouter({ + listNotes: publicProcedure + .concat(createOneIntegrationMiddleware("query", "anchor")) + .input(anchorNotesListInputSchema) + .query(async ({ ctx, input }) => { + const handler = anchorNotesListRequestHandler.handler(ctx.integration, { ...input, limit: input.limit ?? 50 }); + + const { data } = await handler.getCachedOrUpdatedDataAsync({ + forceUpdate: false, + }); + + return data; + }), + getNote: publicProcedure + .concat(createOneIntegrationMiddleware("query", "anchor")) + .input(noteIdInput) + .query(async ({ ctx, input }) => { + const handler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId }); + + const { data } = await handler.getCachedOrUpdatedDataAsync({ + forceUpdate: false, + }); + + return data; + }), + updateNote: protectedProcedure + .concat(createOneIntegrationMiddleware("interact", "anchor")) + .input(anchorNoteUpdateInputSchema) + .mutation(async ({ ctx, input }) => { + const integrationInstance = await createIntegrationAsync(ctx.integration); + const normalizedContent = normalizeAnchorContent(input.content); + const isContentNormalized = + input.content !== undefined && normalizedContent !== undefined && input.content !== normalizedContent; + + try { + const updatedNote = await integrationInstance.updateNoteAsync({ + noteId: input.noteId, + ...(input.title !== undefined ? { title: input.title } : {}), + ...(normalizedContent !== undefined ? { content: normalizedContent } : {}), + }); + + const noteHandler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId }); + await noteHandler.invalidateAsync(); + + return updatedNote; + } catch (error) { + if (error instanceof ResponseError && error.statusCode === 400) { + logger.warn("Anchor update note failed validation", { + noteId: input.noteId, + integrationId: ctx.integration.id, + contentLength: input.content?.length ?? 0, + contentIsJsonDelta: input.content ? isJsonDeltaString(input.content) : false, + contentNormalized: isContentNormalized, + }); + } + + if (error instanceof ResponseError && error.statusCode === 403) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You do not have permission to edit this note", + }); + } + + if (error instanceof ResponseError && error.statusCode === 404) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Note not found", + }); + } + + throw error; + } + }), +}); diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 9d4f579cc..5e29dfa72 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -3,6 +3,7 @@ import { lazy } from "@trpc/server"; import { createTRPCRouter } from "../../trpc"; export const widgetRouter = createTRPCRouter({ + anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)), coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)), immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)), notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)), diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 106d4a6a4..5f7802a73 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -312,6 +312,14 @@ export const integrationDefs = { category: ["calendar"], documentationUrl: createDocumentationLink("/docs/integrations/ical"), }, + anchor: { + name: "Anchor", + secretKinds: [["apiKey"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/anchor.svg", + category: ["notes"], + // @ts-expect-error - docs page will be created when integration is merged + documentationUrl: createDocumentationLink("/docs/integrations/anchor"), + }, truenas: { name: "TrueNAS", secretKinds: [["username", "password"]], @@ -452,6 +460,7 @@ export const integrationCategories = [ "firewall", "timetable", "photoService", + "notes", "mediaMonitoring", "speedtest", ] as const; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index b2a3147b1..b246b32ea 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -5,6 +5,7 @@ export const widgetKinds = [ "iframe", "video", "notebook", + "anchorNote", "dnsHoleSummary", "dnsHoleControls", "smartHome-entityState", diff --git a/packages/integrations/src/anchor/anchor-integration.ts b/packages/integrations/src/anchor/anchor-integration.ts new file mode 100644 index 000000000..5db4a024c --- /dev/null +++ b/packages/integrations/src/anchor/anchor-integration.ts @@ -0,0 +1,118 @@ +import type { fetch as undiciFetch } from "undici"; + +import { ResponseError } from "@homarr/common/server"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import { createLogger } from "@homarr/core/infrastructure/logs"; + +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import { anchorNoteSchema, anchorNoteSummaryListSchema } from "./anchor-types"; +import type { AnchorNote, AnchorNotesListInput, AnchorNoteSummary, AnchorNoteUpdateInput } from "./anchor-types"; + +const logger = createLogger({ module: "anchorIntegration" }); + +export class AnchorIntegration extends Integration { + protected async testingAsync(input: IntegrationTestingInput): Promise { + const response = await input.fetchAsync(this.url("/api/notes", { limit: 1 }), { + headers: { + Authorization: `Bearer ${this.getSecretValue("apiKey")}`, + }, + }); + + if (!response.ok) return TestConnectionError.StatusResult(response); + + anchorNoteSummaryListSchema.parse(await response.json()); + return { success: true }; + } + + public async listNotesAsync(input: AnchorNotesListInput = {}): Promise { + const queryParams = buildListQuery(input); + const response = await this.requestAsync(fetchWithTrustedCertificatesAsync, "/api/notes", { + queryParams, + }); + return anchorNoteSummaryListSchema.parse(response); + } + + public async getNoteAsync(noteId: string): Promise { + const response = await this.requestAsync(fetchWithTrustedCertificatesAsync, `/api/notes/${noteId}`); + return anchorNoteSchema.parse(response); + } + + public async updateNoteAsync(input: AnchorNoteUpdateInput): Promise { + const { noteId, ...updateData } = input; + const response = await this.requestAsync(fetchWithTrustedCertificatesAsync, `/api/notes/${noteId}`, { + method: "PATCH", + body: updateData, + }); + if (response === null) { + return this.getNoteAsync(noteId); + } + return anchorNoteSchema.parse(response); + } + + private async requestAsync( + fetchAsync: typeof undiciFetch, + path: `/${string}`, + options?: { + queryParams?: Record; + method?: string; + body?: unknown; + }, + ): Promise { + const url = this.url(path, options?.queryParams); + const headers: Record = { + Authorization: `Bearer ${this.getSecretValue("apiKey")}`, + }; + + if (options?.body !== undefined) { + headers["Content-Type"] = "application/json"; + } + + const response = await fetchAsync(url, { + method: options?.method ?? "GET", + headers, + body: options?.body !== undefined ? JSON.stringify(options.body) : undefined, + }); + + if (!response.ok) { + let errorBody: string | null = null; + try { + errorBody = await response.text(); + } catch { + errorBody = null; + } + logger.warn("Anchor request failed", { + statusCode: response.status, + url, + errorBody: errorBody ? errorBody.slice(0, 200) : null, + }); + throw new ResponseError(response); + } + + if (response.status === 204) { + return null; + } + + return await response.json(); + } +} + +const buildListQuery = (input: AnchorNotesListInput) => { + const queryParams: Record = {}; + + if (input.search?.trim()) { + queryParams.search = input.search.trim(); + } + + if (input.tagId?.trim()) { + queryParams.tagId = input.tagId.trim(); + } + + if (typeof input.limit === "number") { + queryParams.limit = input.limit.toString(); + } + + return Object.keys(queryParams).length > 0 ? queryParams : undefined; +}; diff --git a/packages/integrations/src/anchor/anchor-types.ts b/packages/integrations/src/anchor/anchor-types.ts new file mode 100644 index 000000000..547969cbb --- /dev/null +++ b/packages/integrations/src/anchor/anchor-types.ts @@ -0,0 +1,43 @@ +import { z } from "zod/v4"; + +export const anchorNotesListInputSchema = z.object({ + search: z.string().optional(), + tagId: z.string().optional(), + limit: z.number().min(1).max(200).optional(), +}); +export type AnchorNotesListInput = z.infer; + +export const anchorNotePermissionSchema = z.enum(["owner", "viewer", "editor"]); +export type AnchorNotePermission = z.infer; + +export const anchorNoteSummarySchema = z.object({ + id: z.string(), + title: z.string(), + updatedAt: z.string(), + isPinned: z.boolean(), + tagIds: z.array(z.string()), + permission: anchorNotePermissionSchema, +}); +export type AnchorNoteSummary = z.infer; + +export const anchorNoteSchema = anchorNoteSummarySchema.extend({ + content: z.string().nullable().optional(), + createdAt: z.string(), + isArchived: z.boolean(), + background: z.string().nullable().optional(), + userId: z.string(), +}); +export type AnchorNote = z.infer; + +export const anchorNoteSummaryListSchema = z.array(anchorNoteSummarySchema); + +export const anchorNoteUpdateInputSchema = z + .object({ + noteId: z.string(), + title: z.string().optional(), + content: z.string().optional(), + }) + .refine((value) => value.title !== undefined || value.content !== undefined, { + message: "At least one field to update must be provided", + }); +export type AnchorNoteUpdateInput = z.infer; diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index c6a2cebc9..5748bdd96 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -1,6 +1,7 @@ import type { IntegrationKind } from "@homarr/definitions"; import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration"; +import { AnchorIntegration } from "../anchor/anchor-integration"; import { CodebergIntegration } from "../codeberg/codeberg-integration"; import { CoolifyIntegration } from "../coolify/coolify-integration"; import { DashDotIntegration } from "../dashdot/dashdot-integration"; @@ -72,6 +73,7 @@ type IntegrationInstance = new (integration: IntegrationInput) => Integration; // factories are an array, to differentiate in js between class constructors and functions export const integrationCreators = { + anchor: AnchorIntegration, piHole: [createPiHoleIntegrationAsync], adGuardHome: AdGuardHomeIntegration, homeAssistant: HomeAssistantIntegration, diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 2e87cb479..2ac590bdb 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -1,5 +1,6 @@ // General integrations export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration"; +export { AnchorIntegration } from "./anchor/anchor-integration"; export { Aria2Integration } from "./download-client/aria2/aria2-integration"; export { DelugeIntegration } from "./download-client/deluge/deluge-integration"; export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration"; @@ -58,10 +59,19 @@ export type { export type { ReleasesRepository, ReleaseResponse } from "./interfaces/releases-providers/releases-providers-types"; export type { Notification } from "./interfaces/notifications/notification-types"; export type { ImmichServerStats, ImmichAlbum, ImmichAsset } from "./immich/immich-integration"; +export type { + AnchorNote, + AnchorNotePermission, + AnchorNoteUpdateInput, + AnchorNotesListInput, + AnchorNoteSummary, +} from "./anchor/anchor-types"; export type { TracearrDashboardData } from "./tracearr/tracearr-types"; export type { SpeedtestTrackerDashboardData } from "./speedtest-tracker/speedtest-tracker-types"; // Schemas +export { anchorNotesListInputSchema } from "./anchor/anchor-types"; +export { anchorNoteUpdateInputSchema } from "./anchor/anchor-types"; export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items"; // Helpers diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index 4ba41a789..eb8b9e6ca 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -12,6 +12,7 @@ export * from "./unifi-controller/unifi-controller-types"; export * from "./opnsense/opnsense-types"; export * from "./interfaces/media-releases"; export * from "./coolify/coolify-types"; +export * from "./anchor/anchor-types"; export * from "./interfaces/timetable/timetable-types"; export * from "./tracearr/tracearr-types"; export * from "./speedtest-tracker/speedtest-tracker-types"; diff --git a/packages/request-handler/src/anchor-notes.ts b/packages/request-handler/src/anchor-notes.ts new file mode 100644 index 000000000..bd6f19e44 --- /dev/null +++ b/packages/request-handler/src/anchor-notes.ts @@ -0,0 +1,30 @@ +import dayjs from "dayjs"; + +import type { AnchorNote, AnchorNotesListInput, AnchorNoteSummary } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const anchorNotesListRequestHandler = createCachedIntegrationRequestHandler< + AnchorNoteSummary[], + "anchor", + AnchorNotesListInput +>({ + queryKey: "anchorNotesList", + cacheDuration: dayjs.duration(15, "seconds"), + async requestAsync(integration, input) { + const instance = await createIntegrationAsync(integration); + return instance.listNotesAsync(input); + }, +}); + +export const anchorNoteRequestHandler = createCachedIntegrationRequestHandler( + { + queryKey: "anchorNote", + cacheDuration: dayjs.duration(15, "seconds"), + async requestAsync(integration, input) { + const instance = await createIntegrationAsync(integration); + return instance.getNoteAsync(input.noteId); + }, + }, +); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 6d5fe7ad1..aadcb0d96 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1606,6 +1606,34 @@ } } }, + "anchorNote": { + "name": "Anchor Note", + "description": "Displays a selected note from Anchor", + "option": { + "noteId": { + "label": "Note" + }, + "showTitle": { + "label": "Show title" + }, + "showUpdatedAt": { + "label": "Show last updated" + } + }, + "empty": "Select a note to display", + "emptyContent": "This note has no content", + "notFound": "Note not found", + "noNotes": "No notes found", + "integrationRequired": "Select an Anchor integration first", + "updatedAt": "Updated {date}", + "untitled": "Untitled", + "edit": "Edit", + "save": "Save", + "cancel": "Cancel", + "readOnlyViewer": "You have viewer access to this note. Editing is disabled.", + "saveForbidden": "You do not have permission to edit this note.", + "saveFailed": "Failed to save this note. Please try again." + }, "iframe": { "name": "iFrame", "description": "Embed any content from the internet. Some websites may restrict access.", diff --git a/packages/widgets/package.json b/packages/widgets/package.json index db2e0d820..56b844f7a 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -76,6 +76,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-markdown": "^10.1.0", + "react-quill-new": "^3.7.0", "recharts": "^2.15.4", "video.js": "^8.23.7", "zod": "^4.3.6" diff --git a/packages/widgets/src/_inputs/index.ts b/packages/widgets/src/_inputs/index.ts index 7018e8dc2..8917664de 100644 --- a/packages/widgets/src/_inputs/index.ts +++ b/packages/widgets/src/_inputs/index.ts @@ -1,4 +1,5 @@ import type { WidgetOptionType } from "../options"; +import { WidgetAnchorNoteInput } from "./widget-anchor-note-input"; import { WidgetAppInput } from "./widget-app-input"; import { WidgetDynamicSelectInput } from "./widget-dynamic-select-input"; import { WidgetLocationInput } from "./widget-location-input"; @@ -13,6 +14,7 @@ import { WidgetSwitchInput } from "./widget-switch-input"; import { WidgetTextInput } from "./widget-text-input"; const mapping = { + anchorNote: WidgetAnchorNoteInput, text: WidgetTextInput, location: WidgetLocationInput, multiSelect: WidgetMultiSelectInput, diff --git a/packages/widgets/src/_inputs/widget-anchor-note-input.tsx b/packages/widgets/src/_inputs/widget-anchor-note-input.tsx new file mode 100644 index 000000000..e045e7df0 --- /dev/null +++ b/packages/widgets/src/_inputs/widget-anchor-note-input.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Loader, Select } from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; + +import { clientApi } from "@homarr/api/client"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; + +import type { CommonWidgetInputProps } from "./common"; +import { useWidgetInputTranslation } from "./common"; +import { useFormContext } from "./form"; + +export const WidgetAnchorNoteInput = ({ property, kind, options }: CommonWidgetInputProps<"anchorNote">) => { + const t = useI18n(); + const tWidget = useWidgetInputTranslation(kind, property); + const tAnchor = useScopedI18n("widget.anchorNote"); + const form = useFormContext(); + + const integrationId = form.values.integrationIds[0]; + const [search, setSearch] = useState(""); + const [debouncedSearch] = useDebouncedValue(search, 250); + + const { data: notes, isPending } = clientApi.widget.anchorNotes.listNotes.useQuery( + { + integrationId: integrationId ?? "", + search: debouncedSearch.trim() ? debouncedSearch : undefined, + limit: 200, + }, + { + enabled: Boolean(integrationId), + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + }, + ); + + const noteOptions = useMemo( + () => + notes?.map((note) => ({ + value: note.id, + label: note.title || tAnchor("untitled"), + })) ?? [], + [notes, tAnchor], + ); + + return ( +