12f71fabdf
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 44s
Frontend half of the narrowing (milestone #194); matches backend b49efdc.
- delete CalendarView, EventSlideOver, WeatherCard (orphan)
- drop /calendar route + nav link + the g→l keyboard shortcut
- strip the calendar-events API client + event/metadata bits from note
types and the notes store
- KnowledgeView: remove People/Places/Lists tabs, entity cards, create
buttons and the upcoming-events widget; keep notes/tasks/plans/processes
+ the overdue-task badge
- NoteEditorView: remove person/place/list forms + list-builder + entity
metadata; keep note + process editors (type select = Note/Process)
- DashboardView: drop the "Upcoming · 7 days" events rail card
- SettingsView: remove the CalDAV integration card + save/test (its
endpoints were deleted backend-side)
- prune the now-dead entity/event CSS
RecurrenceEditor + task recurrence rules are kept (task machinery, not
calendar). Verified by a full dangler sweep.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPtbSzA4JLMAKgFZ8VTg7Q
429 lines
14 KiB
TypeScript
429 lines
14 KiB
TypeScript
export class ApiError extends Error {
|
|
status: number;
|
|
body: Record<string, unknown>;
|
|
|
|
constructor(status: number, body: Record<string, unknown>) {
|
|
const msg = (body.error as string) || `API error: ${status}`;
|
|
super(msg);
|
|
this.name = "ApiError";
|
|
this.status = status;
|
|
this.body = body;
|
|
}
|
|
}
|
|
|
|
async function handleResponse<T>(res: Response, path: string): Promise<T> {
|
|
if (!res.ok) {
|
|
let body: Record<string, unknown> = {};
|
|
try {
|
|
body = await res.json();
|
|
} catch {
|
|
body = { error: `API error: ${res.status}` };
|
|
}
|
|
|
|
// Redirect to login on 401 (except for auth endpoints)
|
|
if (res.status === 401 && !path.startsWith("/api/auth/")) {
|
|
const { default: router } = await import("@/router/index");
|
|
const currentPath = window.location.pathname;
|
|
if (currentPath !== "/login" && currentPath !== "/register") {
|
|
router.push({ name: "login", query: { redirect: currentPath } });
|
|
}
|
|
}
|
|
|
|
throw new ApiError(res.status, body);
|
|
}
|
|
// Handle 204 No Content
|
|
if (res.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
export async function apiGet<T>(path: string): Promise<T> {
|
|
const res = await fetch(path);
|
|
return handleResponse<T>(res, path);
|
|
}
|
|
|
|
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
|
const res = await fetch(path, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return handleResponse<T>(res, path);
|
|
}
|
|
|
|
export async function apiPut<T>(path: string, body: unknown): Promise<T> {
|
|
const res = await fetch(path, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return handleResponse<T>(res, path);
|
|
}
|
|
|
|
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
|
|
const res = await fetch(path, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return handleResponse<T>(res, path);
|
|
}
|
|
|
|
export async function apiDelete(path: string): Promise<void> {
|
|
const res = await fetch(path, { method: "DELETE" });
|
|
return handleResponse<void>(res, path);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sharing, Groups, Notifications, User search
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface ShareEntry {
|
|
id: number
|
|
permission: string
|
|
shared_with_user_id: number | null
|
|
shared_with_group_id: number | null
|
|
username?: string
|
|
group_name?: string
|
|
invited_by: number
|
|
created_at: string
|
|
}
|
|
|
|
export interface GroupEntry {
|
|
id: number
|
|
name: string
|
|
description: string | null
|
|
created_by: number | null
|
|
member_count: number
|
|
is_member: boolean
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
export interface GroupMember {
|
|
id: number
|
|
group_id: number
|
|
user_id: number
|
|
role: string
|
|
username: string
|
|
email: string | null
|
|
created_at: string
|
|
}
|
|
|
|
export interface NotificationEntry {
|
|
id: number
|
|
user_id: number
|
|
type: string
|
|
payload: Record<string, unknown>
|
|
read_at: string | null
|
|
created_at: string
|
|
}
|
|
|
|
export interface UserSearchResult {
|
|
id: number
|
|
username: string
|
|
}
|
|
|
|
// --- User search ---
|
|
export const searchUsers = (q: string) =>
|
|
apiGet<{ users: UserSearchResult[] }>(`/api/users/search?q=${encodeURIComponent(q)}`).then(r => r.users)
|
|
|
|
// --- Groups ---
|
|
export const listGroups = () => apiGet<{ groups: GroupEntry[] }>('/api/groups').then(r => r.groups)
|
|
export const createGroup = (name: string, description?: string) =>
|
|
apiPost<GroupEntry>('/api/groups', { name, description })
|
|
export const updateGroup = (id: number, data: { name?: string; description?: string }) =>
|
|
apiPatch<GroupEntry>(`/api/groups/${id}`, data)
|
|
export const deleteGroup = (id: number) => apiDelete(`/api/groups/${id}`)
|
|
export const getGroupDetail = (id: number) =>
|
|
apiGet<GroupEntry & { members: GroupMember[] }>(`/api/groups/${id}`)
|
|
export const listGroupMembers = (id: number) =>
|
|
apiGet<{ members: GroupMember[] }>(`/api/groups/${id}/members`).then(r => r.members)
|
|
export const addGroupMember = (groupId: number, userId: number, role: string) =>
|
|
apiPost<GroupMember>(`/api/groups/${groupId}/members`, { user_id: userId, role })
|
|
export const updateGroupMember = (groupId: number, userId: number, role: string) =>
|
|
apiPatch<GroupMember>(`/api/groups/${groupId}/members/${userId}`, { role })
|
|
export const removeGroupMember = (groupId: number, userId: number) =>
|
|
apiDelete(`/api/groups/${groupId}/members/${userId}`)
|
|
|
|
// --- Project shares ---
|
|
export const listProjectShares = (projectId: number) =>
|
|
apiGet<{ shares: ShareEntry[] }>(`/api/projects/${projectId}/shares`).then(r => r.shares)
|
|
export const createProjectShare = (projectId: number, body: { user_id?: number; group_id?: number; permission: string }) =>
|
|
apiPost<ShareEntry>(`/api/projects/${projectId}/shares`, body)
|
|
export const updateProjectShare = (projectId: number, shareId: number, permission: string) =>
|
|
apiPatch<ShareEntry>(`/api/projects/${projectId}/shares/${shareId}`, { permission })
|
|
export const deleteProjectShare = (projectId: number, shareId: number) =>
|
|
apiDelete(`/api/projects/${projectId}/shares/${shareId}`)
|
|
|
|
// --- Note / task shares ---
|
|
export const listNoteShares = (noteId: number) =>
|
|
apiGet<{ shares: ShareEntry[] }>(`/api/notes/${noteId}/shares`).then(r => r.shares)
|
|
export const createNoteShare = (noteId: number, body: { user_id?: number; group_id?: number; permission: string }) =>
|
|
apiPost<ShareEntry>(`/api/notes/${noteId}/shares`, body)
|
|
export const updateNoteShare = (noteId: number, shareId: number, permission: string) =>
|
|
apiPatch<ShareEntry>(`/api/notes/${noteId}/shares/${shareId}`, { permission })
|
|
export const deleteNoteShare = (noteId: number, shareId: number) =>
|
|
apiDelete(`/api/notes/${noteId}/shares/${shareId}`)
|
|
|
|
// --- Shared-with-me ---
|
|
export const getSharedWithMe = () =>
|
|
apiGet<{ projects: Record<string, unknown>[]; notes: Record<string, unknown>[] }>('/api/shared-with-me')
|
|
|
|
// --- In-app notifications ---
|
|
export const getNotifications = (all = false) =>
|
|
apiGet<{ notifications: NotificationEntry[] }>(`/api/notifications${all ? '?all=true' : ''}`).then(r => r.notifications)
|
|
export const getNotificationCount = () =>
|
|
apiGet<{ count: number }>('/api/notifications/count').then(r => r.count)
|
|
export const markNotificationRead = (id: number) => apiPost<void>(`/api/notifications/${id}/read`, {})
|
|
export const markAllNotificationsRead = () => apiPost<{ marked: number }>('/api/notifications/read-all', {})
|
|
|
|
export interface SSEStreamHandle {
|
|
close(): void;
|
|
/** Resolves when the stream closes (normally or via error/abort). */
|
|
done: Promise<void>;
|
|
}
|
|
|
|
export interface SSENamedEvent {
|
|
id: number;
|
|
event: string;
|
|
data: Record<string, unknown>;
|
|
}
|
|
|
|
export function apiSSEStream(
|
|
path: string,
|
|
onEvent: (event: SSENamedEvent) => void,
|
|
options?: { lastEventId?: number; signal?: AbortSignal },
|
|
): SSEStreamHandle {
|
|
const controller = new AbortController();
|
|
const combinedSignal = options?.signal
|
|
? AbortSignal.any([controller.signal, options.signal])
|
|
: controller.signal;
|
|
|
|
const headers: Record<string, string> = {};
|
|
if (options?.lastEventId !== undefined) {
|
|
headers["Last-Event-ID"] = String(options.lastEventId);
|
|
}
|
|
|
|
const done = (async () => {
|
|
const res = await fetch(path, { headers, signal: combinedSignal });
|
|
if (!res.ok) {
|
|
let body: Record<string, unknown> = {};
|
|
try {
|
|
body = await res.json();
|
|
} catch {
|
|
body = { error: `API error: ${res.status}` };
|
|
}
|
|
if (res.status === 401 && !path.startsWith("/api/auth/")) {
|
|
const { default: router } = await import("@/router/index");
|
|
router.push({ name: "login" });
|
|
}
|
|
throw new ApiError(res.status, body);
|
|
}
|
|
const reader = res.body?.getReader();
|
|
if (!reader) throw new Error("No response body");
|
|
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
|
|
// SSE field parsing state
|
|
let currentId = -1;
|
|
let currentEvent = "message";
|
|
let currentData = "";
|
|
|
|
function dispatch() {
|
|
if (!currentData) return;
|
|
let parsed: Record<string, unknown>;
|
|
try {
|
|
parsed = JSON.parse(currentData);
|
|
} catch {
|
|
return;
|
|
}
|
|
onEvent({ id: currentId, event: currentEvent, data: parsed });
|
|
// Reset for next event
|
|
currentEvent = "message";
|
|
currentData = "";
|
|
}
|
|
|
|
function processLine(line: string) {
|
|
if (line === "") {
|
|
// Empty line = end of event
|
|
dispatch();
|
|
return;
|
|
}
|
|
if (line.startsWith(":")) return; // comment / keepalive
|
|
const colonIdx = line.indexOf(":");
|
|
let field: string;
|
|
let value: string;
|
|
if (colonIdx === -1) {
|
|
field = line;
|
|
value = "";
|
|
} else {
|
|
field = line.slice(0, colonIdx);
|
|
value = line.slice(colonIdx + 1);
|
|
if (value.startsWith(" ")) value = value.slice(1);
|
|
}
|
|
switch (field) {
|
|
case "id":
|
|
currentId = parseInt(value, 10);
|
|
break;
|
|
case "event":
|
|
currentEvent = value;
|
|
break;
|
|
case "data":
|
|
currentData += value;
|
|
break;
|
|
}
|
|
}
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop() || "";
|
|
for (const line of lines) {
|
|
processLine(line);
|
|
}
|
|
}
|
|
// Process remaining buffer
|
|
if (buffer) {
|
|
processLine(buffer);
|
|
}
|
|
dispatch();
|
|
})().catch(() => {
|
|
// Stream closed or aborted — handled by caller via reconnection
|
|
});
|
|
|
|
return { close: () => controller.abort(), done };
|
|
}
|
|
|
|
export async function apiStreamPost(
|
|
path: string,
|
|
body: unknown,
|
|
onChunk: (data: Record<string, unknown>) => void
|
|
): Promise<void> {
|
|
const res = await fetch(path, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) {
|
|
let errBody: Record<string, unknown> = {};
|
|
try {
|
|
errBody = await res.json();
|
|
} catch {
|
|
errBody = { error: `API error: ${res.status}` };
|
|
}
|
|
|
|
if (res.status === 401 && !path.startsWith("/api/auth/")) {
|
|
const { default: router } = await import("@/router/index");
|
|
router.push({ name: "login" });
|
|
}
|
|
|
|
throw new ApiError(res.status, errBody);
|
|
}
|
|
const reader = res.body?.getReader();
|
|
if (!reader) throw new Error("No response body");
|
|
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
|
|
function processLine(line: string) {
|
|
const trimmed = line.trim();
|
|
if (trimmed.startsWith("data: ")) {
|
|
let data;
|
|
try {
|
|
data = JSON.parse(trimmed.slice(6));
|
|
} catch {
|
|
return; // Skip malformed JSON lines
|
|
}
|
|
onChunk(data);
|
|
}
|
|
}
|
|
|
|
function processBuffer() {
|
|
const lines = buffer.split("\n");
|
|
// Keep the last (possibly incomplete) line in the buffer
|
|
buffer = lines.pop() || "";
|
|
for (const line of lines) {
|
|
processLine(line);
|
|
}
|
|
}
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
processBuffer();
|
|
}
|
|
} catch {
|
|
// Stream may close with a network error after all data was sent.
|
|
}
|
|
|
|
// Flush any remaining complete lines in the buffer
|
|
if (buffer.trim()) {
|
|
// The buffer may contain one or more unprocessed lines
|
|
const remaining = buffer;
|
|
buffer = "";
|
|
for (const line of remaining.split("\n")) {
|
|
processLine(line);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── API Keys ─────────────────────────────────────────────────────────────────
|
|
|
|
export interface ApiKeyEntry {
|
|
id: number
|
|
name: string
|
|
scope: string
|
|
key_prefix: string
|
|
last_used_at: string | null
|
|
}
|
|
|
|
export const listApiKeys = () =>
|
|
apiGet<{ api_keys: ApiKeyEntry[] }>('/api/api-keys').then(r => r.api_keys)
|
|
|
|
export const createApiKey = (name: string, scope: 'read' | 'write') =>
|
|
apiPost<{ key: string; api_key: ApiKeyEntry }>('/api/api-keys', { name, scope })
|
|
|
|
export const revokeApiKey = (id: number) => apiDelete(`/api/api-keys/${id}`)
|
|
|
|
// ── User Profile ─────────────────────────────────────────────────────────────
|
|
|
|
export interface UserProfile {
|
|
display_name: string
|
|
job_title: string
|
|
industry: string
|
|
expertise_level: 'novice' | 'intermediate' | 'expert'
|
|
response_style: 'concise' | 'balanced' | 'detailed'
|
|
tone: 'casual' | 'professional' | 'technical'
|
|
interests: string[]
|
|
work_schedule: { days?: string[]; start?: string; end?: string }
|
|
}
|
|
|
|
export const getProfile = () => apiGet<UserProfile>('/api/profile')
|
|
export const updateProfile = (data: Partial<UserProfile>) =>
|
|
apiPut<UserProfile>('/api/profile', data)
|
|
|
|
|
|
// ── Note Versions (pinning) ──────────────────────────────────────────────────
|
|
|
|
import type { NoteVersion } from '../types/task'
|
|
|
|
/** Mark a note version as manually pinned, optionally with a commit-note
|
|
* label. Re-calling with a different label updates the label. */
|
|
export const pinNoteVersion = (noteId: number, versionId: number, label?: string | null) =>
|
|
apiPost<NoteVersion>(
|
|
`/api/notes/${noteId}/versions/${versionId}/pin`,
|
|
{ label: label ?? null },
|
|
)
|
|
|
|
/** Downgrade a manually-pinned version back to rolling. Does NOT delete
|
|
* the row — older rows may be FIFO-pruned by the next autosave. */
|
|
export const unpinNoteVersion = (noteId: number, versionId: number) =>
|
|
apiDelete(`/api/notes/${noteId}/versions/${versionId}/pin`)
|