Files
edgeever/packages/client/src/index.ts
T

901 lines
28 KiB
TypeScript

import type {
ApiToken,
AuthSession,
LoginInput,
LoginDeviceSession,
InstanceUser,
CreatedApiToken,
JsonBackupMemo,
JsonBackupNotebook,
JsonBackupAiPrompt,
JsonBackupRevision,
MemoDetail,
MemoEditSession,
MemoRevision,
MemoSummary,
MemoShare,
MemoTemplate,
Notebook,
Resource,
ResourceListItem,
ResourceStorageSummary,
ObjectStorageSettings,
PublicMemoShare,
TagSummary,
TiptapDoc,
AiSettings,
AiDiscoveredModel,
AiProvider,
AiPromptTemplate,
AiPromptTemplateCreateInput,
AiPromptTemplateUpdateInput,
AiStreamEvent,
AiGenerateInput,
AiTagSuggestionPromptUpdateInput,
AiTagSuggestionsRequestInput,
AiTagSuggestionsResponse,
SyncBootstrapResponse,
SyncChange,
SyncChangesResponse,
} from "@edgeever/shared";
export type EdgeEverClientRequestContext = {
path: string;
token?: string;
};
export type EdgeEverClientOptions = {
baseUrl?: string | (() => string);
token?: string | null | (() => string | null | undefined);
fetch?: typeof fetch;
beforeRequest?: (context: EdgeEverClientRequestContext) => void | Promise<void>;
shouldAttachToken?: (path: string) => boolean;
onUnauthorized?: (context: EdgeEverClientRequestContext) => void | Promise<void>;
};
export type InstanceHealth = {
ok: true;
name: string;
runtime?: string | null;
containerImageSource?: "official-ghcr" | "official-cn-mirror" | "custom" | "unknown" | string | null;
authMode?: string | null;
build?: string | null;
migration?: string | null;
storage?: {
database?: "d1" | "sqlite" | string | null;
resources?: "r2" | "filesystem" | "s3" | string | null;
} | null;
objectStorageProvider?: "builtin" | "s3" | "unknown" | string | null;
};
export type InstanceRelease = {
version: string;
changes: Record<string, readonly string[]>;
};
export type MemoFilterMode = "all" | "tagged" | "untagged" | "pinned";
export type MemoSortMode = "updated-desc" | "created-desc" | "title-asc";
export type ListNotebooksResponse = {
notebooks: Notebook[];
};
export type ListMemosResponse = {
memos: MemoSummary[];
totalCount: number;
nextCursor: string | null;
};
export type ListMemoRevisionsResponse = {
revisions: MemoRevision[];
};
export type ListResourcesResponse = {
resources: ResourceListItem[];
summary: ResourceStorageSummary;
};
export type ListTagsResponse = {
tags: TagSummary[];
};
export type ListApiTokensResponse = {
apiTokens: ApiToken[];
availableScopes: string[];
};
export type ListUsersResponse = {
users: InstanceUser[];
};
export type UserResponse = {
user: InstanceUser;
};
export type ListLoginDeviceSessionsResponse = {
sessions: LoginDeviceSession[];
};
export type ObjectStorageSettingsResponse = {
settings: ObjectStorageSettings;
externalSettings?: ObjectStorageSettings | null;
};
export type MemoResponse = {
memo: MemoDetail;
};
export type ListTemplatesResponse = {
templates: MemoTemplate[];
};
export type TemplateResponse = {
template: MemoTemplate;
};
export type MemoShareResponse = {
share: MemoShare | null;
};
export type PublicMemoShareResponse = {
share: PublicMemoShare;
};
export type NotebookResponse = {
notebook: Notebook;
};
export type ResourceResponse = {
resource: Resource;
};
export type MarkdownExportPage = {
memos: MemoDetail[];
resources: Resource[];
totalCount: number;
nextOffset: number | null;
};
export type JsonBackupPage = MarkdownExportPage & {
revisions: JsonBackupRevision[];
};
export type AiProviderCreatePayload = {
provider: AiProvider;
displayName: string;
baseUrl: string;
apiKey: string;
isEnabled: boolean;
initialModelId?: string;
};
export type AiProviderUpdatePayload = {
provider: AiProvider;
displayName: string;
baseUrl: string;
apiKey?: string;
isEnabled: boolean;
};
/** @deprecated Use SyncBootstrapResponse; kept for mobile source compatibility. */
export type MobileSyncBootstrapPage = SyncBootstrapResponse;
/** @deprecated Use SyncChange; kept for mobile source compatibility. */
export type MobileSyncChange = SyncChange;
/** @deprecated Use SyncChangesResponse; kept for mobile source compatibility. */
export type MobileSyncChangesPage = SyncChangesResponse;
export class ApiRequestError extends Error {
status: number;
code?: string;
details?: unknown;
responseDiagnostics?: ApiResponseDiagnostics;
constructor(
message: string,
status: number,
code?: string,
details?: unknown,
responseDiagnostics?: ApiResponseDiagnostics,
) {
super(message);
this.name = "ApiRequestError";
this.status = status;
this.code = code;
this.details = details;
this.responseDiagnostics = responseDiagnostics;
}
}
export type ApiResponseDiagnostics = {
cloudflareMitigated: boolean;
isEdgeEverApiError: boolean;
rayId?: string;
};
export type { SyncBootstrapResponse, SyncChangesResponse };
export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
const getFetch = () => options.fetch ?? globalThis.fetch;
const getBaseUrl = () => normalizeBaseUrl(
typeof options.baseUrl === "function" ? options.baseUrl() : options.baseUrl,
);
const getToken = () => {
const token = typeof options.token === "function" ? options.token() : options.token;
return token || undefined;
};
const send = async (
path: string,
init?: RequestInit,
requestOptions?: { attachToken?: boolean; setJsonContentType?: boolean },
) => {
const token = getToken();
const context: EdgeEverClientRequestContext = { path, ...(token ? { token } : {}) };
await options.beforeRequest?.(context);
const headers = new Headers(init?.headers);
const attachToken = requestOptions?.attachToken
?? options.shouldAttachToken?.(path)
?? true;
if (token && attachToken && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${token}`);
}
if (
requestOptions?.setJsonContentType !== false
&& !(init?.body instanceof FormData)
&& !headers.has("Content-Type")
) {
headers.set("Content-Type", "application/json");
}
const requestUrl = isAbsoluteHttpUrl(path) ? path : `${getBaseUrl()}${path}`;
const response = await getFetch()(requestUrl, {
credentials: "include",
...init,
headers,
});
return { context, response };
};
const throwRequestError = async (
context: EdgeEverClientRequestContext,
response: Response,
fallbackMessage = "Request failed",
): Promise<never> => {
const body = await response.json().catch(() => null);
const rayId = response.headers.get("cf-ray")?.trim();
const error =
body && typeof body === "object" && "error" in body
? (body as { error?: { code?: string; message?: string; details?: unknown } }).error
: undefined;
const responseDiagnostics: ApiResponseDiagnostics = {
cloudflareMitigated: response.headers.get("cf-mitigated") === "challenge",
isEdgeEverApiError: Boolean(error && typeof error === "object"),
...(rayId ? { rayId } : {}),
};
if (response.status === 401) {
void options.onUnauthorized?.(context);
}
throw new ApiRequestError(
error?.message || response.statusText || fallbackMessage,
response.status,
error?.code,
error?.details,
responseDiagnostics,
);
};
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
const { context, response } = await send(path, init);
if (!response.ok) {
await throwRequestError(context, response);
}
return response.json() as Promise<T>;
};
const requestBlob = async (path: string) => {
const isAbsolute = isAbsoluteHttpUrl(path);
const { context, response } = await send(path, undefined, {
attachToken: !isAbsolute,
setJsonContentType: false,
});
if (!response.ok) await throwRequestError(context, response, "Resource download failed");
return response.blob();
};
const requestArrayBuffer = async (path: string) => {
const { context, response } = await send(path, undefined, { setJsonContentType: false });
if (!response.ok) await throwRequestError(context, response, "Binary download failed");
return response.arrayBuffer();
};
return {
getInstanceHealth: () => request<InstanceHealth>("/api/health"),
getInstanceRelease: () => request<InstanceRelease>("/api/release"),
getSession: () => request<AuthSession>("/api/v1/auth/session"),
getPublicMemoShare: (token: string) =>
request<PublicMemoShareResponse>(`/api/public/shares/${encodeURIComponent(token)}`),
listLoginDeviceSessions: () =>
request<ListLoginDeviceSessionsResponse>("/api/v1/auth/sessions"),
revokeLoginDeviceSession: (sessionId: string) =>
request<{ ok: true }>(`/api/v1/auth/sessions/${sessionId}`, { method: "DELETE" }),
updateLoginDeviceSession: (sessionId: string, payload: { label: string | null }) =>
request<{ ok: true }>(`/api/v1/auth/sessions/${sessionId}`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
revokeOtherLoginDeviceSessions: () =>
request<{ ok: true }>("/api/v1/auth/sessions", { method: "DELETE" }),
login: (payload: LoginInput) =>
request<AuthSession>("/api/v1/auth/login", {
method: "POST",
body: JSON.stringify(payload),
}),
changePassword: (payload: { currentPassword: string; newPassword: string; confirmPassword: string }) =>
request<{ ok: true }>("/api/v1/auth/change-password", {
method: "POST",
body: JSON.stringify(payload),
}),
getObjectStorageSettings: () =>
request<ObjectStorageSettingsResponse>("/api/v1/instance/object-storage"),
testObjectStorageConnection: (payload: {
endpoint: string;
region: string;
bucket: string;
accessKeyId: string;
secretAccessKey?: string;
forcePathStyle: boolean;
objectPrefix: string;
}) => request<{ ok: true }>("/api/v1/instance/object-storage/test", {
method: "POST",
body: JSON.stringify(payload),
}),
updateObjectStorageSettings: (payload:
| { provider: "builtin" }
| {
provider: "s3";
displayName: string;
endpoint: string;
region: string;
bucket: string;
accessKeyId: string;
secretAccessKey?: string;
forcePathStyle: boolean;
objectPrefix: string;
}) => request<ObjectStorageSettingsResponse>("/api/v1/instance/object-storage", {
method: "PUT",
body: JSON.stringify(payload),
}),
getAiSettings: (locale?: string) => {
const search = locale ? `?locale=${encodeURIComponent(locale)}` : "";
return request<AiSettings>(`/api/v1/ai/settings${search}`);
},
createAiProvider: (payload: AiProviderCreatePayload) =>
request<AiSettings>("/api/v1/ai/providers", {
method: "POST",
body: JSON.stringify(payload),
}),
updateAiProvider: (providerConfigId: string, payload: AiProviderUpdatePayload) =>
request<AiSettings>(`/api/v1/ai/providers/${encodeURIComponent(providerConfigId)}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
deleteAiProvider: (providerConfigId: string) =>
request<AiSettings>(`/api/v1/ai/providers/${encodeURIComponent(providerConfigId)}`, {
method: "DELETE",
}),
testAiProvider: (providerConfigId: string, payload: {
modelId: string;
provider?: AiProvider;
baseUrl?: string;
apiKey?: string;
}) =>
request<{ ok: true; response: string }>(`/api/v1/ai/providers/${encodeURIComponent(providerConfigId)}/test`, {
method: "POST",
body: JSON.stringify(payload),
}),
discoverAiProviderModels: (providerConfigId: string) =>
request<{ models: AiDiscoveredModel[] }>(`/api/v1/ai/providers/${encodeURIComponent(providerConfigId)}/discover-models`, {
method: "POST",
body: JSON.stringify({}),
}),
addAiModel: (providerConfigId: string, payload: { modelId: string; displayName?: string }) =>
request<AiSettings>(`/api/v1/ai/providers/${encodeURIComponent(providerConfigId)}/models`, {
method: "POST",
body: JSON.stringify(payload),
}),
deleteAiModel: (providerConfigId: string, modelConfigId: string) =>
request<AiSettings>(`/api/v1/ai/providers/${encodeURIComponent(providerConfigId)}/models/${encodeURIComponent(modelConfigId)}`, {
method: "DELETE",
}),
updateDefaultAiModel: (modelConfigId: string | null) =>
request<AiSettings>("/api/v1/ai/default-model", {
method: "PUT",
body: JSON.stringify({ modelConfigId }),
}),
updateAiTagSuggestionPrompt: (payload: AiTagSuggestionPromptUpdateInput, locale?: string) =>
request<AiSettings>(`/api/v1/ai/tag-suggestion-prompt${locale ? `?locale=${encodeURIComponent(locale)}` : ""}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
listAiPrompts: (locale?: string) => {
const search = locale ? `?locale=${encodeURIComponent(locale)}` : "";
return request<{ prompts: AiPromptTemplate[] }>(`/api/v1/ai/prompts${search}`);
},
createAiPrompt: (payload: AiPromptTemplateCreateInput) =>
request<{ prompt: AiPromptTemplate }>("/api/v1/ai/prompts", {
method: "POST",
body: JSON.stringify(payload),
}),
updateAiPrompt: (
promptId: string,
payload: AiPromptTemplateUpdateInput,
) =>
request<{ prompt: AiPromptTemplate }>(`/api/v1/ai/prompts/${encodeURIComponent(promptId)}`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
deleteAiPrompt: (promptId: string) =>
request<{ ok: true }>(`/api/v1/ai/prompts/${encodeURIComponent(promptId)}`, {
method: "DELETE",
}),
restoreDefaultAiPrompts: (locale?: string) => {
const search = locale ? `?locale=${encodeURIComponent(locale)}` : "";
return request<{ prompts: AiPromptTemplate[]; restoredCount: number }>(`/api/v1/ai/prompts/restore-defaults${search}`, {
method: "POST",
body: JSON.stringify({}),
});
},
suggestAiTags: (payload: AiTagSuggestionsRequestInput, signal?: AbortSignal) =>
request<AiTagSuggestionsResponse>("/api/v1/ai/tag-suggestions", {
method: "POST",
body: JSON.stringify(payload),
signal,
}),
streamAiGeneration: async (
payload: AiGenerateInput,
streamOptions: { signal?: AbortSignal; onEvent: (event: AiStreamEvent) => void },
) => {
const path = "/api/v1/ai/generate";
const { context, response } = await send(path, {
method: "POST",
body: JSON.stringify(payload),
signal: streamOptions.signal,
});
if (!response.ok) {
await throwRequestError(context, response);
}
if (!response.body) throw new ApiRequestError("Streaming response is unavailable", 502, "ai_stream_unavailable");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
const frames = buffer.split("\n\n");
buffer = frames.pop() ?? "";
for (const frame of frames) {
const data = frame.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
if (data) streamOptions.onEvent(JSON.parse(data) as AiStreamEvent);
}
if (done) break;
}
const trailingData = buffer.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
if (trailingData) streamOptions.onEvent(JSON.parse(trailingData) as AiStreamEvent);
},
listUsers: () => request<ListUsersResponse>("/api/v1/users"),
createUser: (payload: { username: string; displayName?: string | null; password: string }) =>
request<UserResponse>("/api/v1/users", {
method: "POST",
body: JSON.stringify(payload),
}),
updateUser: (userId: string, payload: { displayName?: string | null; password?: string; isDisabled?: boolean }) =>
request<UserResponse>(`/api/v1/users/${userId}`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
logout: () =>
request<{ ok: true }>("/api/v1/auth/logout", {
method: "POST",
body: JSON.stringify({}),
}),
listNotebooks: () => request<ListNotebooksResponse>("/api/v1/notebooks"),
syncBootstrap: (params?: { afterId?: string | null; limit?: number }) => {
const search = new URLSearchParams();
if (params?.afterId) search.set("afterId", params.afterId);
if (params?.limit) search.set("limit", String(params.limit));
const suffix = search.toString() ? `?${search.toString()}` : "";
return request<SyncBootstrapResponse>(`/api/v1/sync/bootstrap${suffix}`);
},
syncChanges: (params: { cursor: number; limit?: number }) => {
const search = new URLSearchParams({ cursor: String(params.cursor) });
if (params.limit) search.set("limit", String(params.limit));
return request<SyncChangesResponse>(`/api/v1/sync/changes?${search.toString()}`);
},
getMobileSyncBootstrapPage: (afterId: string | null = null, limit = 100) => {
const search = new URLSearchParams({ limit: String(limit) });
if (afterId) {
search.set("afterId", afterId);
}
return request<MobileSyncBootstrapPage>(`/api/v1/sync/bootstrap?${search.toString()}`);
},
getMobileSyncChanges: (cursor: number, limit = 100) =>
request<MobileSyncChangesPage>(`/api/v1/sync/changes?cursor=${cursor}&limit=${limit}`),
createNotebook: (payload: { name: string; parentId?: string | null }) =>
request<NotebookResponse>("/api/v1/notebooks", {
method: "POST",
body: JSON.stringify(payload),
}),
updateNotebook: (notebookId: string, payload: { name?: string; parentId?: string | null; sortOrder?: number }) =>
request<NotebookResponse>(`/api/v1/notebooks/${notebookId}`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
deleteNotebook: (notebookId: string) =>
request<{ ok: true }>(`/api/v1/notebooks/${notebookId}`, {
method: "DELETE",
}),
restoreNotebook: (notebookId: string) =>
request<NotebookResponse>(`/api/v1/notebooks/${notebookId}/restore`, {
method: "POST",
}),
listTags: () => request<ListTagsResponse>("/api/v1/tags"),
renameTag: (tag: string, name: string) =>
request<{ ok: true; updated: number }>(`/api/v1/tags/${encodeURIComponent(tag)}`, {
method: "PATCH",
body: JSON.stringify({ name }),
}),
deleteTag: (tag: string) =>
request<{ ok: true; updated: number }>(`/api/v1/tags/${encodeURIComponent(tag)}`, {
method: "DELETE",
}),
listApiTokens: () => request<ListApiTokensResponse>("/api/v1/api-tokens"),
createApiToken: (payload: { name: string; scopes: string[]; expiresAt?: string | null }) =>
request<CreatedApiToken>("/api/v1/api-tokens", {
method: "POST",
body: JSON.stringify(payload),
}),
revokeApiToken: (tokenId: string) =>
request<{ ok: true }>(`/api/v1/api-tokens/${tokenId}`, {
method: "DELETE",
}),
listMemos: (params: {
notebookId?: string | null;
includeDescendants?: boolean;
q?: string;
tag?: string;
trash?: boolean;
sort?: MemoSortMode;
filter?: MemoFilterMode;
cursor?: string | null;
limit?: number;
}) => {
const search = new URLSearchParams();
if (params.notebookId) {
search.set("notebookId", params.notebookId);
}
if (params.includeDescendants) {
search.set("includeDescendants", "1");
}
if (params.q?.trim()) {
search.set("q", params.q.trim());
}
if (params.tag?.trim()) {
search.set("tag", params.tag.trim());
}
if (params.trash) {
search.set("trash", "1");
}
if (params.sort) {
search.set("sort", params.sort);
}
if (params.filter && params.filter !== "all") {
search.set("filter", params.filter);
}
if (params.cursor) {
search.set("cursor", params.cursor);
}
if (params.limit) {
search.set("limit", String(params.limit));
}
return request<ListMemosResponse>(`/api/v1/memos?${search.toString()}`);
},
createMemo: (payload: {
notebookId: string;
title?: string;
contentMarkdown?: string;
tags?: string[];
createdAt?: string;
updatedAt?: string;
}) =>
request<MemoResponse>("/api/v1/memos", {
method: "POST",
body: JSON.stringify(payload),
}),
listTemplates: () => request<ListTemplatesResponse>("/api/v1/templates"),
createTemplate: (payload: {
name: string;
description?: string | null;
memoId?: string;
title?: string | null;
contentMarkdown?: string;
tags?: string[];
}) => request<TemplateResponse>("/api/v1/templates", {
method: "POST",
body: JSON.stringify(payload),
}),
updateTemplate: (templateId: string, payload: {
name?: string;
description?: string | null;
title?: string | null;
contentMarkdown?: string;
tags?: string[];
}) => request<TemplateResponse>(`/api/v1/templates/${templateId}`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
useTemplate: (templateId: string, notebookId: string) =>
request<MemoResponse>(`/api/v1/templates/${templateId}/use`, {
method: "POST",
body: JSON.stringify({ notebookId }),
}),
deleteTemplate: (templateId: string) =>
request<{ ok: true }>(`/api/v1/templates/${templateId}`, { method: "DELETE" }),
moveMemos: (payload: { memoIds: string[]; notebookId: string }) =>
request<{ ok: true; moved: number }>("/api/v1/memos/batch/move", {
method: "POST",
body: JSON.stringify(payload),
}),
deleteMemos: (payload: { memoIds: string[]; permanent?: boolean }) =>
request<{ ok: true; deleted: number }>("/api/v1/memos/batch/delete", {
method: "POST",
body: JSON.stringify(payload),
}),
emptyTrash: () =>
request<{ ok: true; deleted: number }>("/api/v1/memos/trash/empty", {
method: "DELETE",
}),
getMemo: (memoId: string, options?: { includeDeleted?: boolean }) => {
const search = new URLSearchParams();
if (options?.includeDeleted) {
search.set("includeDeleted", "1");
}
const suffix = search.toString() ? `?${search.toString()}` : "";
return request<MemoResponse>(`/api/v1/memos/${memoId}${suffix}`);
},
getMemoShare: (memoId: string) =>
request<MemoShareResponse>(`/api/v1/memos/${memoId}/share`),
createMemoShare: (memoId: string) =>
request<{ share: MemoShare }>(`/api/v1/memos/${memoId}/share`, {
method: "POST",
body: JSON.stringify({}),
}),
revokeMemoShare: (memoId: string) =>
request<{ ok: true }>(`/api/v1/memos/${memoId}/share`, { method: "DELETE" }),
createMemoEditSession: (memoId: string) =>
request<{ editSession: MemoEditSession }>(`/api/v1/memos/${memoId}/edit-sessions`, {
method: "POST",
body: JSON.stringify({}),
}),
listMemoRevisions: (memoId: string) => request<ListMemoRevisionsResponse>(`/api/v1/memos/${memoId}/revisions`),
restoreMemoRevision: (memoId: string, revisionId: string) =>
request<MemoResponse>(`/api/v1/memos/${memoId}/revisions/${revisionId}/restore`, {
method: "POST",
body: JSON.stringify({}),
}),
listResources: () => request<ListResourcesResponse>("/api/v1/resources"),
renameResource: (resourceId: string, filename: string) =>
request<ResourceResponse>(`/api/v1/resources/${encodeURIComponent(resourceId)}`, {
method: "PATCH",
body: JSON.stringify({ filename }),
}),
deleteResource: (resourceId: string) =>
request<{ ok: true }>(`/api/v1/resources/${encodeURIComponent(resourceId)}`, {
method: "DELETE",
}),
getMarkdownExportPage: (offset = 0, limit = 50) =>
request<MarkdownExportPage>(`/api/v1/exports/markdown?offset=${offset}&limit=${limit}`),
getJsonBackupPage: (offset = 0, limit = 25) =>
request<JsonBackupPage>(`/api/v1/backups/json?offset=${offset}&limit=${limit}`),
restoreJsonNotebooks: (notebooks: JsonBackupNotebook[]) =>
request<{ ok: true }>("/api/v1/restores/json/notebooks", {
method: "POST",
body: JSON.stringify({ notebooks }),
}),
restoreJsonMemos: (memos: JsonBackupMemo[]) =>
request<{ ok: true }>("/api/v1/restores/json/memos", {
method: "POST",
body: JSON.stringify({ memos }),
}),
restoreJsonAiPrompts: (prompts: JsonBackupAiPrompt[]) =>
request<{ ok: true }>("/api/v1/restores/json/ai-prompts", {
method: "POST",
body: JSON.stringify({ prompts }),
}),
restoreJsonResource: (resourceId: string, metadata: JsonBackupMemo["resources"][number], file: Blob) => {
const form = new FormData();
form.append("metadata", JSON.stringify(metadata));
form.append("file", file, metadata.filename || metadata.id);
return request<{ ok: true }>(`/api/v1/restores/json/resources/${encodeURIComponent(resourceId)}`, {
method: "PUT",
body: form,
});
},
getResourceBlob: (resourceUrl: string) => requestBlob(resourceUrl),
downloadGithubPluginAsset: (
owner: string,
repository: string,
assetId: number,
assetName: "manifest.json" | "main.js" | "styles.css",
) => requestArrayBuffer(
`/api/v1/plugins/github/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/assets/${assetId}/${encodeURIComponent(assetName)}`,
),
uploadMemoResource: (memoId: string, file: Blob | FormData) => {
const form = file instanceof FormData ? file : new FormData();
if (!(file instanceof FormData)) form.append("file", file);
return request<ResourceResponse>(`/api/v1/memos/${memoId}/resources`, {
method: "POST",
body: form,
});
},
updateMemo: (
memoId: string,
payload: {
expectedRevision?: number;
expectedContentHash?: string;
editSessionId?: string;
notebookId?: string;
title?: string;
isPinned?: boolean;
contentJson?: TiptapDoc;
contentMarkdown?: string;
tags?: string[];
allowDestructiveOverwrite?: boolean;
}
) =>
request<MemoResponse>(`/api/v1/memos/${memoId}`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
deleteMemo: (memoId: string, options?: { permanent?: boolean }) => {
const search = new URLSearchParams();
if (options?.permanent) {
search.set("permanent", "1");
}
const suffix = search.toString() ? `?${search.toString()}` : "";
return request<{ ok: true }>(`/api/v1/memos/${memoId}${suffix}`, {
method: "DELETE",
});
},
restoreMemo: (memoId: string) =>
request<MemoResponse>(`/api/v1/memos/${memoId}/restore`, {
method: "POST",
body: JSON.stringify({}),
}),
mergeMemos: (payload: { memoIds: string[]; notebookId?: string; title?: string }) =>
request<MemoResponse>("/api/v1/memos/merge", {
method: "POST",
body: JSON.stringify(payload),
}),
resetDemo: () =>
request<{ success: true }>("/api/v1/demo/reset", {
method: "POST",
}),
};
};
const normalizeBaseUrl = (value?: string) => {
if (!value) {
return "";
}
return value.replace(/\/+$/, "");
};
const isAbsoluteHttpUrl = (value: string) => /^https?:\/\//i.test(value);