@notis_ai/cli 0.2.4 → 0.2.6

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.
Files changed (119) hide show
  1. package/README.md +47 -5
  2. package/dist/scaffolds/notis-database/CHANGELOG.md +5 -0
  3. package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
  4. package/dist/scaffolds/notis-database/notis.config.ts +0 -1
  5. package/dist/scaffolds/notis-database/src/mock-runtime.ts +1 -0
  6. package/dist/scaffolds/notis-journal/CHANGELOG.md +25 -0
  7. package/dist/scaffolds/notis-journal/app/globals.css +37 -0
  8. package/dist/scaffolds/notis-journal/app/insights/page.tsx +513 -0
  9. package/dist/scaffolds/notis-journal/app/journal-core.tsx +362 -0
  10. package/dist/scaffolds/notis-journal/app/journal-ui.tsx +337 -0
  11. package/dist/scaffolds/notis-journal/app/layout.tsx +6 -0
  12. package/dist/scaffolds/notis-journal/app/page.tsx +485 -0
  13. package/dist/scaffolds/notis-journal/components/ui/badge.tsx +28 -0
  14. package/dist/scaffolds/notis-journal/components/ui/button.tsx +53 -0
  15. package/dist/scaffolds/notis-journal/components/ui/card.tsx +56 -0
  16. package/dist/scaffolds/notis-journal/components.json +20 -0
  17. package/dist/scaffolds/notis-journal/index.html +12 -0
  18. package/dist/scaffolds/notis-journal/lib/utils.ts +6 -0
  19. package/dist/scaffolds/notis-journal/metadata/screenshot-1.png +0 -0
  20. package/dist/scaffolds/notis-journal/metadata/screenshot-2.png +0 -0
  21. package/dist/scaffolds/notis-journal/metadata/screenshot-3.png +0 -0
  22. package/dist/scaffolds/notis-journal/metadata/screenshot-4.png +0 -0
  23. package/dist/scaffolds/notis-journal/metadata/screenshot-5.png +0 -0
  24. package/dist/scaffolds/notis-journal/metadata/screenshot-6.png +0 -0
  25. package/dist/scaffolds/notis-journal/metadata/screenshot-fixtures.json +132 -0
  26. package/dist/scaffolds/notis-journal/notis.config.ts +93 -0
  27. package/dist/scaffolds/notis-journal/package.json +34 -0
  28. package/dist/scaffolds/notis-journal/packages/sdk/package.json +36 -0
  29. package/dist/scaffolds/notis-journal/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  30. package/dist/scaffolds/notis-journal/packages/sdk/src/components/Markdown.tsx +60 -0
  31. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectActionBar.tsx +278 -0
  32. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  33. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  34. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +145 -0
  35. package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +229 -0
  36. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +41 -0
  37. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  38. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocument.ts +78 -0
  39. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocuments.ts +121 -0
  40. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useMultiSelect.ts +539 -0
  41. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotis.ts +34 -0
  42. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
  43. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTool.ts +64 -0
  44. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTools.ts +56 -0
  45. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
  46. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
  47. package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +83 -0
  48. package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +43 -0
  49. package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +220 -0
  50. package/dist/scaffolds/notis-journal/packages/sdk/src/styles.css +186 -0
  51. package/dist/scaffolds/notis-journal/packages/sdk/src/ui.ts +15 -0
  52. package/dist/scaffolds/notis-journal/packages/sdk/src/vite.ts +56 -0
  53. package/dist/scaffolds/notis-journal/packages/sdk/tsconfig.json +15 -0
  54. package/dist/scaffolds/notis-journal/postcss.config.mjs +8 -0
  55. package/dist/scaffolds/notis-journal/skills/journal-onboarding/SKILL.md +120 -0
  56. package/dist/scaffolds/notis-journal/src/dev-main.tsx +58 -0
  57. package/dist/scaffolds/notis-journal/src/mock-runtime.ts +197 -0
  58. package/dist/scaffolds/notis-journal/tailwind.config.ts +58 -0
  59. package/dist/scaffolds/notis-journal/tsconfig.json +23 -0
  60. package/dist/scaffolds/notis-journal/vite.config.ts +10 -0
  61. package/dist/scaffolds/notis-notes/CHANGELOG.md +5 -0
  62. package/dist/scaffolds/notis-notes/app/page.tsx +325 -543
  63. package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
  64. package/dist/scaffolds/notis-notes/notis.config.ts +0 -1
  65. package/dist/scaffolds/notis-random/CHANGELOG.md +5 -0
  66. package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
  67. package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
  68. package/dist/scaffolds/notis-random/notis.config.ts +0 -1
  69. package/dist/scaffolds/notis-random/src/mock-runtime.ts +1 -0
  70. package/dist/scaffolds.json +11 -0
  71. package/package.json +3 -3
  72. package/skills/notis-apps/SKILL.md +64 -6
  73. package/skills/notis-apps/cli.md +38 -2
  74. package/skills/notis-cli/SKILL.md +20 -2
  75. package/skills/notis-query/cli.md +1 -1
  76. package/src/command-specs/apps.js +529 -16
  77. package/src/command-specs/index.js +1 -1
  78. package/src/command-specs/meta.js +8 -2
  79. package/src/command-specs/tools.js +50 -37
  80. package/src/runtime/agent-browser.js +273 -1
  81. package/src/runtime/app-changelog.js +79 -0
  82. package/src/runtime/app-dev-server.js +113 -6
  83. package/src/runtime/app-dev-sessions.js +176 -12
  84. package/src/runtime/app-platform.js +200 -39
  85. package/src/runtime/assets/store-screenshot-dark.png +0 -0
  86. package/src/runtime/desktop-auth.js +93 -0
  87. package/src/runtime/profiles.js +37 -15
  88. package/src/runtime/store-screenshot.js +138 -0
  89. package/src/runtime/transport.js +21 -82
  90. package/template/.harness/index.html.tmpl +128 -6
  91. package/template/CHANGELOG.md +5 -0
  92. package/template/app/page.tsx +11 -41
  93. package/template/notis.config.ts +0 -1
  94. package/template/package-lock.json +4137 -0
  95. package/template/package.json +1 -0
  96. package/template/packages/sdk/package.json +4 -0
  97. package/template/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  98. package/template/packages/sdk/src/components/Markdown.tsx +60 -0
  99. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +7 -2
  100. package/template/packages/sdk/src/config.ts +74 -0
  101. package/template/packages/sdk/src/documents.ts +229 -0
  102. package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  103. package/template/packages/sdk/src/hooks/useDocument.ts +78 -0
  104. package/template/packages/sdk/src/hooks/useDocuments.ts +121 -0
  105. package/template/packages/sdk/src/hooks/useMultiSelect.ts +38 -2
  106. package/template/packages/sdk/src/hooks/useUpsertDocument.ts +54 -9
  107. package/template/packages/sdk/src/index.ts +30 -1
  108. package/template/packages/sdk/src/runtime.ts +76 -17
  109. package/template/packages/sdk/src/styles.css +148 -0
  110. package/dist/scaffolds/notis-database/metadata/cover.png +0 -0
  111. package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
  112. package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
  113. package/dist/scaffolds/notis-notes/metadata/cover.png +0 -0
  114. package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
  115. package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
  116. package/dist/scaffolds/notis-random/metadata/cover.png +0 -0
  117. package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
  118. package/template/metadata/cover.png +0 -0
  119. package/template/packages/sdk/src/hooks/useDatabase.ts +0 -76
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "my-notis-app",
3
3
  "version": "0.1.0",
