feat: Anchor integration and Anchor Note widget (#5040)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-04-10 20:43:43 +02:00
1 parent 57ea82f088
commit 7879b31630
20 files changed
+949 -2

No files matched your search

@@ -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<TestingResult> {
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<AnchorNoteSummary[]> {
const queryParams = buildListQuery(input);
const response = await this.requestAsync(fetchWithTrustedCertificatesAsync, "/api/notes", {
queryParams,
});
return anchorNoteSummaryListSchema.parse(response);
}
public async getNoteAsync(noteId: string): Promise<AnchorNote> {
const response = await this.requestAsync(fetchWithTrustedCertificatesAsync, `/api/notes/${noteId}`);
return anchorNoteSchema.parse(response);
}
public async updateNoteAsync(input: AnchorNoteUpdateInput): Promise<AnchorNote> {
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<string, string>;
method?: string;
body?: unknown;
},
): Promise<unknown> {
const url = this.url(path, options?.queryParams);
const headers: Record<string, string> = {
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<string, string> = {};
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;
};
@@ -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<typeof anchorNotesListInputSchema>;
export const anchorNotePermissionSchema = z.enum(["owner", "viewer", "editor"]);
export type AnchorNotePermission = z.infer<typeof anchorNotePermissionSchema>;
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<typeof anchorNoteSummarySchema>;
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<typeof anchorNoteSchema>;
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<typeof anchorNoteUpdateInputSchema>;
@@ -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,
+10
View File
@@ -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
+1
View File
@@ -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";