@hiai-gg/hiai-docs 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.all-contributorsrc +18 -0
- package/.claude/settings.local.json +61 -0
- package/.dockerignore +113 -0
- package/.env.example +68 -0
- package/.github/FUNDING.yml +5 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +74 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +78 -0
- package/.github/dependabot.yml +136 -0
- package/.github/pull_request_template.md +96 -0
- package/.github/workflows/ci.yml +283 -0
- package/AGENTS.md +237 -0
- package/CODE_OF_CONDUCT.md +134 -0
- package/CONTRIBUTING.md +77 -0
- package/Caddyfile +50 -0
- package/Dockerfile.backend +60 -0
- package/LICENSE +21 -0
- package/README.md +284 -0
- package/RELEASE_CHECKLIST.md +34 -0
- package/SECURITY.md +60 -0
- package/backend/package.json +43 -0
- package/backend/src/__tests__/auth-helpers.test.ts +51 -0
- package/backend/src/__tests__/chunker.test.ts +65 -0
- package/backend/src/__tests__/config.test.ts +91 -0
- package/backend/src/__tests__/csrf.test.ts +91 -0
- package/backend/src/__tests__/embedding.test.ts +48 -0
- package/backend/src/__tests__/rate-limit.test.ts +46 -0
- package/backend/src/__tests__/routes.test.ts +38 -0
- package/backend/src/__tests__/schema.test.ts +31 -0
- package/backend/src/__tests__/validation.test.ts +556 -0
- package/backend/src/api/middleware/auth.ts +56 -0
- package/backend/src/api/middleware/csrf.ts +91 -0
- package/backend/src/api/middleware/rate-limit.ts +77 -0
- package/backend/src/api/middleware/webhook-verify.ts +22 -0
- package/backend/src/api/routes/attachments.ts +280 -0
- package/backend/src/api/routes/auth.ts +52 -0
- package/backend/src/api/routes/collaboration.ts +121 -0
- package/backend/src/api/routes/documents.ts +664 -0
- package/backend/src/api/routes/folders.ts +226 -0
- package/backend/src/api/routes/search.ts +354 -0
- package/backend/src/api/routes/share.ts +512 -0
- package/backend/src/api/routes/tags.ts +247 -0
- package/backend/src/api/routes/versions.ts +99 -0
- package/backend/src/api/routes/webhooks.ts +43 -0
- package/backend/src/embedding/chunker.ts +74 -0
- package/backend/src/embedding/index.ts +117 -0
- package/backend/src/embedding/providers/ollama.ts +63 -0
- package/backend/src/embedding/providers/openrouter.ts +71 -0
- package/backend/src/embedding/utils.ts +13 -0
- package/backend/src/embedding/worker.ts +89 -0
- package/backend/src/index.ts +147 -0
- package/backend/src/lib/auth-helpers.ts +27 -0
- package/backend/src/lib/auth.ts +35 -0
- package/backend/src/lib/config.ts +73 -0
- package/backend/src/lib/db.ts +7 -0
- package/backend/src/lib/embedding-queue.ts +12 -0
- package/backend/src/lib/logger.ts +18 -0
- package/backend/src/lib/markdown-to-doc.ts +45 -0
- package/backend/src/lib/minio.ts +46 -0
- package/backend/src/lib/redis.ts +19 -0
- package/backend/src/lib/yjs-provider.ts +182 -0
- package/backend/tests/integration/_harness.ts +754 -0
- package/backend/tests/integration/auth.test.ts +296 -0
- package/backend/tests/integration/routes.documents.test.ts +459 -0
- package/backend/tests/integration/routes.folders.test.ts +337 -0
- package/backend/tests/integration/routes.search.test.ts +322 -0
- package/backend/tests/integration/routes.share.test.ts +773 -0
- package/backend/tests/integration/routes.tags.test.ts +425 -0
- package/backend/tests/integration/routes.versions.test.ts +233 -0
- package/backend/tsconfig.json +18 -0
- package/docker-compose.yml +218 -0
- package/docs/API.md +328 -0
- package/docs/ARCHITECTURE.md +75 -0
- package/docs/DEPLOYMENT.md +113 -0
- package/docs/PRODUCTION_STATUS.md +61 -0
- package/docs/openapi.json +385 -0
- package/frontend/.svelte-kit.old/ambient.d.ts +230 -0
- package/frontend/.svelte-kit.old/env.d.ts +1 -0
- package/frontend/.svelte-kit.old/generated/client/app.js +46 -0
- package/frontend/.svelte-kit.old/generated/client/matchers.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/0.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/1.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/10.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/2.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/3.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/4.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/5.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/6.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/7.js +3 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/8.js +1 -0
- package/frontend/.svelte-kit.old/generated/client/nodes/9.js +3 -0
- package/frontend/.svelte-kit.old/generated/root.js +3 -0
- package/frontend/.svelte-kit.old/generated/root.svelte +80 -0
- package/frontend/.svelte-kit.old/generated/server/internal.js +55 -0
- package/frontend/.svelte-kit.old/non-ambient.d.ts +59 -0
- package/frontend/.svelte-kit.old/tsconfig.json +59 -0
- package/frontend/.svelte-kit.old/types/route_meta_data.json +40 -0
- package/frontend/.svelte-kit.old/types/src/routes/$types.d.ts +21 -0
- package/frontend/.svelte-kit.old/types/src/routes/(app)/$types.d.ts +30 -0
- package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/$types.d.ts +27 -0
- package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/proxy+page.ts +25 -0
- package/frontend/.svelte-kit.old/types/src/routes/api/[...path]/$types.d.ts +10 -0
- package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/$types.d.ts +27 -0
- package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/proxy+page.ts +15 -0
- package/frontend/.svelte-kit.old/types/src/routes/login/$types.d.ts +17 -0
- package/frontend/.svelte-kit.old/types/src/routes/register/$types.d.ts +17 -0
- package/frontend/.svelte-kit.old/types/src/routes/s/[token]/$types.d.ts +20 -0
- package/frontend/.svelte-kit.old/types/src/routes/s/[token]/proxy+page.ts +6 -0
- package/frontend/.svelte-kit.old/types/src/routes/search/$types.d.ts +19 -0
- package/frontend/.svelte-kit.old/types/src/routes/search/proxy+page.ts +26 -0
- package/frontend/.svelte-kit.old/types/src/routes/settings/$types.d.ts +17 -0
- package/frontend/Dockerfile +44 -0
- package/frontend/biome.json +40 -0
- package/frontend/components.json +18 -0
- package/frontend/messages/en.json +434 -0
- package/frontend/package.json +70 -0
- package/frontend/project.inlang/settings.json +12 -0
- package/frontend/src/app.css +6 -0
- package/frontend/src/app.d.ts +13 -0
- package/frontend/src/app.html +30 -0
- package/frontend/src/hooks.server.ts +10 -0
- package/frontend/src/hooks.ts +10 -0
- package/frontend/src/lib/api/attachments.ts +45 -0
- package/frontend/src/lib/api/client.test.ts +15 -0
- package/frontend/src/lib/api/client.ts +57 -0
- package/frontend/src/lib/api/documents.ts +83 -0
- package/frontend/src/lib/api/folders.ts +180 -0
- package/frontend/src/lib/api/search.test.ts +52 -0
- package/frontend/src/lib/api/search.ts +128 -0
- package/frontend/src/lib/api/settings.ts +95 -0
- package/frontend/src/lib/api/share.ts +71 -0
- package/frontend/src/lib/api/tags.test.ts +91 -0
- package/frontend/src/lib/api/tags.ts +87 -0
- package/frontend/src/lib/auth-client.ts +10 -0
- package/frontend/src/lib/collaboration.ts +63 -0
- package/frontend/src/lib/components/AttachmentUpload.svelte +110 -0
- package/frontend/src/lib/components/DatePicker.svelte +322 -0
- package/frontend/src/lib/components/DocumentCard.svelte +166 -0
- package/frontend/src/lib/components/EmptyState.svelte +49 -0
- package/frontend/src/lib/components/FolderCard.svelte +93 -0
- package/frontend/src/lib/components/ScrollToTop.svelte +72 -0
- package/frontend/src/lib/components/SearchBar.svelte +47 -0
- package/frontend/src/lib/components/SearchResult.svelte +115 -0
- package/frontend/src/lib/components/SettingsDialog.svelte +271 -0
- package/frontend/src/lib/components/ShareDialog.svelte +158 -0
- package/frontend/src/lib/components/ShareLink.svelte +98 -0
- package/frontend/src/lib/components/TagCreateDialog.svelte +284 -0
- package/frontend/src/lib/components/VersionDiff.svelte +55 -0
- package/frontend/src/lib/components/VersionHistory.svelte +96 -0
- package/frontend/src/lib/components/editor/DocumentTitle.svelte +87 -0
- package/frontend/src/lib/components/editor/EditorToolbar.svelte +1367 -0
- package/frontend/src/lib/components/editor/HiAiEditor.svelte +531 -0
- package/frontend/src/lib/components/editor/LinkDialog.svelte +134 -0
- package/frontend/src/lib/components/editor/MarkdownToggle.svelte +88 -0
- package/frontend/src/lib/components/editor/editorExtensions.ts +53 -0
- package/frontend/src/lib/components/editor/markdown.ts +38 -0
- package/frontend/src/lib/components/sidebar/FolderTree.svelte +731 -0
- package/frontend/src/lib/components/sidebar/RecentDocs.svelte +311 -0
- package/frontend/src/lib/components/sidebar/Sidebar.svelte +156 -0
- package/frontend/src/lib/components/sidebar/TagList.svelte +200 -0
- package/frontend/src/lib/components/ui/confirm-dialog/ConfirmDialog.svelte +76 -0
- package/frontend/src/lib/components/ui/confirm-dialog/index.ts +1 -0
- package/frontend/src/lib/stores/tag-store.svelte.ts +56 -0
- package/frontend/src/lib/stores/theme.svelte.ts +97 -0
- package/frontend/src/lib/svelte.d.ts +6 -0
- package/frontend/src/lib/types.ts +44 -0
- package/frontend/src/lib/utils/clipboard.ts +17 -0
- package/frontend/src/lib/utils/strip-markdown.ts +59 -0
- package/frontend/src/lib/utils.ts +33 -0
- package/frontend/src/routes/(app)/+layout.svelte +17 -0
- package/frontend/src/routes/(app)/+page.server.ts +10 -0
- package/frontend/src/routes/(app)/+page.svelte +303 -0
- package/frontend/src/routes/(app)/docs/[id]/+page.server.ts +10 -0
- package/frontend/src/routes/(app)/docs/[id]/+page.svelte +1108 -0
- package/frontend/src/routes/(app)/docs/[id]/+page.ts +24 -0
- package/frontend/src/routes/(app)/search/+page.svelte +593 -0
- package/frontend/src/routes/(app)/search/+page.ts +25 -0
- package/frontend/src/routes/+error.svelte +12 -0
- package/frontend/src/routes/+layout.svelte +18 -0
- package/frontend/src/routes/+layout.ts +2 -0
- package/frontend/src/routes/api/[...path]/+server.ts +111 -0
- package/frontend/src/routes/folders/[id]/+page.server.ts +10 -0
- package/frontend/src/routes/folders/[id]/+page.svelte +319 -0
- package/frontend/src/routes/folders/[id]/+page.ts +14 -0
- package/frontend/src/routes/login/+page.svelte +90 -0
- package/frontend/src/routes/register/+page.svelte +97 -0
- package/frontend/src/routes/s/[token]/+page.svelte +496 -0
- package/frontend/src/routes/s/[token]/+page.ts +5 -0
- package/frontend/src/routes/settings/+page.svelte +175 -0
- package/frontend/static/favicon.png +0 -0
- package/frontend/static/logo.png +0 -0
- package/frontend/svelte.config.js +15 -0
- package/frontend/tsconfig.json +15 -0
- package/frontend/vite.config.ts +25 -0
- package/init.sql +9 -0
- package/logo.png +0 -0
- package/package.json +39 -0
- package/package.public.json +39 -0
- package/packages/db/drizzle.config.ts +10 -0
- package/packages/db/package.json +30 -0
- package/packages/db/src/client.ts +9 -0
- package/packages/db/src/index.ts +2 -0
- package/packages/db/src/migrations/0000_nice_bedlam.sql +165 -0
- package/packages/db/src/migrations/0001_w2_3_test.sql +5 -0
- package/packages/db/src/migrations/0002_rename_content_json.sql +2 -0
- package/packages/db/src/migrations/meta/0000_snapshot.json +1331 -0
- package/packages/db/src/migrations/meta/0001_snapshot.json +1399 -0
- package/packages/db/src/migrations/meta/0002_snapshot.json +1399 -0
- package/packages/db/src/migrations/meta/_journal.json +27 -0
- package/packages/db/src/schema.ts +378 -0
- package/packages/db/tsconfig.json +17 -0
- package/scripts/export-openapi.ts +37 -0
- package/scripts/health-check.sh +75 -0
- package/scripts/migrate.sh +135 -0
- package/scripts/prework_backup.sh +25 -0
- package/scripts/release.sh +83 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
interface FetchOptions extends RequestInit {
|
|
2
|
+
timeout?: number;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export class ApiError extends Error {
|
|
6
|
+
status: number;
|
|
7
|
+
constructor(message: string, status: number) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "ApiError";
|
|
10
|
+
this.status = status;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function apiFetch<T>(
|
|
15
|
+
path: string,
|
|
16
|
+
options: FetchOptions = {},
|
|
17
|
+
// Optional fetcher — pass SvelteKit's `load`/`fetch` to inherit cookies
|
|
18
|
+
// and bypass the `window.fetch` warning. Falls back to the global
|
|
19
|
+
// `fetch` when called from the browser outside SvelteKit.
|
|
20
|
+
fetcher: typeof fetch = fetch,
|
|
21
|
+
): Promise<T> {
|
|
22
|
+
const { timeout = 10000, body, ...fetchOptions } = options;
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
25
|
+
|
|
26
|
+
const headers: Record<string, string> = {};
|
|
27
|
+
if (body && !(body instanceof FormData)) {
|
|
28
|
+
headers["Content-Type"] = "application/json";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetcher(path, {
|
|
33
|
+
...fetchOptions,
|
|
34
|
+
body,
|
|
35
|
+
signal: controller.signal,
|
|
36
|
+
headers: {
|
|
37
|
+
...headers,
|
|
38
|
+
...fetchOptions.headers,
|
|
39
|
+
},
|
|
40
|
+
credentials: "include",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
const error = await response
|
|
45
|
+
.json()
|
|
46
|
+
.catch(() => ({ error: response.statusText }));
|
|
47
|
+
throw new ApiError(
|
|
48
|
+
error.error ?? `HTTP ${response.status}`,
|
|
49
|
+
response.status,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return response.json() as Promise<T>;
|
|
54
|
+
} finally {
|
|
55
|
+
clearTimeout(timeoutId);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { apiFetch } from "./client";
|
|
2
|
+
|
|
3
|
+
export interface Document {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
content: string;
|
|
7
|
+
contentJson?: unknown;
|
|
8
|
+
folderId?: string | null;
|
|
9
|
+
folderName?: string;
|
|
10
|
+
tags?: Array<{ id: string; name: string; color: string }>;
|
|
11
|
+
excerpt?: string;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
updatedAt: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DocumentListResponse {
|
|
17
|
+
items: Document[];
|
|
18
|
+
total: number;
|
|
19
|
+
page: number;
|
|
20
|
+
limit: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function listDocuments(params?: {
|
|
24
|
+
folderId?: string;
|
|
25
|
+
tag?: string;
|
|
26
|
+
page?: number;
|
|
27
|
+
limit?: number;
|
|
28
|
+
}): Promise<DocumentListResponse> {
|
|
29
|
+
const searchParams = new URLSearchParams();
|
|
30
|
+
if (params?.folderId) searchParams.set("folderId", params.folderId);
|
|
31
|
+
if (params?.tag) searchParams.set("tag", params.tag);
|
|
32
|
+
if (params?.page) searchParams.set("page", String(params.page));
|
|
33
|
+
if (params?.limit) searchParams.set("limit", String(params.limit));
|
|
34
|
+
const qs = searchParams.toString();
|
|
35
|
+
return apiFetch(`/api/documents${qs ? `?${qs}` : ""}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getDocument(
|
|
39
|
+
id: string,
|
|
40
|
+
fetcher?: typeof fetch,
|
|
41
|
+
): Promise<Document> {
|
|
42
|
+
return apiFetch(`/api/documents/${id}`, {}, fetcher);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createDocument(data: {
|
|
46
|
+
title: string;
|
|
47
|
+
content?: string;
|
|
48
|
+
folderId?: string;
|
|
49
|
+
}): Promise<Document> {
|
|
50
|
+
return apiFetch("/api/documents", {
|
|
51
|
+
method: "POST",
|
|
52
|
+
body: JSON.stringify(data),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function updateDocument(
|
|
57
|
+
id: string,
|
|
58
|
+
data: {
|
|
59
|
+
title?: string;
|
|
60
|
+
content?: string;
|
|
61
|
+
folderId?: string | null;
|
|
62
|
+
contentJson?: unknown;
|
|
63
|
+
},
|
|
64
|
+
): Promise<Document> {
|
|
65
|
+
return apiFetch(`/api/documents/${id}`, {
|
|
66
|
+
method: "PATCH",
|
|
67
|
+
body: JSON.stringify(data),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function deleteDocument(id: string): Promise<void> {
|
|
72
|
+
return apiFetch(`/api/documents/${id}`, { method: "DELETE" });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function importDocument(
|
|
76
|
+
file: File,
|
|
77
|
+
folderId?: string,
|
|
78
|
+
): Promise<Document> {
|
|
79
|
+
const formData = new FormData();
|
|
80
|
+
formData.append("file", file);
|
|
81
|
+
if (folderId) formData.append("folderId", folderId);
|
|
82
|
+
return apiFetch("/api/documents/import", { method: "POST", body: formData });
|
|
83
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CreateFolderData,
|
|
3
|
+
Document,
|
|
4
|
+
Folder,
|
|
5
|
+
UpdateFolderData,
|
|
6
|
+
} from "$lib/types.js";
|
|
7
|
+
import { apiFetch } from "./client";
|
|
8
|
+
|
|
9
|
+
interface FolderWire {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
parentId: string | null;
|
|
13
|
+
ownerId?: string;
|
|
14
|
+
createdAt: string;
|
|
15
|
+
updatedAt: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface DocumentWire {
|
|
19
|
+
id: string;
|
|
20
|
+
title: string;
|
|
21
|
+
content?: string;
|
|
22
|
+
contentJson?: unknown;
|
|
23
|
+
metadata?: unknown;
|
|
24
|
+
folderId: string | null;
|
|
25
|
+
createdAt: string;
|
|
26
|
+
updatedAt: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toFolder(f: FolderWire): Folder {
|
|
30
|
+
return {
|
|
31
|
+
id: f.id,
|
|
32
|
+
name: f.name,
|
|
33
|
+
parentId: f.parentId,
|
|
34
|
+
documentCount: 0,
|
|
35
|
+
children: [],
|
|
36
|
+
documents: [],
|
|
37
|
+
createdAt: f.createdAt,
|
|
38
|
+
updatedAt: f.updatedAt,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function toDocument(d: DocumentWire, folderName = ""): Document {
|
|
43
|
+
const content = d.content ?? "";
|
|
44
|
+
return {
|
|
45
|
+
id: d.id,
|
|
46
|
+
title: d.title,
|
|
47
|
+
content,
|
|
48
|
+
folderId: d.folderId,
|
|
49
|
+
folderName,
|
|
50
|
+
tags: [],
|
|
51
|
+
createdAt: d.createdAt,
|
|
52
|
+
updatedAt: d.updatedAt,
|
|
53
|
+
excerpt: content.length > 200 ? `${content.slice(0, 200)}…` : content,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Get a single folder by ID. Backend: `GET /api/folders/:id`. */
|
|
58
|
+
export async function getFolder(id: string): Promise<Folder> {
|
|
59
|
+
const row: FolderWire = await apiFetch<FolderWire>(
|
|
60
|
+
`/api/folders/${encodeURIComponent(id)}`,
|
|
61
|
+
);
|
|
62
|
+
return toFolder(row);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* List folders under a given parent.
|
|
67
|
+
*
|
|
68
|
+
* - `listFolders(null)` returns a single-element array whose first element is a
|
|
69
|
+
* synthetic root folder whose `children` are the user's top-level folders.
|
|
70
|
+
* This matches the contract that `FolderTree.svelte` depends on
|
|
71
|
+
* (`result[0].children`).
|
|
72
|
+
* - `listFolders(parentId)` returns the flat immediate children of `parentId`.
|
|
73
|
+
*
|
|
74
|
+
* The backend returns flat rows keyed by `parentId`; the tree shape is
|
|
75
|
+
* composed client-side.
|
|
76
|
+
*/
|
|
77
|
+
export async function listFolders(
|
|
78
|
+
parentId: string | null = null,
|
|
79
|
+
): Promise<Folder[]> {
|
|
80
|
+
const qs = parentId ? `?parentId=${encodeURIComponent(parentId)}` : "";
|
|
81
|
+
const rows: FolderWire[] = await apiFetch<FolderWire[]>(`/api/folders${qs}`);
|
|
82
|
+
const folders = rows.map(toFolder);
|
|
83
|
+
|
|
84
|
+
if (parentId === null) {
|
|
85
|
+
const now = new Date().toISOString();
|
|
86
|
+
return [
|
|
87
|
+
{
|
|
88
|
+
id: "root",
|
|
89
|
+
name: "Workspace",
|
|
90
|
+
parentId: null,
|
|
91
|
+
documentCount: 0,
|
|
92
|
+
children: folders,
|
|
93
|
+
documents: [],
|
|
94
|
+
createdAt: now,
|
|
95
|
+
updatedAt: now,
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return folders;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get the breadcrumb path from the workspace root down to the given folder.
|
|
105
|
+
*
|
|
106
|
+
* Walks up the parent chain via repeated `GET /api/folders/:id` calls and
|
|
107
|
+
* reverses the collected ancestors so the returned array is ordered
|
|
108
|
+
* root-first. Includes a cycle guard so a malformed parent chain cannot loop
|
|
109
|
+
* forever.
|
|
110
|
+
*/
|
|
111
|
+
export async function getFolderPath(
|
|
112
|
+
folderId: string,
|
|
113
|
+
): Promise<Array<{ id: string; name: string }>> {
|
|
114
|
+
const path: Array<{ id: string; name: string }> = [];
|
|
115
|
+
const visited = new Set<string>();
|
|
116
|
+
let currentId: string | null = folderId;
|
|
117
|
+
|
|
118
|
+
while (currentId && !visited.has(currentId)) {
|
|
119
|
+
visited.add(currentId);
|
|
120
|
+
const folder: FolderWire = await apiFetch<FolderWire>(
|
|
121
|
+
`/api/folders/${encodeURIComponent(currentId)}`,
|
|
122
|
+
);
|
|
123
|
+
path.unshift({ id: folder.id, name: folder.name });
|
|
124
|
+
currentId = folder.parentId;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return path;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Create a new folder. Backend: `POST /api/folders`. */
|
|
131
|
+
export async function createFolder(data: CreateFolderData): Promise<Folder> {
|
|
132
|
+
const created: FolderWire = await apiFetch<FolderWire>("/api/folders", {
|
|
133
|
+
method: "POST",
|
|
134
|
+
body: JSON.stringify(data),
|
|
135
|
+
});
|
|
136
|
+
return toFolder(created);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Update a folder (rename or move). Backend: `PATCH /api/folders/:id`. */
|
|
140
|
+
export async function updateFolder(
|
|
141
|
+
id: string,
|
|
142
|
+
data: UpdateFolderData,
|
|
143
|
+
): Promise<Folder> {
|
|
144
|
+
const updated: FolderWire = await apiFetch<FolderWire>(
|
|
145
|
+
`/api/folders/${encodeURIComponent(id)}`,
|
|
146
|
+
{
|
|
147
|
+
method: "PATCH",
|
|
148
|
+
body: JSON.stringify(data),
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
return toFolder(updated);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Delete a folder. Backend: `DELETE /api/folders/:id`. */
|
|
155
|
+
export async function deleteFolder(id: string): Promise<void> {
|
|
156
|
+
await apiFetch(`/api/folders/${encodeURIComponent(id)}`, {
|
|
157
|
+
method: "DELETE",
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Duplicate a document. The new copy is suffixed with ` (Copy)` on the title.
|
|
163
|
+
* Backend: `POST /api/documents/:id/duplicate`.
|
|
164
|
+
*/
|
|
165
|
+
export async function duplicateDocument(docId: string): Promise<Document> {
|
|
166
|
+
const created: DocumentWire = await apiFetch<DocumentWire>(
|
|
167
|
+
`/api/documents/${encodeURIComponent(docId)}/duplicate`,
|
|
168
|
+
{
|
|
169
|
+
method: "POST",
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
return toDocument(created);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Delete a document. Backend: `DELETE /api/documents/:id`. */
|
|
176
|
+
export async function deleteDocument(docId: string): Promise<void> {
|
|
177
|
+
await apiFetch(`/api/documents/${encodeURIComponent(docId)}`, {
|
|
178
|
+
method: "DELETE",
|
|
179
|
+
});
|
|
180
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { highlightTerms, stripMarks } from "./search";
|
|
3
|
+
|
|
4
|
+
describe("search helpers", () => {
|
|
5
|
+
describe("stripMarks", () => {
|
|
6
|
+
test("removes <mark> tags", () => {
|
|
7
|
+
expect(stripMarks("hello <mark>world</mark>")).toBe("hello world");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("removes multiple <mark> tags", () => {
|
|
11
|
+
expect(stripMarks("<mark>a</mark> and <mark>b</mark>")).toBe("a and b");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("returns plain text unchanged", () => {
|
|
15
|
+
expect(stripMarks("no marks here")).toBe("no marks here");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("handles empty string", () => {
|
|
19
|
+
expect(stripMarks("")).toBe("");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("highlightTerms", () => {
|
|
24
|
+
test("wraps matching terms in <mark> tags", () => {
|
|
25
|
+
const result = highlightTerms("Hello world", "Hello");
|
|
26
|
+
expect(result).toBe("<mark>Hello</mark> world");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("is case-insensitive", () => {
|
|
30
|
+
const result = highlightTerms("Hello HELLO hello", "hello");
|
|
31
|
+
expect(result).toContain("<mark>Hello</mark>");
|
|
32
|
+
expect(result).toContain("<mark>HELLO</mark>");
|
|
33
|
+
expect(result).toContain("<mark>hello</mark>");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("handles multiple search terms", () => {
|
|
37
|
+
const result = highlightTerms("foo bar baz", "foo baz");
|
|
38
|
+
expect(result).toContain("<mark>foo</mark>");
|
|
39
|
+
expect(result).toContain("<mark>baz</mark>");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("escapes regex special characters", () => {
|
|
43
|
+
const result = highlightTerms("price is $100 (USD)", "$100");
|
|
44
|
+
expect(result).toContain("<mark>$100</mark>");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("returns original text for empty query", () => {
|
|
48
|
+
expect(highlightTerms("hello", "")).toBe("hello");
|
|
49
|
+
expect(highlightTerms("hello", " ")).toBe("hello");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { apiFetch } from "$lib/api/client";
|
|
2
|
+
|
|
3
|
+
// --- Types -------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
export interface SearchResult {
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
snippet: string;
|
|
9
|
+
score: number;
|
|
10
|
+
folder_id: string | null;
|
|
11
|
+
folder_name?: string | null;
|
|
12
|
+
created_at: string;
|
|
13
|
+
updated_at: string;
|
|
14
|
+
tags?: Array<{ id: string; name: string; color: string | null }>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SearchResponse {
|
|
18
|
+
items: SearchResult[];
|
|
19
|
+
total: number;
|
|
20
|
+
page: number;
|
|
21
|
+
limit: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SearchSuggestion {
|
|
25
|
+
id: string;
|
|
26
|
+
title: string;
|
|
27
|
+
score: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FilterOptions {
|
|
31
|
+
folders: string[];
|
|
32
|
+
tags: Array<{ id: string; name: string; color: string | null }>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// --- Public API --------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
export type SearchSort =
|
|
38
|
+
| "relevance"
|
|
39
|
+
| "date_desc"
|
|
40
|
+
| "date_asc"
|
|
41
|
+
| "name_asc"
|
|
42
|
+
| "name_desc";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Full hybrid search (text + semantic).
|
|
46
|
+
*/
|
|
47
|
+
export async function search(
|
|
48
|
+
query: string,
|
|
49
|
+
page = 1,
|
|
50
|
+
limit = 20,
|
|
51
|
+
sort: SearchSort = "relevance",
|
|
52
|
+
filters?: {
|
|
53
|
+
folder?: string;
|
|
54
|
+
tags?: string[];
|
|
55
|
+
dateFrom?: string;
|
|
56
|
+
dateTo?: string;
|
|
57
|
+
},
|
|
58
|
+
): Promise<SearchResponse> {
|
|
59
|
+
if (!query.trim()) {
|
|
60
|
+
return { items: [], total: 0, page: 1, limit };
|
|
61
|
+
}
|
|
62
|
+
const params = new URLSearchParams({
|
|
63
|
+
q: query,
|
|
64
|
+
page: String(page),
|
|
65
|
+
limit: String(limit),
|
|
66
|
+
});
|
|
67
|
+
if (sort !== "relevance") params.set("sort", sort);
|
|
68
|
+
if (filters?.folder) params.set("folder", filters.folder);
|
|
69
|
+
if (filters?.tags && filters.tags.length > 0)
|
|
70
|
+
params.set("tags", filters.tags.join(","));
|
|
71
|
+
if (filters?.dateFrom) params.set("dateFrom", filters.dateFrom);
|
|
72
|
+
if (filters?.dateTo) params.set("dateTo", filters.dateTo);
|
|
73
|
+
return apiFetch(`/api/search?${params}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Quick title-only search for autocomplete / suggestions.
|
|
78
|
+
* Returns top 5 matches.
|
|
79
|
+
*/
|
|
80
|
+
export async function searchSuggest(
|
|
81
|
+
query: string,
|
|
82
|
+
): Promise<SearchSuggestion[]> {
|
|
83
|
+
if (!query.trim()) return [];
|
|
84
|
+
const params = new URLSearchParams({ q: query });
|
|
85
|
+
return apiFetch(`/api/search/suggest?${params}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get available filter options (folders and tags for the current user).
|
|
90
|
+
*/
|
|
91
|
+
export async function getFilterOptions(): Promise<FilterOptions> {
|
|
92
|
+
try {
|
|
93
|
+
const [folders, tags] = await Promise.all([
|
|
94
|
+
apiFetch<Array<{ id: string; name: string }>>("/api/folders"),
|
|
95
|
+
apiFetch<Array<{ id: string; name: string; color: string | null }>>(
|
|
96
|
+
"/api/tags",
|
|
97
|
+
),
|
|
98
|
+
]);
|
|
99
|
+
return {
|
|
100
|
+
folders: folders.map((f) => f.name),
|
|
101
|
+
tags,
|
|
102
|
+
};
|
|
103
|
+
} catch {
|
|
104
|
+
return { folders: [], tags: [] };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- Text helpers (exported for component use) ------------------------------
|
|
109
|
+
|
|
110
|
+
/** Strip existing <mark> tags from a string. */
|
|
111
|
+
export function stripMarks(html: string): string {
|
|
112
|
+
return html.replace(/<\/?mark>/g, "");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Wrap occurrences of query terms in <mark> tags. */
|
|
116
|
+
export function highlightTerms(text: string, query: string): string {
|
|
117
|
+
if (!query.trim()) return text;
|
|
118
|
+
|
|
119
|
+
const terms = query
|
|
120
|
+
.split(/\s+/)
|
|
121
|
+
.filter(Boolean)
|
|
122
|
+
.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
123
|
+
|
|
124
|
+
if (terms.length === 0) return text;
|
|
125
|
+
|
|
126
|
+
const regex = new RegExp(`(${terms.join("|")})`, "gi");
|
|
127
|
+
return text.replace(regex, "<mark>$1</mark>");
|
|
128
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { authClient } from "$lib/auth-client";
|
|
2
|
+
import { apiFetch } from "./client.js";
|
|
3
|
+
|
|
4
|
+
export interface UserProfile {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
email: string;
|
|
8
|
+
avatar: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface EmbeddingConfig {
|
|
12
|
+
provider: "ollama" | "openrouter" | "voyage";
|
|
13
|
+
model: string;
|
|
14
|
+
fallbackProvider: string | null;
|
|
15
|
+
fallbackModel: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// --- Profile (uses Better Auth session) ---
|
|
19
|
+
|
|
20
|
+
export async function getProfile(): Promise<UserProfile> {
|
|
21
|
+
try {
|
|
22
|
+
const session = await apiFetch<{ user?: UserProfile }>(
|
|
23
|
+
"/api/auth/get-session",
|
|
24
|
+
);
|
|
25
|
+
if (session.user?.name || session.user?.email) {
|
|
26
|
+
return session.user;
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
/* fall through */
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Fallback: try client-side getSession (works even when server-side
|
|
33
|
+
// /api/auth/session returns empty, e.g. for newly registered users).
|
|
34
|
+
const { data } = await authClient.getSession();
|
|
35
|
+
if (data?.user) {
|
|
36
|
+
return {
|
|
37
|
+
id: data.user.id ?? "",
|
|
38
|
+
name: data.user.name ?? "User",
|
|
39
|
+
email: data.user.email ?? "",
|
|
40
|
+
avatar: null,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return { id: "", name: "User", email: "", avatar: null };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function updateProfile(data: {
|
|
47
|
+
name?: string;
|
|
48
|
+
}): Promise<UserProfile> {
|
|
49
|
+
return apiFetch("/api/auth/update-user", {
|
|
50
|
+
method: "POST",
|
|
51
|
+
body: JSON.stringify({ name: data.name }),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- Embedding Config (stored in localStorage for OSS simplicity) ---
|
|
56
|
+
|
|
57
|
+
const EMBEDDING_KEY = "hiai-docs:embedding-config";
|
|
58
|
+
|
|
59
|
+
export function getEmbeddingConfig(): EmbeddingConfig {
|
|
60
|
+
if (typeof window === "undefined") {
|
|
61
|
+
return {
|
|
62
|
+
provider: "ollama",
|
|
63
|
+
model: "nomic-embed-text",
|
|
64
|
+
fallbackProvider: "openrouter",
|
|
65
|
+
fallbackModel: "openai/text-embedding-3-small",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const stored = localStorage.getItem(EMBEDDING_KEY);
|
|
70
|
+
if (stored) return JSON.parse(stored);
|
|
71
|
+
} catch {
|
|
72
|
+
/* ignore */
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
provider: "ollama",
|
|
76
|
+
model: "nomic-embed-text",
|
|
77
|
+
fallbackProvider: "openrouter",
|
|
78
|
+
fallbackModel: "openai/text-embedding-3-small",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function updateEmbeddingConfig(
|
|
83
|
+
data: Partial<EmbeddingConfig>,
|
|
84
|
+
): EmbeddingConfig {
|
|
85
|
+
const current = getEmbeddingConfig();
|
|
86
|
+
const updated = { ...current, ...data };
|
|
87
|
+
localStorage.setItem(EMBEDDING_KEY, JSON.stringify(updated));
|
|
88
|
+
return updated;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- Delete Account ---
|
|
92
|
+
|
|
93
|
+
export async function deleteAccount(): Promise<void> {
|
|
94
|
+
await apiFetch("/api/auth/delete-user", { method: "POST" });
|
|
95
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { apiFetch } from "./client.js";
|
|
2
|
+
|
|
3
|
+
export interface ShareLink {
|
|
4
|
+
id: string;
|
|
5
|
+
token: string;
|
|
6
|
+
documentId?: string;
|
|
7
|
+
folderId?: string;
|
|
8
|
+
hasPassword: boolean;
|
|
9
|
+
expiresAt?: string | null;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
title?: string;
|
|
12
|
+
type?: "document" | "folder";
|
|
13
|
+
guestEmails: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ShareContent {
|
|
17
|
+
type: "document" | "folder";
|
|
18
|
+
data: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CreateShareLinkInput {
|
|
22
|
+
documentId?: string;
|
|
23
|
+
folderId?: string;
|
|
24
|
+
password?: string;
|
|
25
|
+
expiresIn?: "1h" | "1d" | "7d" | "30d" | "never";
|
|
26
|
+
guestEmails?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// --- Share Links ---
|
|
30
|
+
|
|
31
|
+
export function createShareLink(
|
|
32
|
+
data: CreateShareLinkInput,
|
|
33
|
+
): Promise<ShareLink> {
|
|
34
|
+
if (!data.documentId) {
|
|
35
|
+
return Promise.reject(new Error("createShareLink: documentId is required"));
|
|
36
|
+
}
|
|
37
|
+
return apiFetch("/api/share", { method: "POST", body: JSON.stringify(data) });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function listShareLinks(params?: { documentId?: string }): Promise<{
|
|
41
|
+
links: ShareLink[];
|
|
42
|
+
}> {
|
|
43
|
+
const qs = params?.documentId ? `?documentId=${params.documentId}` : "";
|
|
44
|
+
return apiFetch(`/api/share${qs}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getShareLink(token: string): Promise<ShareContent> {
|
|
48
|
+
if (!token) {
|
|
49
|
+
return Promise.reject(new Error("getShareLink: token is required"));
|
|
50
|
+
}
|
|
51
|
+
return apiFetch(`/api/share/${token}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function revokeShareLink(id: string): Promise<void> {
|
|
55
|
+
return apiFetch(`/api/share/${id}`, { method: "DELETE" });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- Guests ---
|
|
59
|
+
|
|
60
|
+
export function addGuest(linkId: string, email: string): Promise<ShareLink> {
|
|
61
|
+
return apiFetch(`/api/share/${linkId}/guests`, {
|
|
62
|
+
method: "POST",
|
|
63
|
+
body: JSON.stringify({ email }),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function removeGuest(linkId: string, email: string): Promise<ShareLink> {
|
|
68
|
+
return apiFetch(`/api/share/${linkId}/guests/${encodeURIComponent(email)}`, {
|
|
69
|
+
method: "DELETE",
|
|
70
|
+
});
|
|
71
|
+
}
|