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:
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
Reference in new issue
Block a user