4
+ "notisAppVersion": "0.1.0",
4
5
  "private": true,
5
6
  "type": "module",
6
7
  "scripts": {
@@ -17,6 +17,10 @@
17
17
  "scripts": {
18
18
  "type-check": "tsc --noEmit"
19
19
  },
20
+ "dependencies": {
21
+ "react-markdown": "^10.1.0",
22
+ "remark-gfm": "^4.0.1"
23
+ },
20
24
  "peerDependencies": {
21
25
  "react": ">=18.0.0",
22
26
  "react-dom": ">=18.0.0",
@@ -0,0 +1,93 @@
1
+ 'use client';
2
+
3
+ import type { CSSProperties, ReactElement } from 'react';
4
+ import { useNotisRuntime } from '../provider';
5
+ import { useDocument } from '../hooks/useDocument';
6
+ import { Markdown } from './Markdown';
7
+ import type { NotisDocumentEditorProps } from '../runtime';
8
+
9
+ const fallbackNoticeStyle: CSSProperties = {
10
+ marginTop: '0.75rem',
11
+ fontSize: '12px',
12
+ color: 'hsl(var(--muted-foreground))',
13
+ };
14
+
15
+ const fallbackFrameStyle: CSSProperties = {
16
+ borderRadius: '0.5rem',
17
+ border: '1px dashed hsl(var(--border))',
18
+ padding: '1rem',
19
+ fontSize: '13px',
20
+ color: 'hsl(var(--muted-foreground))',
21
+ };
22
+
23
+ const fallbackTitleStyle: CSSProperties = {
24
+ margin: '0 0 0.75rem',
25
+ fontSize: '1.5rem',
26
+ fontWeight: 700,
27
+ letterSpacing: '-0.01em',
28
+ color: 'hsl(var(--foreground))',
29
+ };
30
+
31
+ /**
32
+ * Read-only stand-in used when no host editor is available (standalone dev
33
+ * harness, snapshots). Markdown content renders via `Markdown`; other content
34
+ * types get a typed placeholder.
35
+ */
36
+ function DocumentEditorFallback({ documentId, variant = 'full', className }: NotisDocumentEditorProps) {
37
+ const runtime = useNotisRuntime();
38
+ const { document, loading, error } = useDocument(documentId);
39
+ // Listing screenshots should show the app as it looks in the portal, so the
40
+ // harness-only editing notice stays out of scenario captures.
41
+ const isScreenshot = Boolean(runtime?.context?.screenshotScenario);
42
+
43
+ if (loading) {
44
+ return <div className={className} style={fallbackFrameStyle}>Loading document…</div>;
45
+ }
46
+ if (error || !document) {
47
+ return (
48
+ <div className={className} style={fallbackFrameStyle}>
49
+ {error?.message ?? 'Document not found.'}
50
+ </div>
51
+ );
52
+ }
53
+
54
+ const isMarkdown = document.contentType !== 'file';
55
+ return (
56
+ <div className={className}>
57
+ {variant === 'full' ? <h1 style={fallbackTitleStyle}>{document.title}</h1> : null}
58
+ {isMarkdown ? (
59
+ <>
60
+ <Markdown value={document.contentMarkdown ?? ''} />
61
+ {isScreenshot ? null : (
62
+ <p style={fallbackNoticeStyle}>Read-only preview — editing is available inside the Notis portal.</p>
63
+ )}
64
+ </>
65
+ ) : (
66
+ <div style={fallbackFrameStyle}>
67
+ {document.fileType ? `${document.fileType.toUpperCase()} document` : 'File document'} — open it in
68
+ the Notis portal to view.
69
+ </div>
70
+ )}
71
+ </div>
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Embeds the document editor for a document anywhere in an app view. The host
77
+ * (portal) provides the implementation through `runtime.ui.DocumentEditor` and
78
+ * dispatches on the document's content type (markdown -> rich text editor,
79
+ * files -> matching viewer). Outside the portal this falls back to a read-only
80
+ * preview.
81
+ *
82
+ * ```tsx
83
+ * <DocumentEditor documentId={entry.id} variant="body" className="min-h-[320px]" />
84
+ * ```
85
+ */
86
+ export function DocumentEditor(props: NotisDocumentEditorProps): ReactElement {
87
+ const runtime = useNotisRuntime();
88
+ const HostEditor = runtime?.ui?.DocumentEditor;
89
+ if (HostEditor) {
90
+ return <HostEditor {...props} />;
91
+ }
92
+ return <DocumentEditorFallback {...props} />;
93
+ }
@@ -0,0 +1,60 @@
1
+ 'use client';
2
+
3
+ import type { AnchorHTMLAttributes, ReactElement } from 'react';
4
+ import ReactMarkdown, { type Components } from 'react-markdown';
5
+ import remarkGfm from 'remark-gfm';
6
+
7
+ export interface MarkdownProps {
8
+ /** Markdown source to render. */
9
+ value: string;
10
+ /** Typography scale. 'sm' suits dense surfaces (cards, list rows). */
11
+ size?: 'sm' | 'base';
12
+ className?: string;
13
+ /** Per-element overrides, passed through to react-markdown. */
14
+ components?: Components;
15
+ }
16
+
17
+ function MarkdownLink({
18
+ href,
19
+ children,
20
+ node: _node,
21
+ ...rest
22
+ }: AnchorHTMLAttributes<HTMLAnchorElement> & { node?: unknown }) {
23
+ const isExternal = typeof href === 'string' && /^https?:\/\//i.test(href);
24
+ return (
25
+ <a
26
+ href={href}
27
+ {...(isExternal ? { target: '_blank', rel: 'noreferrer noopener' } : {})}
28
+ {...rest}
29
+ >
30
+ {children}
31
+ </a>
32
+ );
33
+ }
34
+
35
+ const defaultComponents: Components = {
36
+ a: MarkdownLink,
37
+ };
38
+
39
+ /**
40
+ * Renders a markdown value with Notis typography. Markdown is a value type:
41
+ * use this anywhere markdown appears — document content, database fields,
42
+ * generated text. Styling comes from `.notis-markdown` rules in the SDK
43
+ * stylesheet and follows the portal theme variables.
44
+ */
45
+ export function Markdown({ value, size = 'base', className, components }: MarkdownProps): ReactElement {
46
+ const classes = ['notis-markdown', size === 'sm' ? 'notis-markdown--sm' : null, className]
47
+ .filter(Boolean)
48
+ .join(' ');
49
+
50
+ return (
51
+ <div className={classes}>
52
+ <ReactMarkdown
53
+ remarkPlugins={[remarkGfm]}
54
+ components={components ? { ...defaultComponents, ...components } : defaultComponents}
55
+ >
56
+ {value}
57
+ </ReactMarkdown>
58
+ </div>
59
+ );
60
+ }
@@ -73,7 +73,12 @@ function shortcutMatches(shortcut: ParsedShortcut, event: KeyboardEvent): boolea
73
73
  return true;
74
74
  }
75
75
 
76
- function isEditableTarget(target: EventTarget | null): boolean {
76
+ function isEditableEvent(event: Event): boolean {
77
+ // Notis apps mount inside a Shadow DOM, so a document-level listener sees
78
+ // `event.target` retargeted to the shadow host. `composedPath()[0]` pierces
79
+ // the shadow boundary to the real focused element, so action-bar shortcuts
80
+ // don't fire while the user is typing in an app input.
81
+ const target = event.composedPath?.()[0] ?? event.target;
77
82
  if (!(target instanceof HTMLElement)) return false;
78
83
  if (target.isContentEditable) return true;
79
84
  const tag = target.tagName;
@@ -193,7 +198,7 @@ export function MultiSelectActionBar({
193
198
  if (selectedCount === 0 || parsedShortcuts.length === 0) return;
194
199
 
195
200
  const handleKeyDown = (event: KeyboardEvent) => {
196
- if (isEditableTarget(event.target)) return;
201
+ if (isEditableEvent(event)) return;
197
202
  for (const { action, parsed } of parsedShortcuts) {
198
203
  if (shortcutMatches(parsed, event)) {
199
204
  event.preventDefault();
@@ -52,13 +52,87 @@ export interface NotisRouteConfig {
52
52
  };
53
53
  }
54
54
 
55
+ export const NOTIS_APP_CATEGORIES = [
56
+ 'Productivity',
57
+ 'Sales & Marketing',
58
+ 'Operations',
59
+ 'Product & Engineering',
60
+ 'Personal',
61
+ ] as const;
62
+
63
+ export type NotisAppCategory = typeof NOTIS_APP_CATEGORIES[number];
64
+
65
+ export interface NotisAppAuthor {
66
+ name: string;
67
+ handle?: string;
68
+ url?: string;
69
+ }
70
+
71
+ export interface NotisAppSkillConfig {
72
+ key: string;
73
+ path: string;
74
+ name: string;
75
+ description?: string;
76
+ }
77
+
78
+ export interface NotisAppOnboardingConfig {
79
+ skill: string;
80
+ prompt: string;
81
+ }
82
+
83
+ export interface NotisAppScreenshotConfig {
84
+ path: string;
85
+ alt: string;
86
+ route?: string;
87
+ scenario?: string;
88
+ focus?: string;
89
+ theme?: 'light' | 'dark';
90
+ }
91
+
92
+ /**
93
+ * Named accent tokens for an app's avatar. Keep in sync with the portal
94
+ * `ACCENT_NAMES` and the server `ACCENT_TOKENS`.
95
+ */
96
+ export const NOTIS_APP_ACCENTS = [
97
+ 'blue',
98
+ 'violet',
99
+ 'emerald',
100
+ 'amber',
101
+ 'rose',
102
+ 'sky',
103
+ 'fuchsia',
104
+ 'teal',
105
+ ] as const;
106
+
107
+ export type NotisAppAccent = typeof NOTIS_APP_ACCENTS[number];
108
+
55
109
  export interface NotisAppConfig {
110
+ /** URL-safe app slug. Existing apps may still use a display name here. */
56
111
  name: string;
112
+ /** Human display title, Raycast-style. Falls back to `name`. */
113
+ title?: string;
57
114
  description?: string;
115
+ /**
116
+ * App icon. A `phosphor:<name>` value (e.g. `phosphor:dice-five`) or
117
+ * `metadata/icon.png`. When unset, the app shows its two-letter initials.
118
+ */
58
119
  icon?: string;
120
+ /**
121
+ * Optional accent color for the app avatar. One of {@link NOTIS_APP_ACCENTS}.
122
+ * When unset, a stable accent is derived automatically from the app id.
123
+ */
124
+ accent?: NotisAppAccent;
125
+ author?: NotisAppAuthor;
126
+ categories?: NotisAppCategory[];
127
+ tagline?: string;
128
+ /** @deprecated Add release entries to the root CHANGELOG.md instead. */
129
+ versionNotes?: string;
130
+ screenshots?: NotisAppScreenshotConfig[];
59
131
  databases?: string[];
60
132
  routes?: NotisRouteConfig[];
61
133
  tools?: string[];
134
+ skills?: NotisAppSkillConfig[];
135
+ onboarding?: NotisAppOnboardingConfig;
62
136
  }
63
137
 
64
138
  /**
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Document data helpers: normalize raw tool results (Notion-shaped property
3
+ * values, snake_case fields) into the SDK's `DocumentRecord`/`DatabaseProperty`
4
+ * shapes, and derive plain-text projections from typed content.
5
+ *
6
+ * These are pure functions — safe in any context (portal, dev harness, tests).
7
+ */
8
+
9
+ import type {
10
+ DatabaseProperty,
11
+ DatabasePropertyOption,
12
+ DatabasePropertyType,
13
+ DocumentContentType,
14
+ DocumentRecord,
15
+ } from './runtime';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Value primitives
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export function isPresentString(value: unknown): value is string {
22
+ return typeof value === 'string' && value.trim().length > 0;
23
+ }
24
+
25
+ export function asRecord(value: unknown): Record<string, unknown> | null {
26
+ return value && typeof value === 'object' && !Array.isArray(value)
27
+ ? (value as Record<string, unknown>)
28
+ : null;
29
+ }
30
+
31
+ export function optionalString(value: unknown): string | null {
32
+ return isPresentString(value) ? value.trim() : null;
33
+ }
34
+
35
+ /** Flattens a Notion-shaped rich_text array (or plain string) to text. */
36
+ export function extractRichText(value: unknown): string {
37
+ if (typeof value === 'string') return value;
38
+ if (!Array.isArray(value)) return '';
39
+ return value
40
+ .map((item) => {
41
+ const record = asRecord(item);
42
+ const text = asRecord(record?.text);
43
+ return optionalString(text?.content) ?? optionalString(record?.plain_text) ?? '';
44
+ })
45
+ .join('');
46
+ }
47
+
48
+ /** Extracts the ids of a normalized relation property value. */
49
+ export function getRelationIds(value: unknown): string[] {
50
+ if (!Array.isArray(value)) return [];
51
+ return value.filter((item): item is string => isPresentString(item));
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Property + document normalization
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /**
59
+ * Collapses a Notion-shaped property value ({type: 'select', select: {...}})
60
+ * into a plain JS value (string, string[], number, boolean, ...). Values that
61
+ * are already plain pass through unchanged.
62
+ */
63
+ export function normalizePropertyValue(value: unknown): unknown {
64
+ const record = asRecord(value);
65
+ if (!record) return value;
66
+
67
+ const type = optionalString(record.type);
68
+ if (!type) return value;
69
+
70
+ if (type === 'title') return extractRichText(record.title);
71
+ if (type === 'rich_text') return extractRichText(record.rich_text);
72
+ if (type === 'select' || type === 'status') {
73
+ return optionalString(asRecord(record[type])?.name) ?? record[type] ?? null;
74
+ }
75
+ if (type === 'multi_select') {
76
+ const items = Array.isArray(record.multi_select) ? record.multi_select : [];
77
+ return items.map((item) => optionalString(asRecord(item)?.name) ?? item).filter(Boolean);
78
+ }
79
+ if (type === 'relation') {
80
+ const items = Array.isArray(record.relation) ? record.relation : [];
81
+ return items.map((item) => optionalString(asRecord(item)?.id) ?? item).filter(Boolean);
82
+ }
83
+ if (type === 'date') return optionalString(asRecord(record.date)?.start) ?? record.date ?? null;
84
+ if (type in record) return record[type];
85
+ return value;
86
+ }
87
+
88
+ function normalizeContentType(value: unknown): DocumentContentType | null {
89
+ return value === 'markdown' || value === 'file' ? value : null;
90
+ }
91
+
92
+ /**
93
+ * Normalizes a raw document from a Notis database tool result
94
+ * (LOCAL_NOTIS_DATABASE_QUERY / GET_DOCUMENT / UPSERT_*) into a
95
+ * `DocumentRecord`: camelCases fields and collapses property values.
96
+ */
97
+ export function normalizeDocumentRecord(value: unknown): DocumentRecord {
98
+ const record = asRecord(value) ?? {};
99
+ const rawProperties = asRecord(record.properties) ?? {};
100
+ const properties: Record<string, unknown> = {};
101
+ for (const [key, propertyValue] of Object.entries(rawProperties)) {
102
+ properties[key] = normalizePropertyValue(propertyValue);
103
+ }
104
+
105
+ return {
106
+ id: optionalString(record.id) ?? '',
107
+ title: optionalString(record.title) ?? 'Untitled',
108
+ url: optionalString(record.url),
109
+ properties,
110
+ icon: optionalString(record.icon),
111
+ cover: optionalString(record.cover),
112
+ databaseSlug: optionalString(record.databaseSlug) ?? optionalString(record.database_slug) ?? undefined,
113
+ contentType: normalizeContentType(record.contentType ?? record.content_type),
114
+ fileType: optionalString(record.fileType) ?? optionalString(record.file_type),
115
+ contentBlocknote: Array.isArray(record.contentBlocknote)
116
+ ? (record.contentBlocknote as Array<Record<string, unknown>>)
117
+ : Array.isArray(record.content_blocknote)
118
+ ? (record.content_blocknote as Array<Record<string, unknown>>)
119
+ : null,
120
+ contentMarkdown: optionalString(record.contentMarkdown) ?? optionalString(record.content_markdown),
121
+ plainText: optionalString(record.plainText) ?? optionalString(record.plain_text),
122
+ createdAt:
123
+ optionalString(record.createdAt)
124
+ ?? optionalString(record.created_at)
125
+ ?? optionalString(record.created_time),
126
+ lastEditedTime:
127
+ optionalString(record.lastEditedTime)
128
+ ?? optionalString(record.last_edited_time)
129
+ ?? optionalString(record.updated_at),
130
+ };
131
+ }
132
+
133
+ /** Normalizes a raw schema property from LOCAL_NOTIS_DATABASE_GET_DATABASE. */
134
+ export function normalizeDatabaseProperty(value: unknown): DatabaseProperty | null {
135
+ const record = asRecord(value);
136
+ const name = optionalString(record?.name);
137
+ if (!record || !name) return null;
138
+
139
+ const rawOptions = Array.isArray(record.options) ? record.options : [];
140
+ const options = rawOptions.flatMap((option): DatabasePropertyOption[] => {
141
+ const optionRecord = asRecord(option);
142
+ const optionName = optionalString(optionRecord?.name);
143
+ if (!optionRecord || !optionName) return [];
144
+ return [{
145
+ id: optionalString(optionRecord.id),
146
+ name: optionName,
147
+ color: optionalString(optionRecord.color),
148
+ order: typeof optionRecord.order === 'number' ? optionRecord.order : undefined,
149
+ }];
150
+ });
151
+
152
+ return {
153
+ id: optionalString(record.id),
154
+ name,
155
+ type: (optionalString(record.type) ?? 'rich_text') as DatabasePropertyType,
156
+ description: optionalString(record.description),
157
+ options,
158
+ };
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Plain-text projections
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /** Strips markdown syntax to readable plain text (previews, search). */
166
+ export function markdownToPlainText(markdown: string): string {
167
+ return markdown
168
+ // fenced code blocks: keep the code, drop the fences
169
+ .replace(/```[^\n]*\n?/g, '')
170
+ // images: keep alt text
171
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
172
+ // links: keep link text
173
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
174
+ // html tags
175
+ .replace(/<[^>]+>/g, ' ')
176
+ // headings, blockquotes, list markers, task boxes
177
+ .replace(/^\s{0,3}(#{1,6}\s+|>\s?|[-*+]\s+(\[[ xX]\]\s+)?|\d+\.\s+)/gm, '')
178
+ // emphasis / strikethrough / inline code
179
+ .replace(/(\*\*|__|[*_~`])/g, '')
180
+ // table separators and pipes
181
+ .replace(/^\s*\|?[-:| ]+\|?\s*$/gm, '')
182
+ .replace(/\|/g, ' ')
183
+ // horizontal rules
184
+ .replace(/^\s*([-*_]\s*){3,}$/gm, '')
185
+ .replace(/\s+/g, ' ')
186
+ .trim();
187
+ }
188
+
189
+ /** Flattens BlockNote block JSON to plain text. */
190
+ export function blockNoteToPlainText(blocks: unknown): string {
191
+ const segments: string[] = [];
192
+
193
+ function visit(node: unknown): void {
194
+ if (typeof node === 'string') {
195
+ if (node.trim()) segments.push(node.trim());
196
+ return;
197
+ }
198
+ if (Array.isArray(node)) {
199
+ node.forEach(visit);
200
+ return;
201
+ }
202
+ const record = asRecord(node);
203
+ if (!record) return;
204
+ if (typeof record.text === 'string' && record.text.trim()) {
205
+ segments.push(record.text.trim());
206
+ }
207
+ visit(record.content);
208
+ visit(record.children);
209
+ }
210
+
211
+ visit(blocks);
212
+ return segments.join(' ').replace(/\s+/g, ' ').trim();
213
+ }
214
+
215
+ /**
216
+ * Best-available plain-text preview for a document:
217
+ * plainText -> contentMarkdown -> contentBlocknote -> title.
218
+ */
219
+ export function getDocumentPreview(document: DocumentRecord): string {
220
+ if (isPresentString(document.plainText)) {
221
+ return document.plainText.replace(/\s+/g, ' ').trim();
222
+ }
223
+ if (isPresentString(document.contentMarkdown)) {
224
+ return markdownToPlainText(document.contentMarkdown);
225
+ }
226
+ const fromBlocks = blockNoteToPlainText(document.contentBlocknote);
227
+ if (fromBlocks) return fromBlocks;
228
+ return isPresentString(document.title) ? document.title : 'Untitled';
229
+ }
@@ -0,0 +1,85 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useState } from 'react';
4
+ import { useNotisRuntime } from '../provider';
5
+ import { normalizeDatabaseProperty, optionalString } from '../documents';
6
+ import type { DatabaseProperty } from '../runtime';
7
+
8
+ interface GetDatabaseResult {
9
+ database?: {
10
+ name?: string;
11
+ description?: string | null;
12
+ schema?: {
13
+ properties?: unknown[];
14
+ };
15
+ };
16
+ message?: string;
17
+ error?: string;
18
+ }
19
+
20
+ export interface UseDatabaseSchemaResult {
21
+ name: string | null;
22
+ description: string | null;
23
+ properties: DatabaseProperty[];
24
+ loading: boolean;
25
+ error: Error | null;
26
+ refetch: () => void;
27
+ }
28
+
29
+ /** Fetch a database's schema (normalized properties + metadata). */
30
+ export function useDatabaseSchema(databaseSlug: string): UseDatabaseSchemaResult {
31
+ const runtime = useNotisRuntime();
32
+ const [name, setName] = useState<string | null>(null);
33
+ const [description, setDescription] = useState<string | null>(null);
34
+ const [properties, setProperties] = useState<DatabaseProperty[]>([]);
35
+ const [loading, setLoading] = useState(true);
36
+ const [error, setError] = useState<Error | null>(null);
37
+ const [fetchKey, setFetchKey] = useState(0);
38
+
39
+ const refetch = useCallback(() => {
40
+ setFetchKey((key) => key + 1);
41
+ }, []);
42
+
43
+ useEffect(() => {
44
+ if (!runtime) {
45
+ setLoading(false);
46
+ return;
47
+ }
48
+
49
+ let cancelled = false;
50
+ setLoading(true);
51
+ setError(null);
52
+
53
+ runtime
54
+ .callTool<GetDatabaseResult>('LOCAL_NOTIS_DATABASE_GET_DATABASE', {
55
+ database_slug: databaseSlug,
56
+ })
57
+ .then((result) => {
58
+ if (cancelled) return;
59
+ const message = result.error ?? result.message;
60
+ if (!result.database && message) {
61
+ throw new Error(message);
62
+ }
63
+ setName(optionalString(result.database?.name));
64
+ setDescription(optionalString(result.database?.description));
65
+ const rawProperties = result.database?.schema?.properties ?? [];
66
+ setProperties(
67
+ rawProperties
68
+ .map(normalizeDatabaseProperty)
69
+ .filter((property): property is DatabaseProperty => Boolean(property)),
70
+ );
71
+ setLoading(false);
72
+ })
73
+ .catch((err) => {
74
+ if (cancelled) return;
75
+ setError(err instanceof Error ? err : new Error(String(err)));
76
+ setLoading(false);
77
+ });
78
+
79
+ return () => {
80
+ cancelled = true;
81
+ };
82
+ }, [runtime, databaseSlug, fetchKey]);
83
+
84
+ return { name, description, properties, loading, error, refetch };
85
+ }
@@ -0,0 +1,78 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useState } from 'react';
4
+ import { useNotisRuntime } from '../provider';
5
+ import { normalizeDocumentRecord } from '../documents';
6
+ import type { DocumentRecord } from '../runtime';
7
+
8
+ interface GetDocumentResult {
9
+ status?: string;
10
+ document?: unknown;
11
+ message?: string;
12
+ error?: string;
13
+ }
14
+
15
+ export interface UseDocumentOptions {
16
+ enabled?: boolean;
17
+ }
18
+
19
+ export interface UseDocumentResult {
20
+ document: DocumentRecord | null;
21
+ loading: boolean;
22
+ error: Error | null;
23
+ refetch: () => void;
24
+ }
25
+
26
+ /** Fetch a single document (with content) by id, normalized. */
27
+ export function useDocument(
28
+ documentId: string | null | undefined,
29
+ options: UseDocumentOptions = {},
30
+ ): UseDocumentResult {
31
+ const runtime = useNotisRuntime();
32
+ const [document, setDocument] = useState<DocumentRecord | null>(null);
33
+ const [loading, setLoading] = useState(Boolean(documentId));
34
+ const [error, setError] = useState<Error | null>(null);
35
+ const [fetchKey, setFetchKey] = useState(0);
36
+
37
+ const enabled = options.enabled !== false && Boolean(documentId);
38
+
39
+ const refetch = useCallback(() => {
40
+ setFetchKey((key) => key + 1);
41
+ }, []);
42
+
43
+ useEffect(() => {
44
+ if (!runtime || !enabled || !documentId) {
45
+ setDocument(null);
46
+ setLoading(false);
47
+ return;
48
+ }
49
+
50
+ let cancelled = false;
51
+ setLoading(true);
52
+ setError(null);
53
+
54
+ runtime
55
+ .callTool<GetDocumentResult>('LOCAL_NOTIS_DATABASE_GET_DOCUMENT', {
56
+ document_id: documentId,
57
+ })
58
+ .then((result) => {
59
+ if (cancelled) return;
60
+ if (!result.document) {
61
+ throw new Error(result.error ?? result.message ?? 'Document not found');
62
+ }
63
+ setDocument(normalizeDocumentRecord(result.document));
64
+ setLoading(false);
65
+ })
66
+ .catch((err) => {
67
+ if (cancelled) return;
68
+ setError(err instanceof Error ? err : new Error(String(err)));
69
+ setLoading(false);
70
+ });
71
+
72
+ return () => {
73
+ cancelled = true;
74
+ };
75
+ }, [runtime, documentId, enabled, fetchKey]);
76
+
77
+ return { document, loading, error, refetch };
78
+ }