Files
edgeever/apps/web/src/lib/api.ts
T

553 lines
17 KiB
TypeScript

import type {
AuthSession,
LoginDeviceSession,
InstanceUser,
ApiToken,
CreatedApiToken,
JsonBackupMemo,
JsonBackupNotebook,
JsonBackupRevision,
MemoDetail,
MemoTemplate,
MemoEditSession,
MemoRevision,
MemoSummary,
Notebook,
Resource,
ResourceListItem,
ResourceStorageSummary,
TagSummary,
TiptapDoc,
SyncBootstrapResponse,
SyncChangesResponse,
} from "@edgeever/shared";
import type { MemoFilterMode, MemoSortMode } from "./app-helpers";
type ListNotebooksResponse = {
notebooks: Notebook[];
};
type ListMemosResponse = {
memos: MemoSummary[];
totalCount: number;
nextCursor: string | null;
};
type ListMemoRevisionsResponse = {
revisions: MemoRevision[];
};
type ListResourcesResponse = {
resources: ResourceListItem[];
summary: ResourceStorageSummary;
};
type ListTagsResponse = {
tags: TagSummary[];
};
export type { SyncBootstrapResponse, SyncChangesResponse };
type ListApiTokensResponse = {
apiTokens: ApiToken[];
availableScopes: string[];
};
type ListUsersResponse = { users: InstanceUser[] };
type UserResponse = { user: InstanceUser };
type ListLoginDeviceSessionsResponse = { sessions: LoginDeviceSession[] };
const WEB_DEVICE_ID_STORAGE_KEY = "edgeever.web.device-id";
export const DESKTOP_API_BASE_URL_STORAGE_KEY = "edgeever.desktop.api-base-url";
const DESKTOP_SESSION_STORAGE_KEY = "edgeever.desktop.session";
export const getCachedDesktopSession = (): AuthSession | null => {
if (typeof window === "undefined" || !window.edgeeverDesktop?.isAvailable) return null;
try {
const value = window.localStorage.getItem(DESKTOP_SESSION_STORAGE_KEY);
if (!value) return null;
const parsed = JSON.parse(value) as AuthSession;
return parsed && typeof parsed === "object" && "authenticated" in parsed ? parsed : null;
} catch {
return null;
}
};
export const cacheDesktopSession = (session: AuthSession) => {
if (typeof window === "undefined" || !window.edgeeverDesktop?.isAvailable) return;
try {
window.localStorage.setItem(DESKTOP_SESSION_STORAGE_KEY, JSON.stringify(session));
} catch {
// A session cache is an offline convenience and must never block login.
}
};
export const clearCachedDesktopSession = () => {
if (typeof window === "undefined" || !window.edgeeverDesktop?.isAvailable) return;
try {
window.localStorage.removeItem(DESKTOP_SESSION_STORAGE_KEY);
} catch {
// Ignore restricted storage contexts.
}
};
export const getConfiguredDesktopApiBaseUrl = () => {
if (typeof window === "undefined") return "";
const bridgeUrl = (window.edgeeverDesktop?.apiBaseUrl ?? "").trim();
if (bridgeUrl) return bridgeUrl.replace(/\/$/, "");
try {
return (window.localStorage.getItem(DESKTOP_API_BASE_URL_STORAGE_KEY) ?? "").trim().replace(/\/$/, "");
} catch {
return "";
}
};
export const isDesktopInstanceConfigurationRequired = () =>
typeof window !== "undefined" &&
Boolean(window.edgeeverDesktop?.isAvailable) &&
window.location.protocol === "file:" &&
!getConfiguredDesktopApiBaseUrl();
export const saveDesktopApiBaseUrl = (value: string) => {
const normalized = value.trim().replace(/\/$/, "");
const parsed = new URL(normalized);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("Desktop instance URL must use http or https");
}
window.localStorage.setItem(DESKTOP_API_BASE_URL_STORAGE_KEY, normalized);
void window.edgeeverDesktop?.setApiBaseUrl(normalized);
return normalized;
};
const createWebDeviceId = () => {
const uuid = globalThis.crypto?.randomUUID?.();
return uuid
? `web-${uuid}`
: `web-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
};
const getOrCreateWebDeviceId = () => {
try {
const existing = window.localStorage.getItem(WEB_DEVICE_ID_STORAGE_KEY);
if (existing) return existing;
const deviceId = createWebDeviceId();
window.localStorage.setItem(WEB_DEVICE_ID_STORAGE_KEY, deviceId);
return deviceId;
} catch {
return createWebDeviceId();
}
};
type MemoResponse = {
memo: MemoDetail;
};
type TemplateResponse = {
template: MemoTemplate;
};
type NotebookResponse = {
notebook: Notebook;
};
type ResourceResponse = {
resource: Resource;
};
export type MarkdownExportPage = {
memos: MemoDetail[];
resources: Resource[];
totalCount: number;
nextOffset: number | null;
};
export type JsonBackupPage = MarkdownExportPage & {
revisions: JsonBackupRevision[];
};
export class ApiRequestError extends Error {
status: number;
code?: string;
constructor(message: string, status: number, code?: string) {
super(message);
this.name = "ApiRequestError";
this.status = status;
this.code = code;
}
}
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
const headers = new Headers(init?.headers);
if (!(init?.body instanceof FormData) && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const baseUrl = getConfiguredDesktopApiBaseUrl();
const response = await fetch(`${baseUrl}${path}`, {
credentials: "include",
...init,
headers,
});
if (!response.ok) {
const body = await response.json().catch(() => null);
const error = body && typeof body === "object" && "error" in body ? (body as { error?: { code?: string; message?: string } }).error : undefined;
const message =
body && typeof body === "object" && "error" in body
? error?.message
: response.statusText;
if (response.status === 401) {
window.dispatchEvent(new CustomEvent("edgeever:unauthorized"));
}
throw new ApiRequestError(message || "Request failed", response.status, error?.code);
}
return response.json() as Promise<T>;
};
export const api = {
getSession: () => request<AuthSession>("/api/v1/auth/session"),
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: { username: string; password: string }) =>
request<AuthSession>("/api/v1/auth/login", {
method: "POST",
body: JSON.stringify({ ...payload, deviceId: getOrCreateWebDeviceId() }),
}),
changePassword: (payload: { currentPassword: string; newPassword: string; confirmPassword: string }) =>
request<{ ok: true }>("/api/v1/auth/change-password", {
method: "POST",
body: JSON.stringify(payload),
}),
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()}`);
},
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;
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.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<{ templates: MemoTemplate[] }>("/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}`);
},
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"),
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 }),
}),
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: async (resourceUrl: string) => {
const baseUrl = getConfiguredDesktopApiBaseUrl();
const response = await fetch(resourceUrl.startsWith("/") ? `${baseUrl}${resourceUrl}` : resourceUrl, { credentials: "include" });
if (!response.ok) {
if (response.status === 401) {
window.dispatchEvent(new CustomEvent("edgeever:unauthorized"));
}
throw new ApiRequestError(response.statusText || "Resource download failed", response.status);
}
return response.blob();
},
uploadMemoResource: (memoId: string, file: File) => {
const form = new 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",
}),
};