Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot]
bb538791e0 chore: bump up c8 version to v11 2026-03-14 10:14:33 +00:00
241 changed files with 6188 additions and 16827 deletions

View File

@@ -1,4 +1,4 @@
# syntax=docker/dockerfile:1.22
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim AS assets
WORKDIR /app

View File

@@ -63,7 +63,7 @@
"groupName": "opentelemetry",
"matchPackageNames": [
"/^@opentelemetry/",
"/^@google-cloud/opentelemetry-/"
"/^@google-cloud\/opentelemetry-/"
]
}
],
@@ -79,7 +79,7 @@
"customManagers": [
{
"customType": "regex",
"managerFilePatterns": ["/^rust-toolchain\\.toml?$/"],
"fileMatch": ["^rust-toolchain\\.toml?$"],
"matchStrings": [
"channel\\s*=\\s*\"(?<currentValue>\\d+\\.\\d+(\\.\\d+)?)\""
],

View File

@@ -269,13 +269,10 @@ jobs:
- name: Run playground build
run: yarn workspace @blocksuite/playground build
- name: Run integration browser tests
timeout-minutes: 10
run: yarn workspace @blocksuite/integration-test test:unit
- name: Run cross-platform playwright tests
timeout-minutes: 10
run: yarn workspace @affine-test/blocksuite test "cross-platform/" --forbid-only
- name: Run playwright tests
run: |
yarn workspace @blocksuite/integration-test test:unit
yarn workspace @affine-test/blocksuite test "cross-platform/" --forbid-only
- name: Upload test results
if: always()

View File

@@ -182,7 +182,7 @@ jobs:
run: yarn workspace @affine/android cap sync
- uses: actions/setup-python@v6
with:
python-version: '3.14'
python-version: '3.13'
- name: Auth gcloud
id: auth
uses: google-github-actions/auth@v2

942
.yarn/releases/yarn-4.12.0.cjs vendored Executable file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -12,4 +12,4 @@ npmPublishAccess: public
npmRegistryServer: "https://registry.npmjs.org"
yarnPath: .yarn/releases/yarn-4.13.0.cjs
yarnPath: .yarn/releases/yarn-4.12.0.cjs

3303
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,13 +30,13 @@ resolver = "3"
chrono = "0.4"
clap = { version = "4.4", features = ["derive"] }
core-foundation = "0.10"
coreaudio-rs = "0.14"
cpal = "0.17"
criterion = { version = "0.8", features = ["html_reports"] }
coreaudio-rs = "0.12"
cpal = "0.15"
criterion = { version = "0.5", features = ["html_reports"] }
criterion2 = { version = "3", default-features = false }
crossbeam-channel = "0.5"
dispatch2 = "0.3"
docx-parser = { git = "https://github.com/toeverything/docx-parser", rev = "380beea" }
docx-parser = { git = "https://github.com/toeverything/docx-parser" }
dotenvy = "0.15"
file-format = { version = "0.28", features = ["reader"] }
homedir = "0.3"
@@ -59,7 +59,6 @@ resolver = "3"
lru = "0.16"
matroska = "0.30"
memory-indexer = "0.3.0"
mermaid-rs-renderer = { git = "https://github.com/toeverything/mermaid-rs-renderer", rev = "fba9097", default-features = false }
mimalloc = "0.1"
mp4parse = "0.17"
nanoid = "0.4"
@@ -76,24 +75,23 @@ resolver = "3"
notify = { version = "8", features = ["serde"] }
objc2 = "0.6"
objc2-foundation = "0.3"
ogg = "0.9"
once_cell = "1"
ordered-float = "5"
parking_lot = "0.12"
path-ext = "0.1.2"
pdf-extract = { git = "https://github.com/toeverything/pdf-extract", branch = "darksky/improve-font-decoding" }
phf = { version = "0.13", features = ["macros"] }
phf = { version = "0.11", features = ["macros"] }
proptest = "1.3"
proptest-derive = "0.8"
proptest-derive = "0.5"
pulldown-cmark = "0.13"
rand = "0.10"
rand_chacha = "0.10"
rand_distr = "0.6"
rand = "0.9"
rand_chacha = "0.9"
rand_distr = "0.5"
rayon = "1.10"
readability = { version = "0.3.0", default-features = false }
regex = "1.10"
rubato = "0.16"
screencapturekit = "0.4"
screencapturekit = "0.3"
serde = "1"
serde_json = "1"
sha3 = "0.10"
@@ -106,37 +104,29 @@ resolver = "3"
"sqlite",
"tls-rustls",
] }
strum_macros = "0.28.0"
strum_macros = "0.27.0"
symphonia = { version = "0.5", features = ["all", "opt-simd"] }
text-splitter = "0.29"
text-splitter = "0.27"
thiserror = "2"
tiktoken-rs = "0.9"
tiktoken-rs = "0.7"
tokio = "1.45"
tree-sitter = { version = "0.26" }
tree-sitter = { version = "0.25" }
tree-sitter-c = { version = "0.24" }
tree-sitter-c-sharp = { version = "0.23" }
tree-sitter-cpp = { version = "0.23" }
tree-sitter-go = { version = "0.25" }
tree-sitter-go = { version = "0.23" }
tree-sitter-java = { version = "0.23" }
tree-sitter-javascript = { version = "0.25" }
tree-sitter-javascript = { version = "0.23" }
tree-sitter-kotlin-ng = { version = "1.1" }
tree-sitter-python = { version = "0.25" }
tree-sitter-python = { version = "0.23" }
tree-sitter-rust = { version = "0.24" }
tree-sitter-scala = { version = "0.25" }
tree-sitter-scala = { version = "0.24" }
tree-sitter-typescript = { version = "0.23" }
typst = "0.14.2"
typst-as-lib = { version = "0.15.4", default-features = false, features = [
"packages",
"typst-kit-embed-fonts",
"typst-kit-fonts",
"ureq",
] }
typst-svg = "0.14.2"
uniffi = "0.31"
uniffi = "0.29"
url = { version = "2.5" }
uuid = "1.8"
v_htmlescape = "0.15"
windows = { version = "0.62", features = [
windows = { version = "0.61", features = [
"Win32_Devices_FunctionDiscovery",
"Win32_Foundation",
"Win32_Media_Audio",
@@ -148,10 +138,10 @@ resolver = "3"
"Win32_System_Variant",
"Win32_UI_Shell_PropertiesSystem",
] }
windows-core = { version = "0.62" }
windows-core = { version = "0.61" }
y-octo = { path = "./packages/common/y-octo/core" }
y-sync = { version = "0.4" }
yrs = "0.25.0"
yrs = "0.23.0"
[profile.dev.package.sqlx-macros]
opt-level = 3

View File

@@ -1,94 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`snapshot to markdown > imports obsidian vault fixtures 1`] = `
{
"entry": {
"children": [
{
"children": [
{
"children": [
{
"delta": [
{
"insert": "Panel
Body line",
},
],
"flavour": "affine:paragraph",
"type": "text",
},
],
"emoji": "💡",
"flavour": "affine:callout",
},
{
"flavour": "affine:attachment",
"name": "archive.zip",
"style": "horizontalThin",
},
{
"delta": [
{
"footnote": {
"label": "1",
"reference": {
"title": "reference body",
"type": "url",
},
},
"insert": " ",
},
],
"flavour": "affine:paragraph",
"type": "text",
},
{
"flavour": "affine:divider",
},
{
"delta": [
{
"insert": "after note",
},
],
"flavour": "affine:paragraph",
"type": "text",
},
{
"delta": [
{
"insert": " ",
"reference": {
"page": "linked",
"type": "LinkedPage",
},
},
],
"flavour": "affine:paragraph",
"type": "text",
},
{
"delta": [
{
"insert": "Sources",
},
],
"flavour": "affine:paragraph",
"type": "h6",
},
{
"flavour": "affine:bookmark",
},
],
"flavour": "affine:note",
},
],
"flavour": "affine:page",
},
"titles": [
"entry",
"linked",
],
}
`;

View File

@@ -1,14 +0,0 @@
> [!custom] Panel
> Body line
![[archive.zip]]
[^1]
---
after note
[[linked]]
[^1]: reference body

View File

@@ -1,10 +1,4 @@
import { readFileSync } from 'node:fs';
import { basename, resolve } from 'node:path';
import {
MarkdownTransformer,
ObsidianTransformer,
} from '@blocksuite/affine/widgets/linked-doc';
import { MarkdownTransformer } from '@blocksuite/affine/widgets/linked-doc';
import {
DefaultTheme,
NoteDisplayMode,
@@ -14,18 +8,13 @@ import {
CalloutAdmonitionType,
CalloutExportStyle,
calloutMarkdownExportMiddleware,
docLinkBaseURLMiddleware,
embedSyncedDocMiddleware,
MarkdownAdapter,
titleMiddleware,
} from '@blocksuite/affine-shared/adapters';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type {
BlockSnapshot,
DeltaInsert,
DocSnapshot,
SliceSnapshot,
Store,
TransformerMiddleware,
} from '@blocksuite/store';
import { AssetsManager, MemoryBlobCRUD, Schema } from '@blocksuite/store';
@@ -40,138 +29,6 @@ import { testStoreExtensions } from '../utils/store.js';
const provider = getProvider();
function withRelativePath(file: File, relativePath: string): File {
Object.defineProperty(file, 'webkitRelativePath', {
value: relativePath,
writable: false,
});
return file;
}
function markdownFixture(relativePath: string): File {
return withRelativePath(
new File(
[
readFileSync(
resolve(import.meta.dirname, 'fixtures/obsidian', relativePath),
'utf8'
),
],
basename(relativePath),
{ type: 'text/markdown' }
),
`vault/${relativePath}`
);
}
function exportSnapshot(doc: Store): DocSnapshot {
const job = doc.getTransformer([
docLinkBaseURLMiddleware(doc.workspace.id),
titleMiddleware(doc.workspace.meta.docMetas),
]);
const snapshot = job.docToSnapshot(doc);
expect(snapshot).toBeTruthy();
return snapshot!;
}
function normalizeDeltaForSnapshot(
delta: DeltaInsert<AffineTextAttributes>[],
titleById: ReadonlyMap<string, string>
) {
return delta.map(item => {
const normalized: Record<string, unknown> = {
insert: item.insert,
};
if (item.attributes?.link) {
normalized.link = item.attributes.link;
}
if (item.attributes?.reference?.type === 'LinkedPage') {
normalized.reference = {
type: 'LinkedPage',
page: titleById.get(item.attributes.reference.pageId) ?? '<missing>',
...(item.attributes.reference.title
? { title: item.attributes.reference.title }
: {}),
};
}
if (item.attributes?.footnote) {
const reference = item.attributes.footnote.reference;
normalized.footnote = {
label: item.attributes.footnote.label,
reference:
reference.type === 'doc'
? {
type: 'doc',
page: reference.docId
? (titleById.get(reference.docId) ?? '<missing>')
: '<missing>',
}
: {
type: reference.type,
...(reference.title ? { title: reference.title } : {}),
...(reference.fileName ? { fileName: reference.fileName } : {}),
},
};
}
return normalized;
});
}
function simplifyBlockForSnapshot(
block: BlockSnapshot,
titleById: ReadonlyMap<string, string>
): Record<string, unknown> {
const simplified: Record<string, unknown> = {
flavour: block.flavour,
};
if (block.flavour === 'affine:paragraph' || block.flavour === 'affine:list') {
simplified.type = block.props.type;
const text = block.props.text as
| { delta?: DeltaInsert<AffineTextAttributes>[] }
| undefined;
simplified.delta = normalizeDeltaForSnapshot(text?.delta ?? [], titleById);
}
if (block.flavour === 'affine:callout') {
simplified.emoji = block.props.emoji;
}
if (block.flavour === 'affine:attachment') {
simplified.name = block.props.name;
simplified.style = block.props.style;
}
if (block.flavour === 'affine:image') {
simplified.sourceId = '<asset>';
}
const children = (block.children ?? [])
.filter(child => child.flavour !== 'affine:surface')
.map(child => simplifyBlockForSnapshot(child, titleById));
if (children.length) {
simplified.children = children;
}
return simplified;
}
function snapshotDocByTitle(
collection: TestWorkspace,
title: string,
titleById: ReadonlyMap<string, string>
) {
const meta = collection.meta.docMetas.find(meta => meta.title === title);
expect(meta).toBeTruthy();
const doc = collection.getDoc(meta!.id)?.getStore({ id: meta!.id });
expect(doc).toBeTruthy();
return simplifyBlockForSnapshot(exportSnapshot(doc!).blocks, titleById);
}
describe('snapshot to markdown', () => {
test('code', async () => {
const blockSnapshot: BlockSnapshot = {
@@ -270,46 +127,6 @@ Hello world
expect(meta?.tags).toEqual(['a', 'b']);
});
test('imports obsidian vault fixtures', async () => {
const schema = new Schema().register(AffineSchemas);
const collection = new TestWorkspace();
collection.storeExtensions = testStoreExtensions;
collection.meta.initialize();
const attachment = withRelativePath(
new File([new Uint8Array([80, 75, 3, 4])], 'archive.zip', {
type: 'application/zip',
}),
'vault/archive.zip'
);
const { docIds } = await ObsidianTransformer.importObsidianVault({
collection,
schema,
importedFiles: [
markdownFixture('entry.md'),
markdownFixture('linked.md'),
attachment,
],
extensions: testStoreExtensions,
});
expect(docIds).toHaveLength(2);
const titleById = new Map(
collection.meta.docMetas.map(meta => [
meta.id,
meta.title ?? '<untitled>',
])
);
expect({
titles: collection.meta.docMetas
.map(meta => meta.title)
.sort((a, b) => (a ?? '').localeCompare(b ?? '')),
entry: snapshotDocByTitle(collection, 'entry', titleById),
}).toMatchSnapshot();
});
test('paragraph', async () => {
const blockSnapshot: BlockSnapshot = {
type: 'block',

View File

@@ -5,7 +5,6 @@ import {
import {
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
createAttachmentBlockSnapshot,
FOOTNOTE_DEFINITION_PREFIX,
getFootnoteDefinitionText,
isFootnoteDefinitionNode,
@@ -57,15 +56,18 @@ export const attachmentBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher
}
walkerContext
.openNode(
createAttachmentBlockSnapshot({
{
type: 'block',
id: nanoid(),
flavour: AttachmentBlockSchema.model.flavour,
props: {
name: fileName,
sourceId: blobId,
footnoteIdentifier,
style: 'citation',
},
}),
children: [],
},
'children'
)
.closeNode();

View File

@@ -32,7 +32,6 @@
},
"devDependencies": {
"@vitest/browser-playwright": "^4.0.18",
"playwright": "=1.58.2",
"vitest": "^4.0.18"
},
"exports": {

View File

@@ -516,9 +516,6 @@ export const EdgelessNoteInteraction =
}
})
.catch(console.error);
} else if (multiSelect && alreadySelected && editing) {
// range selection using Shift-click when editing
return;
} else {
context.default(context);
}

View File

@@ -83,9 +83,9 @@ export class RecordField extends SignalWatcher(
border: 1px solid transparent;
}
.field-content affine-database-number-cell .number {
.field-content .affine-database-number {
text-align: left;
justify-content: flex-start;
justify-content: start;
}
.field-content:hover {

View File

@@ -35,7 +35,6 @@
},
"devDependencies": {
"@vitest/browser-playwright": "^4.0.18",
"playwright": "=1.58.2",
"vitest": "^4.0.18"
},
"exports": {

View File

@@ -39,7 +39,7 @@
"micromark-extension-gfm-table": "^2.1.0",
"micromark-extension-gfm-task-list-item": "^2.1.0",
"micromark-util-combine-extensions": "^2.0.0",
"pdfmake": "^0.3.0",
"pdfmake": "^0.2.20",
"quick-lru": "^7.3.0",
"rehype-parse": "^9.0.0",
"rehype-stringify": "^10.0.0",
@@ -73,7 +73,7 @@
"!dist/__tests__"
],
"devDependencies": {
"@types/pdfmake": "^0.3.0",
"@types/pdfmake": "^0.2.12",
"vitest": "^4.0.18"
},
"version": "0.26.3"

View File

@@ -1,7 +1,4 @@
import {
type AttachmentBlockProps,
AttachmentBlockSchema,
} from '@blocksuite/affine-model';
import { AttachmentBlockSchema } from '@blocksuite/affine-model';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import {
type AssetsManager,
@@ -26,24 +23,6 @@ import { AdapterFactoryIdentifier } from './types/adapter';
export type Attachment = File[];
type CreateAttachmentBlockSnapshotOptions = {
id?: string;
props: Partial<AttachmentBlockProps> & Pick<AttachmentBlockProps, 'name'>;
};
export function createAttachmentBlockSnapshot({
id = nanoid(),
props,
}: CreateAttachmentBlockSnapshotOptions): BlockSnapshot {
return {
type: 'block',
id,
flavour: AttachmentBlockSchema.model.flavour,
props,
children: [],
};
}
type AttachmentToSliceSnapshotPayload = {
file: Attachment;
assets?: AssetsManager;
@@ -118,6 +97,8 @@ export class AttachmentAdapter extends BaseAdapter<Attachment> {
if (files.length === 0) return null;
const content: SliceSnapshot['content'] = [];
const flavour = AttachmentBlockSchema.model.flavour;
for (const blob of files) {
const id = nanoid();
const { name, size, type } = blob;
@@ -127,21 +108,22 @@ export class AttachmentAdapter extends BaseAdapter<Attachment> {
mapInto: sourceId => ({ sourceId }),
});
content.push(
createAttachmentBlockSnapshot({
id,
props: {
name,
size,
type,
embed: false,
style: 'horizontalThin',
index: 'a0',
xywh: '[0,0,0,0]',
rotate: 0,
},
})
);
content.push({
type: 'block',
flavour,
id,
props: {
name,
size,
type,
embed: false,
style: 'horizontalThin',
index: 'a0',
xywh: '[0,0,0,0]',
rotate: 0,
},
children: [],
});
}
return {

View File

@@ -1,20 +1,3 @@
function safeDecodePathReference(path: string): string {
try {
return decodeURIComponent(path);
} catch {
return path;
}
}
export function normalizeFilePathReference(path: string): string {
return safeDecodePathReference(path)
.trim()
.replace(/\\/g, '/')
.replace(/^\.\/+/, '')
.replace(/^\/+/, '')
.replace(/\/+/g, '/');
}
/**
* Normalizes a relative path by resolving all relative path segments
* @param basePath The base path (markdown file's directory)
@@ -57,7 +40,7 @@ export function getImageFullPath(
imageReference: string
): string {
// Decode the image reference in case it contains URL-encoded characters
const decodedReference = safeDecodePathReference(imageReference);
const decodedReference = decodeURIComponent(imageReference);
// Get the directory of the file path
const markdownDir = filePath.substring(0, filePath.lastIndexOf('/'));

View File

@@ -20,30 +20,9 @@ declare global {
showOpenFilePicker?: (
options?: OpenFilePickerOptions
) => Promise<FileSystemFileHandle[]>;
// Window API: showDirectoryPicker
showDirectoryPicker?: (options?: {
id?: string;
mode?: 'read' | 'readwrite';
startIn?: FileSystemHandle | string;
}) => Promise<FileSystemDirectoryHandle>;
}
}
// Minimal polyfill for FileSystemDirectoryHandle to iterate over files
interface FileSystemDirectoryHandle {
kind: 'directory';
name: string;
values(): AsyncIterableIterator<
FileSystemFileHandle | FileSystemDirectoryHandle
>;
}
interface FileSystemFileHandle {
kind: 'file';
name: string;
getFile(): Promise<File>;
}
// See [Common MIME types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types)
const FileTypes: NonNullable<OpenFilePickerOptions['types']> = [
{
@@ -142,27 +121,21 @@ type AcceptTypes =
| 'Docx'
| 'MindMap';
function canUseFileSystemAccessAPI(
api: 'showOpenFilePicker' | 'showDirectoryPicker'
) {
return (
api in window &&
export async function openFilesWith(
acceptType: AcceptTypes = 'Any',
multiple: boolean = true
): Promise<File[] | null> {
// Feature detection. The API needs to be supported
// and the app not run in an iframe.
const supportsFileSystemAccess =
'showOpenFilePicker' in window &&
(() => {
try {
return window.self === window.top;
} catch {
return false;
}
})()
);
}
export async function openFilesWith(
acceptType: AcceptTypes = 'Any',
multiple: boolean = true
): Promise<File[] | null> {
const supportsFileSystemAccess =
canUseFileSystemAccessAPI('showOpenFilePicker');
})();
// If the File System Access API is supported…
if (supportsFileSystemAccess && window.showOpenFilePicker) {
@@ -221,75 +194,6 @@ export async function openFilesWith(
});
}
export async function openDirectory(): Promise<File[] | null> {
const supportsFileSystemAccess = canUseFileSystemAccessAPI(
'showDirectoryPicker'
);
if (supportsFileSystemAccess && window.showDirectoryPicker) {
try {
const dirHandle = await window.showDirectoryPicker();
const files: File[] = [];
const readDirectory = async (
directoryHandle: FileSystemDirectoryHandle,
path: string
) => {
for await (const handle of directoryHandle.values()) {
const relativePath = path ? `${path}/${handle.name}` : handle.name;
if (handle.kind === 'file') {
const fileHandle = handle as FileSystemFileHandle;
if (fileHandle.getFile) {
const file = await fileHandle.getFile();
Object.defineProperty(file, 'webkitRelativePath', {
value: relativePath,
writable: false,
});
files.push(file);
}
} else if (handle.kind === 'directory') {
await readDirectory(
handle as FileSystemDirectoryHandle,
relativePath
);
}
}
};
await readDirectory(dirHandle, '');
return files;
} catch (err) {
console.error(err);
return null;
}
}
return new Promise(resolve => {
const input = document.createElement('input');
input.classList.add('affine-upload-input');
input.style.display = 'none';
input.type = 'file';
input.setAttribute('webkitdirectory', '');
input.setAttribute('directory', '');
document.body.append(input);
input.addEventListener('change', () => {
input.remove();
resolve(input.files ? Array.from(input.files) : null);
});
input.addEventListener('cancel', () => resolve(null));
if ('showPicker' in HTMLInputElement.prototype) {
input.showPicker();
} else {
input.click();
}
});
}
export async function openSingleFileWith(
acceptType?: AcceptTypes
): Promise<File | null> {

View File

@@ -7,7 +7,6 @@ import {
NotionIcon,
} from '@blocksuite/affine-components/icons';
import {
openDirectory,
openFilesWith,
openSingleFileWith,
} from '@blocksuite/affine-shared/utils';
@@ -19,16 +18,11 @@ import { query, state } from 'lit/decorators.js';
import { HtmlTransformer } from '../transformers/html.js';
import { MarkdownTransformer } from '../transformers/markdown.js';
import { NotionHtmlTransformer } from '../transformers/notion-html.js';
import { ObsidianTransformer } from '../transformers/obsidian.js';
import { styles } from './styles.js';
export type OnSuccessHandler = (
pageIds: string[],
options: {
isWorkspaceFile: boolean;
importedCount: number;
docEmojis?: Map<string, string>;
}
options: { isWorkspaceFile: boolean; importedCount: number }
) => void;
export type OnFailHandler = (message: string) => void;
@@ -146,29 +140,6 @@ export class ImportDoc extends WithDisposable(LitElement) {
});
}
private async _importObsidian() {
const files = await openDirectory();
if (!files || files.length === 0) return;
const needLoading =
files.reduce((acc, f) => acc + f.size, 0) > SHOW_LOADING_SIZE;
if (needLoading) {
this.hidden = false;
this._loading = true;
} else {
this.abortController.abort();
}
const { docIds, docEmojis } = await ObsidianTransformer.importObsidianVault(
{
collection: this.collection,
schema: this.schema,
importedFiles: files,
extensions: this.extensions,
}
);
needLoading && this.abortController.abort();
this._onImportSuccess(docIds, { docEmojis });
}
private _onCloseClick(event: MouseEvent) {
event.stopPropagation();
this.abortController.abort();
@@ -180,21 +151,15 @@ export class ImportDoc extends WithDisposable(LitElement) {
private _onImportSuccess(
pageIds: string[],
options: {
isWorkspaceFile?: boolean;
importedCount?: number;
docEmojis?: Map<string, string>;
} = {}
options: { isWorkspaceFile?: boolean; importedCount?: number } = {}
) {
const {
isWorkspaceFile = false,
importedCount: pagesImportedCount = pageIds.length,
docEmojis,
} = options;
this.onSuccess?.(pageIds, {
isWorkspaceFile,
importedCount: pagesImportedCount,
docEmojis,
});
}
@@ -293,13 +258,6 @@ export class ImportDoc extends WithDisposable(LitElement) {
</affine-tooltip>
</div>
</icon-button>
<icon-button
class="button-item"
text="Obsidian"
@click="${this._importObsidian}"
>
${ExportToMarkdownIcon}
</icon-button>
<icon-button class="button-item" text="Coming soon..." disabled>
${NewIcon}
</icon-button>

View File

@@ -2,7 +2,6 @@ export { DocxTransformer } from './docx.js';
export { HtmlTransformer } from './html.js';
export { MarkdownTransformer } from './markdown.js';
export { NotionHtmlTransformer } from './notion-html.js';
export { ObsidianTransformer } from './obsidian.js';
export { PdfTransformer } from './pdf.js';
export { createAssetsArchive, download } from './utils.js';
export { ZipTransformer } from './zip.js';

View File

@@ -21,11 +21,8 @@ import { extMimeMap, Transformer } from '@blocksuite/store';
import type { AssetMap, ImportedFileEntry, PathBlobIdMap } from './type.js';
import { createAssetsArchive, download, parseMatter, Unzip } from './utils.js';
export type ParsedFrontmatterMeta = Partial<
Pick<
DocMeta,
'title' | 'createDate' | 'updatedDate' | 'tags' | 'favorite' | 'trash'
>
type ParsedFrontmatterMeta = Partial<
Pick<DocMeta, 'title' | 'createDate' | 'updatedDate' | 'tags' | 'favorite'>
>;
const FRONTMATTER_KEYS = {
@@ -153,18 +150,11 @@ function buildMetaFromFrontmatter(
}
continue;
}
if (FRONTMATTER_KEYS.trash.includes(key)) {
const trash = parseBoolean(value);
if (trash !== undefined) {
meta.trash = trash;
}
continue;
}
}
return meta;
}
export function parseFrontmatter(markdown: string): {
function parseFrontmatter(markdown: string): {
content: string;
meta: ParsedFrontmatterMeta;
} {
@@ -186,7 +176,7 @@ export function parseFrontmatter(markdown: string): {
}
}
export function applyMetaPatch(
function applyMetaPatch(
collection: Workspace,
docId: string,
meta: ParsedFrontmatterMeta
@@ -197,14 +187,13 @@ export function applyMetaPatch(
if (meta.updatedDate !== undefined) metaPatch.updatedDate = meta.updatedDate;
if (meta.tags) metaPatch.tags = meta.tags;
if (meta.favorite !== undefined) metaPatch.favorite = meta.favorite;
if (meta.trash !== undefined) metaPatch.trash = meta.trash;
if (Object.keys(metaPatch).length) {
collection.meta.setDocMeta(docId, metaPatch);
}
}
export function getProvider(extensions: ExtensionType[]) {
function getProvider(extensions: ExtensionType[]) {
const container = new Container();
extensions.forEach(ext => {
ext.setup(container);
@@ -234,103 +223,6 @@ type ImportMarkdownZipOptions = {
extensions: ExtensionType[];
};
/**
* Filters hidden/system entries that should never participate in imports.
*/
export function isSystemImportPath(path: string) {
return path.includes('__MACOSX') || path.includes('.DS_Store');
}
/**
* Creates the doc CRUD bridge used by importer transformers.
*/
export function createCollectionDocCRUD(collection: Workspace) {
return {
create: (id: string) => collection.createDoc(id).getStore({ id }),
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
delete: (id: string) => collection.removeDoc(id),
};
}
type CreateMarkdownImportJobOptions = {
collection: Workspace;
schema: Schema;
preferredTitle?: string;
fullPath?: string;
};
/**
* Creates a markdown import job with the standard collection middlewares.
*/
export function createMarkdownImportJob({
collection,
schema,
preferredTitle,
fullPath,
}: CreateMarkdownImportJobOptions) {
return new Transformer({
schema,
blobCRUD: collection.blobSync,
docCRUD: createCollectionDocCRUD(collection),
middlewares: [
defaultImageProxyMiddleware,
fileNameMiddleware(preferredTitle),
docLinkBaseURLMiddleware(collection.id),
...(fullPath ? [filePathMiddleware(fullPath)] : []),
],
});
}
type StageImportedAssetOptions = {
pendingAssets: AssetMap;
pendingPathBlobIdMap: PathBlobIdMap;
path: string;
content: Blob;
fileName: string;
};
/**
* Hashes a non-markdown import file and stages it into the shared asset maps.
*/
export async function stageImportedAsset({
pendingAssets,
pendingPathBlobIdMap,
path,
content,
fileName,
}: StageImportedAssetOptions) {
const ext = path.split('.').at(-1) ?? '';
const mime = extMimeMap.get(ext.toLowerCase()) ?? '';
const key = await sha(await content.arrayBuffer());
pendingPathBlobIdMap.set(path, key);
pendingAssets.set(key, new File([content], fileName, { type: mime }));
}
/**
* Binds previously staged asset files into a transformer job before import.
*/
export function bindImportedAssetsToJob(
job: Transformer,
pendingAssets: AssetMap,
pendingPathBlobIdMap: PathBlobIdMap
) {
const pathBlobIdMap = job.assetsManager.getPathBlobIdMap();
// Iterate over all assets to be imported
for (const [assetPath, key] of pendingPathBlobIdMap.entries()) {
// Get the relative path of the asset to the markdown file
// Store the path to blobId map
pathBlobIdMap.set(assetPath, key);
// Store the asset to assets, the key is the blobId, the value is the file object
// In block adapter, it will use the blobId to get the file object
const assetFile = pendingAssets.get(key);
if (assetFile) {
job.assets.set(key, assetFile);
}
}
return pathBlobIdMap;
}
/**
* Exports a doc to a Markdown file or a zip archive containing Markdown and assets.
* @param doc The doc to export
@@ -437,10 +329,19 @@ async function importMarkdownToDoc({
const { content, meta } = parseFrontmatter(markdown);
const preferredTitle = meta.title ?? fileName;
const provider = getProvider(extensions);
const job = createMarkdownImportJob({
collection,
const job = new Transformer({
schema,
preferredTitle,
blobCRUD: collection.blobSync,
docCRUD: {
create: (id: string) => collection.createDoc(id).getStore({ id }),
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
delete: (id: string) => collection.removeDoc(id),
},
middlewares: [
defaultImageProxyMiddleware,
fileNameMiddleware(preferredTitle),
docLinkBaseURLMiddleware(collection.id),
],
});
const mdAdapter = new MarkdownAdapter(job, provider);
const page = await mdAdapter.toDoc({
@@ -480,7 +381,7 @@ async function importMarkdownZip({
// Iterate over all files in the zip
for (const { path, content: blob } of unzip) {
// Skip the files that are not markdown files
if (isSystemImportPath(path)) {
if (path.includes('__MACOSX') || path.includes('.DS_Store')) {
continue;
}
@@ -494,13 +395,12 @@ async function importMarkdownZip({
fullPath: path,
});
} else {
await stageImportedAsset({
pendingAssets,
pendingPathBlobIdMap,
path,
content: blob,
fileName,
});
// If the file is not a markdown file, store it to pendingAssets
const ext = path.split('.').at(-1) ?? '';
const mime = extMimeMap.get(ext) ?? '';
const key = await sha(await blob.arrayBuffer());
pendingPathBlobIdMap.set(path, key);
pendingAssets.set(key, new File([blob], fileName, { type: mime }));
}
}
@@ -511,13 +411,34 @@ async function importMarkdownZip({
const markdown = await contentBlob.text();
const { content, meta } = parseFrontmatter(markdown);
const preferredTitle = meta.title ?? fileNameWithoutExt;
const job = createMarkdownImportJob({
collection,
const job = new Transformer({
schema,
preferredTitle,
fullPath,
blobCRUD: collection.blobSync,
docCRUD: {
create: (id: string) => collection.createDoc(id).getStore({ id }),
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
delete: (id: string) => collection.removeDoc(id),
},
middlewares: [
defaultImageProxyMiddleware,
fileNameMiddleware(preferredTitle),
docLinkBaseURLMiddleware(collection.id),
filePathMiddleware(fullPath),
],
});
bindImportedAssetsToJob(job, pendingAssets, pendingPathBlobIdMap);
const assets = job.assets;
const pathBlobIdMap = job.assetsManager.getPathBlobIdMap();
// Iterate over all assets to be imported
for (const [assetPath, key] of pendingPathBlobIdMap.entries()) {
// Get the relative path of the asset to the markdown file
// Store the path to blobId map
pathBlobIdMap.set(assetPath, key);
// Store the asset to assets, the key is the blobId, the value is the file object
// In block adapter, it will use the blobId to get the file object
if (pendingAssets.get(key)) {
assets.set(key, pendingAssets.get(key)!);
}
}
const mdAdapter = new MarkdownAdapter(job, provider);
const doc = await mdAdapter.toDoc({

View File

@@ -1,834 +0,0 @@
import { FootNoteReferenceParamsSchema } from '@blocksuite/affine-model';
import {
BlockMarkdownAdapterExtension,
createAttachmentBlockSnapshot,
FULL_FILE_PATH_KEY,
getImageFullPath,
MarkdownAdapter,
type MarkdownAST,
MarkdownASTToDeltaExtension,
normalizeFilePathReference,
} from '@blocksuite/affine-shared/adapters';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type {
DeltaInsert,
ExtensionType,
Schema,
Workspace,
} from '@blocksuite/store';
import { extMimeMap, nanoid } from '@blocksuite/store';
import type { Html, Text } from 'mdast';
import {
applyMetaPatch,
bindImportedAssetsToJob,
createMarkdownImportJob,
getProvider,
isSystemImportPath,
parseFrontmatter,
stageImportedAsset,
} from './markdown.js';
import type {
AssetMap,
MarkdownFileImportEntry,
PathBlobIdMap,
} from './type.js';
const CALLOUT_TYPE_MAP: Record<string, string> = {
note: '💡',
info: '',
tip: '🔥',
hint: '✅',
important: '‼️',
warning: '⚠️',
caution: '⚠️',
attention: '⚠️',
danger: '⚠️',
error: '🚨',
bug: '🐛',
example: '📌',
quote: '💬',
cite: '💬',
abstract: '📋',
summary: '📋',
todo: '☑️',
success: '✅',
check: '✅',
done: '✅',
failure: '❌',
fail: '❌',
missing: '❌',
question: '❓',
help: '❓',
faq: '❓',
};
const AMBIGUOUS_PAGE_LOOKUP = '__ambiguous__';
const DEFAULT_CALLOUT_EMOJI = '💡';
const OBSIDIAN_TEXT_FOOTNOTE_URL_PREFIX = 'data:text/plain;charset=utf-8,';
const OBSIDIAN_ATTACHMENT_EMBED_TAG = 'obsidian-attachment';
function normalizeLookupKey(value: string): string {
return normalizeFilePathReference(value).toLowerCase();
}
function stripMarkdownExtension(value: string): string {
return value.replace(/\.md$/i, '');
}
function basename(value: string): string {
return normalizeFilePathReference(value).split('/').pop() ?? value;
}
function parseObsidianTarget(rawTarget: string): {
path: string;
fragment: string | null;
} {
const normalizedTarget = normalizeFilePathReference(rawTarget);
const match = normalizedTarget.match(/^([^#^]+)([#^].*)?$/);
return {
path: match?.[1]?.trim() ?? normalizedTarget,
fragment: match?.[2] ?? null,
};
}
function extractTitleAndEmoji(rawTitle: string): {
title: string;
emoji: string | null;
} {
const SINGLE_LEADING_EMOJI_RE =
/^[\s\u200b]*((?:[\p{Emoji_Presentation}\p{Extended_Pictographic}\u200b]|\u200d|\ufe0f)+)/u;
let currentTitle = rawTitle;
let extractedEmojiClusters = '';
let emojiMatch;
while ((emojiMatch = currentTitle.match(SINGLE_LEADING_EMOJI_RE))) {
const matchedCluster = emojiMatch[1].trim();
extractedEmojiClusters +=
(extractedEmojiClusters ? ' ' : '') + matchedCluster;
currentTitle = currentTitle.slice(emojiMatch[0].length);
}
return {
title: currentTitle.trim(),
emoji: extractedEmojiClusters || null,
};
}
function preprocessTitleHeader(markdown: string): string {
return markdown.replace(
/^(\s*#\s+)(.*)$/m,
(_, headerPrefix, titleContent) => {
const { title: cleanTitle } = extractTitleAndEmoji(titleContent);
return `${headerPrefix}${cleanTitle}`;
}
);
}
function preprocessObsidianCallouts(markdown: string): string {
return markdown.replace(
/^(> *)\[!([^\]\n]+)\]([+-]?)([^\n]*)/gm,
(_, prefix, type, _fold, rest) => {
const calloutToken =
CALLOUT_TYPE_MAP[type.trim().toLowerCase()] ?? DEFAULT_CALLOUT_EMOJI;
const title = rest.trim();
return title
? `${prefix}[!${calloutToken}] ${title}`
: `${prefix}[!${calloutToken}]`;
}
);
}
function isStructuredFootnoteDefinition(content: string): boolean {
try {
return FootNoteReferenceParamsSchema.safeParse(JSON.parse(content.trim()))
.success;
} catch {
return false;
}
}
function splitFootnoteTextContent(content: string): {
title: string;
description?: string;
} {
const lines = content
.split('\n')
.map(line => line.trim())
.filter(Boolean);
const title = lines[0] ?? content.trim();
const description = lines.slice(1).join('\n').trim();
return {
title,
...(description ? { description } : {}),
};
}
function createTextFootnoteDefinition(content: string): string {
const normalizedContent = content.trim();
const { title, description } = splitFootnoteTextContent(normalizedContent);
return JSON.stringify({
type: 'url',
url: encodeURIComponent(
`${OBSIDIAN_TEXT_FOOTNOTE_URL_PREFIX}${encodeURIComponent(
normalizedContent
)}`
),
title,
...(description ? { description } : {}),
});
}
function parseFootnoteDefLine(line: string): {
identifier: string;
content: string;
} | null {
if (!line.startsWith('[^')) return null;
const closeBracketIndex = line.indexOf(']:', 2);
if (closeBracketIndex <= 2) return null;
const identifier = line.slice(2, closeBracketIndex);
if (!identifier || identifier.includes(']')) return null;
let contentStart = closeBracketIndex + 2;
while (
contentStart < line.length &&
(line[contentStart] === ' ' || line[contentStart] === '\t')
) {
contentStart += 1;
}
return {
identifier,
content: line.slice(contentStart),
};
}
function extractObsidianFootnotes(markdown: string): {
content: string;
footnotes: string[];
} {
const lines = markdown.split('\n');
const output: string[] = [];
const footnotes: string[] = [];
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const definition = parseFootnoteDefLine(line);
if (!definition) {
output.push(line);
continue;
}
const { identifier } = definition;
const contentLines = [definition.content];
while (index + 1 < lines.length) {
const nextLine = lines[index + 1];
if (/^(?: {1,4}|\t)/.test(nextLine)) {
contentLines.push(nextLine.replace(/^(?: {1,4}|\t)/, ''));
index += 1;
continue;
}
if (
nextLine.trim() === '' &&
index + 2 < lines.length &&
/^(?: {1,4}|\t)/.test(lines[index + 2])
) {
contentLines.push('');
index += 1;
continue;
}
break;
}
const content = contentLines.join('\n').trim();
footnotes.push(
`[^${identifier}]: ${
!content || isStructuredFootnoteDefinition(content)
? content
: createTextFootnoteDefinition(content)
}`
);
}
return { content: output.join('\n'), footnotes };
}
function buildLookupKeys(
targetPath: string,
currentFilePath?: string
): string[] {
const parsedTargetPath = normalizeFilePathReference(targetPath);
if (!parsedTargetPath) {
return [];
}
const keys = new Set<string>();
const addPathVariants = (value: string) => {
const normalizedValue = normalizeFilePathReference(value);
if (!normalizedValue) {
return;
}
keys.add(normalizedValue);
keys.add(stripMarkdownExtension(normalizedValue));
const fileName = basename(normalizedValue);
keys.add(fileName);
keys.add(stripMarkdownExtension(fileName));
const cleanTitle = extractTitleAndEmoji(
stripMarkdownExtension(fileName)
).title;
if (cleanTitle) {
keys.add(cleanTitle);
}
};
addPathVariants(parsedTargetPath);
if (currentFilePath) {
addPathVariants(getImageFullPath(currentFilePath, parsedTargetPath));
}
return Array.from(keys).map(normalizeLookupKey);
}
function registerPageLookup(
pageLookupMap: Map<string, string>,
key: string,
pageId: string
) {
const normalizedKey = normalizeLookupKey(key);
if (!normalizedKey) {
return;
}
const existing = pageLookupMap.get(normalizedKey);
if (existing && existing !== pageId) {
pageLookupMap.set(normalizedKey, AMBIGUOUS_PAGE_LOOKUP);
return;
}
pageLookupMap.set(normalizedKey, pageId);
}
function resolvePageIdFromLookup(
pageLookupMap: Pick<ReadonlyMap<string, string>, 'get'>,
rawTarget: string,
currentFilePath?: string
): string | null {
const { path } = parseObsidianTarget(rawTarget);
for (const key of buildLookupKeys(path, currentFilePath)) {
const targetPageId = pageLookupMap.get(key);
if (!targetPageId || targetPageId === AMBIGUOUS_PAGE_LOOKUP) {
continue;
}
return targetPageId;
}
return null;
}
function resolveWikilinkDisplayTitle(
rawAlias: string | undefined,
pageEmoji: string | undefined
): string | undefined {
if (!rawAlias) {
return undefined;
}
const { title: aliasTitle, emoji: aliasEmoji } =
extractTitleAndEmoji(rawAlias);
if (aliasEmoji && aliasEmoji === pageEmoji) {
return aliasTitle;
}
return rawAlias;
}
function isImageAssetPath(path: string): boolean {
const extension = path.split('.').at(-1)?.toLowerCase() ?? '';
return extMimeMap.get(extension)?.startsWith('image/') ?? false;
}
function encodeMarkdownPath(path: string): string {
return encodeURI(path).replaceAll('(', '%28').replaceAll(')', '%29');
}
function escapeMarkdownLabel(label: string): string {
return label.replace(/[[\]\\]/g, '\\$&');
}
function isObsidianSizeAlias(alias: string | undefined): boolean {
return !!alias && /^\d+(?:x\d+)?$/i.test(alias.trim());
}
function getEmbedLabel(
rawAlias: string | undefined,
targetPath: string,
fallbackToFileName: boolean
): string {
if (!rawAlias || isObsidianSizeAlias(rawAlias)) {
return fallbackToFileName
? stripMarkdownExtension(basename(targetPath))
: '';
}
return rawAlias.trim();
}
type ObsidianAttachmentEmbed = {
blobId: string;
fileName: string;
fileType: string;
};
function createObsidianAttach(embed: ObsidianAttachmentEmbed): string {
return `<!-- ${OBSIDIAN_ATTACHMENT_EMBED_TAG} ${encodeURIComponent(
JSON.stringify(embed)
)} -->`;
}
function parseObsidianAttach(value: string): ObsidianAttachmentEmbed | null {
const match = value.match(
new RegExp(`^<!-- ${OBSIDIAN_ATTACHMENT_EMBED_TAG} ([^ ]+) -->$`)
);
if (!match?.[1]) return null;
try {
const parsed = JSON.parse(
decodeURIComponent(match[1])
) as ObsidianAttachmentEmbed;
if (!parsed.blobId || !parsed.fileName) {
return null;
}
return parsed;
} catch {
return null;
}
}
function parseWikiLinkAt(
source: string,
startIdx: number,
embedded: boolean
): {
raw: string;
rawTarget: string;
rawAlias?: string;
endIdx: number;
} | null {
const opener = embedded ? '![[' : '[[';
if (!source.startsWith(opener, startIdx)) return null;
const contentStart = startIdx + opener.length;
const closeIndex = source.indexOf(']]', contentStart);
if (closeIndex === -1) return null;
const inner = source.slice(contentStart, closeIndex);
const separatorIdx = inner.indexOf('|');
const rawTarget = separatorIdx === -1 ? inner : inner.slice(0, separatorIdx);
const rawAlias =
separatorIdx === -1 ? undefined : inner.slice(separatorIdx + 1);
if (
rawTarget.length === 0 ||
rawTarget.includes(']') ||
rawTarget.includes('|') ||
rawAlias?.includes(']')
) {
return null;
}
return {
raw: source.slice(startIdx, closeIndex + 2),
rawTarget,
rawAlias,
endIdx: closeIndex + 2,
};
}
function replaceWikiLinks(
source: string,
embedded: boolean,
replacer: (match: {
raw: string;
rawTarget: string;
rawAlias?: string;
}) => string
): string {
const opener = embedded ? '![[' : '[[';
let cursor = 0;
let output = '';
while (cursor < source.length) {
const matchStart = source.indexOf(opener, cursor);
if (matchStart === -1) {
output += source.slice(cursor);
break;
}
output += source.slice(cursor, matchStart);
const match = parseWikiLinkAt(source, matchStart, embedded);
if (!match) {
output += source.slice(matchStart, matchStart + opener.length);
cursor = matchStart + opener.length;
continue;
}
output += replacer(match);
cursor = match.endIdx;
}
return output;
}
function preprocessObsidianEmbeds(
markdown: string,
filePath: string,
pageLookupMap: ReadonlyMap<string, string>,
pathBlobIdMap: ReadonlyMap<string, string>
): string {
return replaceWikiLinks(markdown, true, ({ raw, rawTarget, rawAlias }) => {
const targetPageId = resolvePageIdFromLookup(
pageLookupMap,
rawTarget,
filePath
);
if (targetPageId) {
return `[[${rawTarget}${rawAlias ? `|${rawAlias}` : ''}]]`;
}
const { path } = parseObsidianTarget(rawTarget);
if (!path) return raw;
const assetPath = getImageFullPath(filePath, path);
const encodedPath = encodeMarkdownPath(assetPath);
if (isImageAssetPath(path)) {
const alt = getEmbedLabel(rawAlias, path, false);
return `![${escapeMarkdownLabel(alt)}](${encodedPath})`;
}
const label = getEmbedLabel(rawAlias, path, true);
const blobId = pathBlobIdMap.get(assetPath);
if (!blobId) return `[${escapeMarkdownLabel(label)}](${encodedPath})`;
const extension = path.split('.').at(-1)?.toLowerCase() ?? '';
return createObsidianAttach({
blobId,
fileName: basename(path),
fileType: extMimeMap.get(extension) ?? '',
});
});
}
function preprocessObsidianMarkdown(
markdown: string,
filePath: string,
pageLookupMap: ReadonlyMap<string, string>,
pathBlobIdMap: ReadonlyMap<string, string>
): string {
const { content: contentWithoutFootnotes, footnotes: extractedFootnotes } =
extractObsidianFootnotes(markdown);
const content = preprocessObsidianEmbeds(
contentWithoutFootnotes,
filePath,
pageLookupMap,
pathBlobIdMap
);
const normalizedMarkdown = preprocessTitleHeader(
preprocessObsidianCallouts(content)
);
if (extractedFootnotes.length === 0) {
return normalizedMarkdown;
}
const trimmedMarkdown = normalizedMarkdown.replace(/\s+$/, '');
return `${trimmedMarkdown}\n\n${extractedFootnotes.join('\n\n')}\n`;
}
function isObsidianAttachmentEmbedNode(node: MarkdownAST): node is Html {
return node.type === 'html' && !!parseObsidianAttach(node.value);
}
export const obsidianAttachmentEmbedMarkdownAdapterMatcher =
BlockMarkdownAdapterExtension({
flavour: 'obsidian:attachment-embed',
toMatch: o => isObsidianAttachmentEmbedNode(o.node),
fromMatch: () => false,
toBlockSnapshot: {
enter: (o, context) => {
if (!isObsidianAttachmentEmbedNode(o.node)) {
return;
}
const attachment = parseObsidianAttach(o.node.value);
if (!attachment) {
return;
}
const assetFile = context.assets?.getAssets().get(attachment.blobId);
context.walkerContext
.openNode(
createAttachmentBlockSnapshot({
id: nanoid(),
props: {
name: attachment.fileName,
size: assetFile?.size ?? 0,
type:
attachment.fileType ||
assetFile?.type ||
'application/octet-stream',
sourceId: attachment.blobId,
embed: false,
style: 'horizontalThin',
footnoteIdentifier: null,
},
}),
'children'
)
.closeNode();
(o.node as unknown as { type: string }).type =
'obsidianAttachmentEmbed';
},
},
fromBlockSnapshot: {},
});
export const obsidianWikilinkToDeltaMatcher = MarkdownASTToDeltaExtension({
name: 'obsidian-wikilink',
match: ast => ast.type === 'text',
toDelta: (ast, context) => {
const textNode = ast as Text;
if (!textNode.value) {
return [];
}
const nodeContent = textNode.value;
const deltas: DeltaInsert<AffineTextAttributes>[] = [];
let cursor = 0;
while (cursor < nodeContent.length) {
const matchStart = nodeContent.indexOf('[[', cursor);
if (matchStart === -1) {
deltas.push({ insert: nodeContent.substring(cursor) });
break;
}
if (matchStart > cursor) {
deltas.push({
insert: nodeContent.substring(cursor, matchStart),
});
}
const linkMatch = parseWikiLinkAt(nodeContent, matchStart, false);
if (!linkMatch) {
deltas.push({ insert: '[[' });
cursor = matchStart + 2;
continue;
}
const targetPageName = linkMatch.rawTarget.trim();
const alias = linkMatch.rawAlias?.trim();
const currentFilePath = context.configs.get(FULL_FILE_PATH_KEY);
const targetPageId = resolvePageIdFromLookup(
{ get: key => context.configs.get(`obsidian:pageId:${key}`) },
targetPageName,
typeof currentFilePath === 'string' ? currentFilePath : undefined
);
if (targetPageId) {
const pageEmoji = context.configs.get(
'obsidian:pageEmoji:' + targetPageId
);
const displayTitle = resolveWikilinkDisplayTitle(alias, pageEmoji);
deltas.push({
insert: ' ',
attributes: {
reference: {
type: 'LinkedPage',
pageId: targetPageId,
...(displayTitle ? { title: displayTitle } : {}),
},
},
});
} else {
deltas.push({ insert: linkMatch.raw });
}
cursor = linkMatch.endIdx;
}
return deltas;
},
});
export type ImportObsidianVaultOptions = {
collection: Workspace;
schema: Schema;
importedFiles: File[];
extensions: ExtensionType[];
};
export type ImportObsidianVaultResult = {
docIds: string[];
docEmojis: Map<string, string>;
};
export async function importObsidianVault({
collection,
schema,
importedFiles,
extensions,
}: ImportObsidianVaultOptions): Promise<ImportObsidianVaultResult> {
const provider = getProvider([
obsidianWikilinkToDeltaMatcher,
obsidianAttachmentEmbedMarkdownAdapterMatcher,
...extensions,
]);
const docIds: string[] = [];
const docEmojis = new Map<string, string>();
const pendingAssets: AssetMap = new Map();
const pendingPathBlobIdMap: PathBlobIdMap = new Map();
const markdownBlobs: MarkdownFileImportEntry[] = [];
const pageLookupMap = new Map<string, string>();
for (const file of importedFiles) {
const filePath = file.webkitRelativePath || file.name;
if (isSystemImportPath(filePath)) continue;
if (file.name.endsWith('.md')) {
const fileNameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
const markdown = await file.text();
const { content, meta } = parseFrontmatter(markdown);
const documentTitleCandidate = meta.title ?? fileNameWithoutExt;
const { title: preferredTitle, emoji: leadingEmoji } =
extractTitleAndEmoji(documentTitleCandidate);
const newPageId = collection.idGenerator();
registerPageLookup(pageLookupMap, filePath, newPageId);
registerPageLookup(
pageLookupMap,
stripMarkdownExtension(filePath),
newPageId
);
registerPageLookup(pageLookupMap, file.name, newPageId);
registerPageLookup(pageLookupMap, fileNameWithoutExt, newPageId);
registerPageLookup(pageLookupMap, documentTitleCandidate, newPageId);
registerPageLookup(pageLookupMap, preferredTitle, newPageId);
if (leadingEmoji) {
docEmojis.set(newPageId, leadingEmoji);
}
markdownBlobs.push({
filename: file.name,
contentBlob: file,
fullPath: filePath,
pageId: newPageId,
preferredTitle,
content,
meta,
});
} else {
await stageImportedAsset({
pendingAssets,
pendingPathBlobIdMap,
path: filePath,
content: file,
fileName: file.name,
});
}
}
for (const existingDocMeta of collection.meta.docMetas) {
if (existingDocMeta.title) {
registerPageLookup(
pageLookupMap,
existingDocMeta.title,
existingDocMeta.id
);
}
}
await Promise.all(
markdownBlobs.map(async markdownFile => {
const {
fullPath,
pageId: predefinedId,
preferredTitle,
content,
meta,
} = markdownFile;
const job = createMarkdownImportJob({
collection,
schema,
preferredTitle,
fullPath,
});
for (const [lookupKey, id] of pageLookupMap.entries()) {
if (id === AMBIGUOUS_PAGE_LOOKUP) {
continue;
}
job.adapterConfigs.set(`obsidian:pageId:${lookupKey}`, id);
}
for (const [id, emoji] of docEmojis.entries()) {
job.adapterConfigs.set('obsidian:pageEmoji:' + id, emoji);
}
const pathBlobIdMap = bindImportedAssetsToJob(
job,
pendingAssets,
pendingPathBlobIdMap
);
const preprocessedMarkdown = preprocessObsidianMarkdown(
content,
fullPath,
pageLookupMap,
pathBlobIdMap
);
const mdAdapter = new MarkdownAdapter(job, provider);
const snapshot = await mdAdapter.toDocSnapshot({
file: preprocessedMarkdown,
assets: job.assetsManager,
});
if (snapshot) {
snapshot.meta.id = predefinedId;
const doc = await job.snapshotToDoc(snapshot);
if (doc) {
applyMetaPatch(collection, doc.id, {
...meta,
title: preferredTitle,
trash: false,
});
docIds.push(doc.id);
}
}
})
);
return { docIds, docEmojis };
}
export const ObsidianTransformer = {
importObsidianVault,
};

View File

@@ -1,5 +1,3 @@
import type { ParsedFrontmatterMeta } from './markdown.js';
/**
* Represents an imported file entry in the zip archive
*/
@@ -12,13 +10,6 @@ export type ImportedFileEntry = {
fullPath: string;
};
export type MarkdownFileImportEntry = ImportedFileEntry & {
pageId: string;
preferredTitle: string;
content: string;
meta: ParsedFrontmatterMeta;
};
/**
* Map of asset hash to File object for all media files in the zip
* Key: SHA hash of the file content (blobId)

View File

@@ -162,11 +162,10 @@ export class AffineToolbarWidget extends WidgetComponent {
}
setReferenceElementWithElements(gfx: GfxController, elements: GfxModel[]) {
const surfaceBounds = getCommonBoundWithRotation(elements);
const getBoundingClientRect = () => {
const bounds = getCommonBoundWithRotation(elements);
const { x: offsetX, y: offsetY } = this.getBoundingClientRect();
const [x, y, w, h] = gfx.viewport.toViewBound(surfaceBounds).toXYWH();
const [x, y, w, h] = gfx.viewport.toViewBound(bounds).toXYWH();
const rect = new DOMRect(x + offsetX, y + offsetY, w, h);
return rect;
};

View File

@@ -34,7 +34,6 @@
},
"devDependencies": {
"@vitest/browser-playwright": "^4.0.18",
"playwright": "=1.58.2",
"vitest": "^4.0.18"
},
"exports": {

View File

@@ -103,9 +103,8 @@ export abstract class GfxPrimitiveElementModel<
}
get deserializedXYWH() {
const xywh = this.xywh;
if (!this._lastXYWH || xywh !== this._lastXYWH) {
if (!this._lastXYWH || this.xywh !== this._lastXYWH) {
const xywh = this.xywh;
this._local.set('deserializedXYWH', deserializeXYWH(xywh));
this._lastXYWH = xywh;
}
@@ -387,8 +386,6 @@ export abstract class GfxGroupLikeElementModel<
{
private _childIds: string[] = [];
private _xywhDirty = true;
private readonly _mutex = createMutex();
abstract children: Y.Map<any>;
@@ -423,9 +420,24 @@ export abstract class GfxGroupLikeElementModel<
get xywh() {
this._mutex(() => {
if (this._xywhDirty || !this._local.has('xywh')) {
this._local.set('xywh', this._getXYWH().serialize());
this._xywhDirty = false;
const curXYWH =
(this._local.get('xywh') as SerializedXYWH) ?? '[0,0,0,0]';
const newXYWH = this._getXYWH().serialize();
if (curXYWH !== newXYWH || !this._local.has('xywh')) {
this._local.set('xywh', newXYWH);
if (curXYWH !== newXYWH) {
this._onChange({
props: {
xywh: newXYWH,
},
oldValues: {
xywh: curXYWH,
},
local: true,
});
}
}
});
@@ -445,41 +457,15 @@ export abstract class GfxGroupLikeElementModel<
bound = bound ? bound.unite(child.elementBound) : child.elementBound;
});
if (bound) {
this._local.set('xywh', bound.serialize());
} else {
this._local.delete('xywh');
}
return bound ?? new Bound(0, 0, 0, 0);
}
invalidateXYWH() {
this._xywhDirty = true;
this._local.delete('deserializedXYWH');
}
refreshXYWH(local: boolean) {
this._mutex(() => {
const oldXYWH =
(this._local.get('xywh') as SerializedXYWH) ?? '[0,0,0,0]';
const nextXYWH = this._getXYWH().serialize();
this._xywhDirty = false;
if (oldXYWH === nextXYWH && this._local.has('xywh')) {
return;
}
this._local.set('xywh', nextXYWH);
this._local.delete('deserializedXYWH');
this._onChange({
props: {
xywh: nextXYWH,
},
oldValues: {
xywh: oldXYWH,
},
local,
});
});
}
abstract addChild(element: GfxModel): void;
/**
@@ -510,7 +496,6 @@ export abstract class GfxGroupLikeElementModel<
setChildIds(value: string[], fromLocal: boolean) {
const oldChildIds = this.childIds;
this._childIds = value;
this.invalidateXYWH();
this._onChange({
props: {

View File

@@ -52,12 +52,6 @@ export type MiddlewareCtx = {
export type SurfaceMiddleware = (ctx: MiddlewareCtx) => void;
export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
private static readonly _groupBoundImpactKeys = new Set([
'xywh',
'rotate',
'hidden',
]);
protected _decoratorState = createDecoratorState();
protected _elementCtorMap: Record<
@@ -314,42 +308,6 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
Object.keys(payload.props).forEach(key => {
model.propsUpdated.next({ key });
});
this._refreshParentGroupBoundsForElement(model, payload);
}
private _refreshParentGroupBounds(id: string, local: boolean) {
const group = this.getGroup(id);
if (group instanceof GfxGroupLikeElementModel) {
group.refreshXYWH(local);
}
}
private _refreshParentGroupBoundsForElement(
model: GfxPrimitiveElementModel,
payload: ElementUpdatedData
) {
if (
model instanceof GfxGroupLikeElementModel &&
('childIds' in payload.props || 'childIds' in payload.oldValues)
) {
model.refreshXYWH(payload.local);
return;
}
const affectedKeys = new Set([
...Object.keys(payload.props),
...Object.keys(payload.oldValues),
]);
if (
Array.from(affectedKeys).some(key =>
SurfaceBlockModel._groupBoundImpactKeys.has(key)
)
) {
this._refreshParentGroupBounds(model.id, payload.local);
}
}
private _initElementModels() {
@@ -500,10 +458,6 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
);
}
if (payload.model instanceof BlockModel) {
this._refreshParentGroupBounds(payload.id, payload.isLocal);
}
break;
case 'delete':
if (isGfxGroupCompatibleModel(payload.model)) {
@@ -528,13 +482,6 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
}
}
if (
payload.props.key &&
SurfaceBlockModel._groupBoundImpactKeys.has(payload.props.key)
) {
this._refreshParentGroupBounds(payload.id, payload.isLocal);
}
break;
}
});

View File

@@ -42,7 +42,6 @@
"devDependencies": {
"@vanilla-extract/vite-plugin": "^5.0.0",
"@vitest/browser-playwright": "^4.0.18",
"playwright": "=1.58.2",
"vite": "^7.2.7",
"vite-plugin-istanbul": "^7.2.1",
"vite-plugin-wasm": "^3.5.0",

View File

@@ -4,7 +4,6 @@ import type {
ConnectorElementModel,
GroupElementModel,
} from '@blocksuite/affine/model';
import { serializeXYWH } from '@blocksuite/global/gfx';
import { beforeEach, describe, expect, test } from 'vitest';
import { wait } from '../utils/common.js';
@@ -139,29 +138,6 @@ describe('group', () => {
expect(group.childIds).toEqual([id]);
});
test('group xywh should update when child xywh changes', () => {
const shapeId = model.addElement({
type: 'shape',
xywh: serializeXYWH(0, 0, 100, 100),
});
const groupId = model.addElement({
type: 'group',
children: {
[shapeId]: true,
},
});
const group = model.getElementById(groupId) as GroupElementModel;
expect(group.xywh).toBe(serializeXYWH(0, 0, 100, 100));
model.updateElement(shapeId, {
xywh: serializeXYWH(50, 60, 100, 100),
});
expect(group.xywh).toBe(serializeXYWH(50, 60, 100, 100));
});
});
describe('connector', () => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -22,7 +22,7 @@
"@toeverything/pdfium": "^0.1.1",
"@toeverything/y-indexeddb": "0.10.0-canary.9",
"@types/katex": "^0.16.7",
"browser-fs-access": "^0.38.0",
"browser-fs-access": "^0.37.0",
"jszip": "^3.10.1",
"katex": "^0.16.27",
"lit": "^3.2.0",

View File

@@ -1,48 +0,0 @@
[graph]
all-features = true
exclude-dev = true
targets = [
"x86_64-unknown-linux-gnu",
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"x86_64-pc-windows-msvc",
"aarch64-linux-android",
"aarch64-apple-ios",
"aarch64-apple-ios-sim",
]
[licenses]
allow = [
"0BSD",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"BSL-1.0",
"CC0-1.0",
"CDLA-Permissive-2.0",
"ISC",
"MIT",
"MPL-2.0",
"Unicode-3.0",
"Unlicense",
"Zlib",
]
confidence-threshold = 0.93
unused-allowed-license = "allow"
version = 2
[[licenses.exceptions]]
allow = ["AGPL-3.0-only"]
crate = "llm_adapter"
[[licenses.exceptions]]
allow = ["AGPL-3.0-or-later"]
crate = "memory-indexer"
[[licenses.exceptions]]
allow = ["AGPL-3.0-or-later"]
crate = "path-ext"
[licenses.private]
ignore = true

View File

@@ -92,7 +92,7 @@
"vite": "^7.2.7",
"vitest": "^4.0.18"
},
"packageManager": "yarn@4.13.0",
"packageManager": "yarn@4.12.0",
"resolutions": {
"array-buffer-byte-length": "npm:@nolyfill/array-buffer-byte-length@^1",
"array-includes": "npm:@nolyfill/array-includes@^1",

View File

@@ -2,7 +2,6 @@
edition = "2024"
license-file = "LICENSE"
name = "affine_server_native"
publish = false
version = "1.0.0"
[lib]

View File

@@ -32,7 +32,7 @@
"build:debug": "napi build"
},
"devDependencies": {
"@napi-rs/cli": "3.5.1",
"@napi-rs/cli": "3.5.0",
"tiktoken": "^1.0.17"
}
}

View File

@@ -33,30 +33,30 @@
"@nestjs-cls/transactional-adapter-prisma": "^1.3.4",
"@nestjs/apollo": "^13.0.4",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.1.17",
"@nestjs/core": "^11.1.17",
"@nestjs/common": "^11.0.21",
"@nestjs/core": "^11.1.14",
"@nestjs/graphql": "^13.0.4",
"@nestjs/platform-express": "^11.1.17",
"@nestjs/platform-socket.io": "^11.1.17",
"@nestjs/platform-express": "^11.1.14",
"@nestjs/platform-socket.io": "^11.1.14",
"@nestjs/schedule": "^6.1.1",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.17",
"@nestjs/websockets": "^11.1.14",
"@node-rs/argon2": "^2.0.2",
"@node-rs/crc32": "^1.10.6",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/core": "^2.2.0",
"@opentelemetry/exporter-prometheus": "^0.213.0",
"@opentelemetry/exporter-zipkin": "^2.6.0",
"@opentelemetry/host-metrics": "^0.38.3",
"@opentelemetry/instrumentation": "^0.213.0",
"@opentelemetry/instrumentation-graphql": "^0.61.0",
"@opentelemetry/instrumentation-http": "^0.213.0",
"@opentelemetry/instrumentation-ioredis": "^0.61.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.59.0",
"@opentelemetry/instrumentation-socket.io": "^0.60.0",
"@opentelemetry/exporter-prometheus": "^0.212.0",
"@opentelemetry/exporter-zipkin": "^2.2.0",
"@opentelemetry/host-metrics": "^0.38.0",
"@opentelemetry/instrumentation": "^0.212.0",
"@opentelemetry/instrumentation-graphql": "^0.60.0",
"@opentelemetry/instrumentation-http": "^0.212.0",
"@opentelemetry/instrumentation-ioredis": "^0.60.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.58.0",
"@opentelemetry/instrumentation-socket.io": "^0.59.0",
"@opentelemetry/resources": "^2.2.0",
"@opentelemetry/sdk-metrics": "^2.2.0",
"@opentelemetry/sdk-node": "^0.213.0",
"@opentelemetry/sdk-node": "^0.212.0",
"@opentelemetry/sdk-trace-node": "^2.2.0",
"@opentelemetry/semantic-conventions": "^1.38.0",
"@prisma/client": "^6.6.0",
@@ -72,14 +72,14 @@
"eventemitter2": "^6.4.9",
"exa-js": "^2.4.0",
"express": "^5.0.1",
"fast-xml-parser": "^5.5.7",
"fast-xml-parser": "^5.3.4",
"get-stream": "^9.0.1",
"google-auth-library": "^10.2.0",
"graphql": "^16.9.0",
"graphql-scalars": "^1.24.0",
"graphql-upload": "^17.0.0",
"html-validate": "^9.0.0",
"htmlrewriter": "^0.0.13",
"htmlrewriter": "^0.0.12",
"http-errors": "^2.0.0",
"ioredis": "^5.8.2",
"is-mobile": "^5.0.0",
@@ -96,7 +96,7 @@
"piscina": "^5.1.4",
"prisma": "^6.6.0",
"react": "^19.2.1",
"react-dom": "19.2.4",
"react-dom": "19.2.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"semver": "^7.7.3",
@@ -132,7 +132,7 @@
"@types/sinon": "^21.0.0",
"@types/supertest": "^7.0.0",
"ava": "^7.0.0",
"c8": "^10.1.3",
"c8": "^11.0.0",
"nodemon": "^3.1.14",
"react-email": "^4.3.2",
"sinon": "^21.0.1",

View File

@@ -1,35 +1,12 @@
import test from 'ava';
import { z } from 'zod';
import type { DocReader } from '../../core/doc';
import type { AccessController } from '../../core/permission';
import type { Models } from '../../models';
import { NativeLlmRequest, NativeLlmStreamEvent } from '../../native';
import {
ToolCallAccumulator,
ToolCallLoop,
ToolSchemaExtractor,
} from '../../plugins/copilot/providers/loop';
import {
buildBlobContentGetter,
createBlobReadTool,
} from '../../plugins/copilot/tools/blob-read';
import {
buildDocKeywordSearchGetter,
createDocKeywordSearchTool,
} from '../../plugins/copilot/tools/doc-keyword-search';
import {
buildDocContentGetter,
createDocReadTool,
} from '../../plugins/copilot/tools/doc-read';
import {
buildDocSearchGetter,
createDocSemanticSearchTool,
} from '../../plugins/copilot/tools/doc-semantic-search';
import {
DOCUMENT_SYNC_PENDING_MESSAGE,
LOCAL_WORKSPACE_SYNC_REQUIRED_MESSAGE,
} from '../../plugins/copilot/tools/doc-sync';
test('ToolCallAccumulator should merge deltas and complete tool call', t => {
const accumulator = new ToolCallAccumulator();
@@ -309,210 +286,3 @@ test('ToolCallLoop should surface invalid JSON as tool error without executing',
is_error: true,
});
});
test('doc_read should return specific sync errors for unavailable docs', async t => {
const cases = [
{
name: 'local workspace without cloud sync',
workspace: null,
authors: null,
markdown: null,
expected: {
type: 'error',
name: 'Workspace Sync Required',
message: LOCAL_WORKSPACE_SYNC_REQUIRED_MESSAGE,
},
docReaderCalled: false,
},
{
name: 'cloud workspace document not synced to server yet',
workspace: { id: 'ws-1' },
authors: null,
markdown: null,
expected: {
type: 'error',
name: 'Document Sync Pending',
message: DOCUMENT_SYNC_PENDING_MESSAGE('doc-1'),
},
docReaderCalled: false,
},
{
name: 'cloud workspace document markdown not ready yet',
workspace: { id: 'ws-1' },
authors: {
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
createdByUser: null,
updatedByUser: null,
},
markdown: null,
expected: {
type: 'error',
name: 'Document Sync Pending',
message: DOCUMENT_SYNC_PENDING_MESSAGE('doc-1'),
},
docReaderCalled: true,
},
] as const;
const ac = {
user: () => ({
workspace: () => ({ doc: () => ({ can: async () => true }) }),
}),
} as unknown as AccessController;
for (const testCase of cases) {
let docReaderCalled = false;
const docReader = {
getDocMarkdown: async () => {
docReaderCalled = true;
return testCase.markdown;
},
} as unknown as DocReader;
const models = {
workspace: {
get: async () => testCase.workspace,
},
doc: {
getAuthors: async () => testCase.authors,
},
} as unknown as Models;
const getDoc = buildDocContentGetter(ac, docReader, models);
const tool = createDocReadTool(
getDoc.bind(null, {
user: 'user-1',
workspace: 'workspace-1',
})
);
const result = await tool.execute?.({ doc_id: 'doc-1' }, {});
t.is(docReaderCalled, testCase.docReaderCalled, testCase.name);
t.deepEqual(result, testCase.expected, testCase.name);
}
});
test('document search tools should return sync error for local workspace', async t => {
const ac = {
user: () => ({
workspace: () => ({
can: async () => true,
docs: async () => [],
}),
}),
} as unknown as AccessController;
const models = {
workspace: {
get: async () => null,
},
} as unknown as Models;
let keywordSearchCalled = false;
const indexerService = {
searchDocsByKeyword: async () => {
keywordSearchCalled = true;
return [];
},
} as unknown as Parameters<typeof buildDocKeywordSearchGetter>[1];
let semanticSearchCalled = false;
const contextService = {
matchWorkspaceAll: async () => {
semanticSearchCalled = true;
return [];
},
} as unknown as Parameters<typeof buildDocSearchGetter>[1];
const keywordTool = createDocKeywordSearchTool(
buildDocKeywordSearchGetter(ac, indexerService, models).bind(null, {
user: 'user-1',
workspace: 'workspace-1',
})
);
const semanticTool = createDocSemanticSearchTool(
buildDocSearchGetter(ac, contextService, null, models).bind(null, {
user: 'user-1',
workspace: 'workspace-1',
})
);
const keywordResult = await keywordTool.execute?.({ query: 'hello' }, {});
const semanticResult = await semanticTool.execute?.({ query: 'hello' }, {});
t.false(keywordSearchCalled);
t.false(semanticSearchCalled);
t.deepEqual(keywordResult, {
type: 'error',
name: 'Workspace Sync Required',
message: LOCAL_WORKSPACE_SYNC_REQUIRED_MESSAGE,
});
t.deepEqual(semanticResult, {
type: 'error',
name: 'Workspace Sync Required',
message: LOCAL_WORKSPACE_SYNC_REQUIRED_MESSAGE,
});
});
test('doc_semantic_search should return empty array when nothing matches', async t => {
const ac = {
user: () => ({
workspace: () => ({
can: async () => true,
docs: async () => [],
}),
}),
} as unknown as AccessController;
const models = {
workspace: {
get: async () => ({ id: 'workspace-1' }),
},
} as unknown as Models;
const contextService = {
matchWorkspaceAll: async () => [],
} as unknown as Parameters<typeof buildDocSearchGetter>[1];
const semanticTool = createDocSemanticSearchTool(
buildDocSearchGetter(ac, contextService, null, models).bind(null, {
user: 'user-1',
workspace: 'workspace-1',
})
);
const result = await semanticTool.execute?.({ query: 'hello' }, {});
t.deepEqual(result, []);
});
test('blob_read should return explicit error when attachment context is missing', async t => {
const ac = {
user: () => ({
workspace: () => ({
allowLocal: () => ({
can: async () => true,
}),
}),
}),
} as unknown as AccessController;
const blobTool = createBlobReadTool(
buildBlobContentGetter(ac, null).bind(null, {
user: 'user-1',
workspace: 'workspace-1',
})
);
const result = await blobTool.execute?.({ blob_id: 'blob-1' }, {});
t.deepEqual(result, {
type: 'error',
name: 'Blob Read Failed',
message:
'Missing workspace, user, blob id, or copilot context for blob_read.',
});
});

View File

@@ -6,16 +6,13 @@ import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import { AppModule } from '../../app.module';
import { ConfigFactory, InvalidOauthResponse, URLHelper } from '../../base';
import { ConfigFactory, URLHelper } from '../../base';
import { ConfigModule } from '../../base/config';
import { CurrentUser } from '../../core/auth';
import { AuthService } from '../../core/auth/service';
import { ServerFeature } from '../../core/config/types';
import { Models } from '../../models';
import { OAuthProviderName } from '../../plugins/oauth/config';
import { OAuthProviderFactory } from '../../plugins/oauth/factory';
import { GoogleOAuthProvider } from '../../plugins/oauth/providers/google';
import { OIDCProvider } from '../../plugins/oauth/providers/oidc';
import { OAuthService } from '../../plugins/oauth/service';
import { createTestingApp, currentUser, TestingApp } from '../utils';
@@ -38,12 +35,6 @@ test.before(async t => {
clientId: 'google-client-id',
clientSecret: 'google-client-secret',
},
oidc: {
clientId: '',
clientSecret: '',
issuer: '',
args: {},
},
},
},
server: {
@@ -441,87 +432,6 @@ function mockOAuthProvider(
return clientNonce;
}
function mockOidcProvider(
provider: OIDCProvider,
{
args = {},
idTokenClaims,
userinfo,
}: {
args?: Record<string, string>;
idTokenClaims: Record<string, unknown>;
userinfo: Record<string, unknown>;
}
) {
Sinon.stub(provider, 'config').get(() => ({
clientId: '',
clientSecret: '',
issuer: '',
args,
}));
Sinon.stub(
provider as unknown as { endpoints: { userinfo_endpoint: string } },
'endpoints'
).get(() => ({
userinfo_endpoint: 'https://oidc.affine.dev/userinfo',
}));
Sinon.stub(
provider as unknown as { verifyIdToken: () => unknown },
'verifyIdToken'
).resolves(idTokenClaims);
Sinon.stub(
provider as unknown as { fetchJson: () => unknown },
'fetchJson'
).resolves(userinfo);
}
function createOidcRegistrationHarness(config?: {
clientId?: string;
clientSecret?: string;
issuer?: string;
}) {
const server = {
enableFeature: Sinon.spy(),
disableFeature: Sinon.spy(),
};
const factory = new OAuthProviderFactory(server as any);
const affineConfig = {
server: {
externalUrl: 'https://affine.example',
host: 'localhost',
path: '',
https: true,
hosts: [],
},
oauth: {
providers: {
oidc: {
clientId: config?.clientId ?? 'oidc-client-id',
clientSecret: config?.clientSecret ?? 'oidc-client-secret',
issuer: config?.issuer ?? 'https://issuer.affine.dev',
args: {},
},
},
},
};
const provider = new OIDCProvider(new URLHelper(affineConfig as any));
(provider as any).factory = factory;
(provider as any).AFFiNEConfig = affineConfig;
return {
provider,
factory,
server,
};
}
async function flushAsyncWork(iterations = 5) {
for (let i = 0; i < iterations; i++) {
await new Promise(resolve => setImmediate(resolve));
}
}
test('should be able to sign up with oauth', async t => {
const { app, db } = t.context;
@@ -644,209 +554,3 @@ test('should be able to fullfil user with oauth sign in', async t => {
t.truthy(account);
t.is(account!.user.id, u3.id);
});
test('oidc should accept email from id token when userinfo email is missing', async t => {
const { app } = t.context;
const provider = app.get(OIDCProvider);
mockOidcProvider(provider, {
idTokenClaims: {
sub: 'oidc-user',
email: 'oidc-id-token@affine.pro',
name: 'OIDC User',
},
userinfo: {
sub: 'oidc-user',
name: 'OIDC User',
},
});
const user = await provider.getUser(
{ accessToken: 'token', idToken: 'id-token' },
{ token: 'nonce', provider: OAuthProviderName.OIDC }
);
t.is(user.id, 'oidc-user');
t.is(user.email, 'oidc-id-token@affine.pro');
t.is(user.name, 'OIDC User');
});
test('oidc should resolve custom email claim from userinfo', async t => {
const { app } = t.context;
const provider = app.get(OIDCProvider);
mockOidcProvider(provider, {
args: { claim_email: 'mail', claim_name: 'display_name' },
idTokenClaims: {
sub: 'oidc-user',
},
userinfo: {
sub: 'oidc-user',
mail: 'oidc-userinfo@affine.pro',
display_name: 'OIDC Custom',
},
});
const user = await provider.getUser(
{ accessToken: 'token', idToken: 'id-token' },
{ token: 'nonce', provider: OAuthProviderName.OIDC }
);
t.is(user.id, 'oidc-user');
t.is(user.email, 'oidc-userinfo@affine.pro');
t.is(user.name, 'OIDC Custom');
});
test('oidc should resolve custom email claim from id token', async t => {
const { app } = t.context;
const provider = app.get(OIDCProvider);
mockOidcProvider(provider, {
args: { claim_email: 'mail', claim_email_verified: 'mail_verified' },
idTokenClaims: {
sub: 'oidc-user',
mail: 'oidc-custom-id-token@affine.pro',
mail_verified: 'true',
},
userinfo: {
sub: 'oidc-user',
},
});
const user = await provider.getUser(
{ accessToken: 'token', idToken: 'id-token' },
{ token: 'nonce', provider: OAuthProviderName.OIDC }
);
t.is(user.id, 'oidc-user');
t.is(user.email, 'oidc-custom-id-token@affine.pro');
});
test('oidc should reject responses without a usable email claim', async t => {
const { app } = t.context;
const provider = app.get(OIDCProvider);
mockOidcProvider(provider, {
args: { claim_email: 'mail' },
idTokenClaims: {
sub: 'oidc-user',
mail: 'not-an-email',
},
userinfo: {
sub: 'oidc-user',
mail: 'still-not-an-email',
},
});
const error = await t.throwsAsync(
provider.getUser(
{ accessToken: 'token', idToken: 'id-token' },
{ token: 'nonce', provider: OAuthProviderName.OIDC }
)
);
t.true(error instanceof InvalidOauthResponse);
t.true(
error.message.includes(
'Missing valid email claim in OIDC response. Tried userinfo and ID token claims: "mail"'
)
);
});
test('oidc should not fall back to default email claim when custom claim is configured', async t => {
const { app } = t.context;
const provider = app.get(OIDCProvider);
mockOidcProvider(provider, {
args: { claim_email: 'mail' },
idTokenClaims: {
sub: 'oidc-user',
email: 'fallback@affine.pro',
},
userinfo: {
sub: 'oidc-user',
email: 'userinfo-fallback@affine.pro',
},
});
const error = await t.throwsAsync(
provider.getUser(
{ accessToken: 'token', idToken: 'id-token' },
{ token: 'nonce', provider: OAuthProviderName.OIDC }
)
);
t.true(error instanceof InvalidOauthResponse);
t.true(
error.message.includes(
'Missing valid email claim in OIDC response. Tried userinfo and ID token claims: "mail"'
)
);
});
test('oidc discovery should remove oauth feature on failure and restore it after backoff retry succeeds', async t => {
const { provider, factory, server } = createOidcRegistrationHarness();
const fetchStub = Sinon.stub(globalThis, 'fetch');
const scheduledRetries: Array<() => void> = [];
const retryDelays: number[] = [];
const setTimeoutStub = Sinon.stub(globalThis, 'setTimeout').callsFake(((
callback: Parameters<typeof setTimeout>[0],
delay?: number
) => {
retryDelays.push(Number(delay));
scheduledRetries.push(callback as () => void);
return Symbol('timeout') as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout);
t.teardown(() => {
provider.onModuleDestroy();
fetchStub.restore();
setTimeoutStub.restore();
});
fetchStub
.onFirstCall()
.rejects(new Error('temporary discovery failure'))
.onSecondCall()
.rejects(new Error('temporary discovery failure'))
.onThirdCall()
.resolves(
new Response(
JSON.stringify({
authorization_endpoint: 'https://issuer.affine.dev/auth',
token_endpoint: 'https://issuer.affine.dev/token',
userinfo_endpoint: 'https://issuer.affine.dev/userinfo',
issuer: 'https://issuer.affine.dev',
jwks_uri: 'https://issuer.affine.dev/jwks',
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
);
(provider as any).setup();
await flushAsyncWork();
t.deepEqual(factory.providers, []);
t.true(server.disableFeature.calledWith(ServerFeature.OAuth));
t.is(fetchStub.callCount, 1);
t.deepEqual(retryDelays, [1000]);
const firstRetry = scheduledRetries.shift();
t.truthy(firstRetry);
firstRetry!();
await flushAsyncWork();
t.is(fetchStub.callCount, 2);
t.deepEqual(factory.providers, []);
t.deepEqual(retryDelays, [1000, 2000]);
const secondRetry = scheduledRetries.shift();
t.truthy(secondRetry);
secondRetry!();
await flushAsyncWork();
t.is(fetchStub.callCount, 3);
t.deepEqual(factory.providers, [OAuthProviderName.OIDC]);
t.true(server.enableFeature.calledWith(ServerFeature.OAuth));
t.is(scheduledRetries.length, 0);
});

View File

@@ -111,20 +111,3 @@ test('delete', async t => {
await t.throwsAsync(() => fs.access(join(config.path, provider.bucket, key)));
});
test('rejects unsafe object keys', async t => {
const provider = createProvider();
await t.throwsAsync(() => provider.put('../escape', Buffer.from('nope')));
await t.throwsAsync(() => provider.get('nested/../escape'));
await t.throwsAsync(() => provider.head('./escape'));
t.throws(() => provider.delete('nested//escape'));
});
test('rejects unsafe list prefixes', async t => {
const provider = createProvider();
await t.throwsAsync(() => provider.list('../escape'));
await t.throwsAsync(() => provider.list('nested/../../escape'));
await t.throwsAsync(() => provider.list('/absolute'));
});

View File

@@ -25,47 +25,9 @@ import {
} from './provider';
import { autoMetadata, toBuffer } from './utils';
function normalizeStorageKey(key: string): string {
const normalized = key.replaceAll('\\', '/');
const segments = normalized.split('/');
if (
!normalized ||
normalized.startsWith('/') ||
segments.some(segment => !segment || segment === '.' || segment === '..')
) {
throw new Error(`Invalid storage key: ${key}`);
}
return segments.join('/');
}
function normalizeStoragePrefix(prefix: string): string {
const normalized = prefix.replaceAll('\\', '/');
if (!normalized) {
return normalized;
}
if (normalized.startsWith('/')) {
throw new Error(`Invalid storage prefix: ${prefix}`);
}
const segments = normalized.split('/');
const lastSegment = segments.pop();
if (
lastSegment === undefined ||
segments.some(segment => !segment || segment === '.' || segment === '..') ||
lastSegment === '.' ||
lastSegment === '..'
) {
throw new Error(`Invalid storage prefix: ${prefix}`);
}
if (lastSegment === '') {
return `${segments.join('/')}/`;
}
return [...segments, lastSegment].join('/');
function escapeKey(key: string): string {
// avoid '../' and './' in key
return key.replace(/\.?\.[/\\]/g, '%');
}
export interface FsStorageConfig {
@@ -95,7 +57,7 @@ export class FsStorageProvider implements StorageProvider {
body: BlobInputType,
metadata: PutObjectMetadata = {}
): Promise<void> {
key = normalizeStorageKey(key);
key = escapeKey(key);
const blob = await toBuffer(body);
// write object
@@ -106,7 +68,6 @@ export class FsStorageProvider implements StorageProvider {
}
async head(key: string) {
key = normalizeStorageKey(key);
const metadata = this.readMetadata(key);
if (!metadata) {
this.logger.verbose(`Object \`${key}\` not found`);
@@ -119,7 +80,7 @@ export class FsStorageProvider implements StorageProvider {
body?: Readable;
metadata?: GetObjectMetadata;
}> {
key = normalizeStorageKey(key);
key = escapeKey(key);
try {
const metadata = this.readMetadata(key);
@@ -144,7 +105,7 @@ export class FsStorageProvider implements StorageProvider {
// read dir recursively and filter out '.metadata.json' files
let dir = this.path;
if (prefix) {
prefix = normalizeStoragePrefix(prefix);
prefix = escapeKey(prefix);
const parts = prefix.split(/[/\\]/);
// for prefix `a/b/c`, move `a/b` to dir and `c` to key prefix
if (parts.length > 1) {
@@ -191,7 +152,7 @@ export class FsStorageProvider implements StorageProvider {
}
delete(key: string): Promise<void> {
key = normalizeStorageKey(key);
key = escapeKey(key);
try {
rmSync(this.join(key), { force: true });

View File

@@ -1,75 +0,0 @@
import test from 'ava';
import Sinon from 'sinon';
import {
exponentialBackoffDelay,
ExponentialBackoffScheduler,
} from '../promise';
test('exponentialBackoffDelay should cap exponential growth at maxDelayMs', t => {
t.is(exponentialBackoffDelay(0, { baseDelayMs: 100, maxDelayMs: 500 }), 100);
t.is(exponentialBackoffDelay(1, { baseDelayMs: 100, maxDelayMs: 500 }), 200);
t.is(exponentialBackoffDelay(3, { baseDelayMs: 100, maxDelayMs: 500 }), 500);
});
test('ExponentialBackoffScheduler should track pending callback and increase delay per attempt', async t => {
const clock = Sinon.useFakeTimers();
t.teardown(() => {
clock.restore();
});
const calls: number[] = [];
const scheduler = new ExponentialBackoffScheduler({
baseDelayMs: 100,
maxDelayMs: 500,
});
t.is(
scheduler.schedule(() => {
calls.push(1);
}),
100
);
t.true(scheduler.pending);
t.is(
scheduler.schedule(() => {
calls.push(2);
}),
null
);
await clock.tickAsync(100);
t.deepEqual(calls, [1]);
t.false(scheduler.pending);
t.is(
scheduler.schedule(() => {
calls.push(3);
}),
200
);
await clock.tickAsync(200);
t.deepEqual(calls, [1, 3]);
});
test('ExponentialBackoffScheduler reset should clear pending work and restart from the base delay', t => {
const scheduler = new ExponentialBackoffScheduler({
baseDelayMs: 100,
maxDelayMs: 500,
});
t.is(
scheduler.schedule(() => {}),
100
);
t.true(scheduler.pending);
scheduler.reset();
t.false(scheduler.pending);
t.is(
scheduler.schedule(() => {}),
100
);
scheduler.clear();
});

View File

@@ -1,4 +1,4 @@
import { setTimeout as delay } from 'node:timers/promises';
import { setTimeout } from 'node:timers/promises';
import { defer as rxjsDefer, retry } from 'rxjs';
@@ -52,61 +52,5 @@ export function defer(dispose: () => Promise<void>) {
}
export function sleep(ms: number): Promise<void> {
return delay(ms);
}
export function exponentialBackoffDelay(
attempt: number,
{
baseDelayMs,
maxDelayMs,
factor = 2,
}: { baseDelayMs: number; maxDelayMs: number; factor?: number }
): number {
return Math.min(
baseDelayMs * Math.pow(factor, Math.max(0, attempt)),
maxDelayMs
);
}
export class ExponentialBackoffScheduler {
#attempt = 0;
#timer: ReturnType<typeof globalThis.setTimeout> | null = null;
constructor(
private readonly options: {
baseDelayMs: number;
maxDelayMs: number;
factor?: number;
}
) {}
get pending() {
return this.#timer !== null;
}
clear() {
if (this.#timer) {
clearTimeout(this.#timer);
this.#timer = null;
}
}
reset() {
this.#attempt = 0;
this.clear();
}
schedule(callback: () => void) {
if (this.#timer) return null;
const timeout = exponentialBackoffDelay(this.#attempt, this.options);
this.#timer = globalThis.setTimeout(() => {
this.#timer = null;
callback();
}, timeout);
this.#attempt += 1;
return timeout;
}
return setTimeout(ms);
}

View File

@@ -1,12 +0,0 @@
import { ModuleRef } from '@nestjs/core';
import { PrismaClient } from '@prisma/client';
import { IndexerService } from '../../plugins/indexer';
export class RebuildManticoreMixedScriptIndexes1763800000000 {
static async up(_db: PrismaClient, ref: ModuleRef) {
await ref.get(IndexerService, { strict: false }).rebuildManticoreIndexes();
}
static async down(_db: PrismaClient) {}
}

View File

@@ -3,4 +3,3 @@ export * from './1703756315970-unamed-account';
export * from './1721299086340-refresh-unnamed-user';
export * from './1745211351719-create-indexer-tables';
export * from './1751966744168-correct-session-update-time';
export * from './1763800000000-rebuild-manticore-mixed-script-indexes';

View File

@@ -258,7 +258,7 @@ export class FalProvider extends CopilotProvider<FalConfig> {
const model = this.selectModel(cond);
try {
metrics.ai.counter('chat_text_calls').add(1, this.metricLabels(model.id));
metrics.ai.counter('chat_text_calls').add(1, { model: model.id });
// by default, image prompt assumes there is only one message
const prompt = this.extractPrompt(messages[messages.length - 1]);
@@ -283,9 +283,7 @@ export class FalProvider extends CopilotProvider<FalConfig> {
}
return data.output;
} catch (e: any) {
metrics.ai
.counter('chat_text_errors')
.add(1, this.metricLabels(model.id));
metrics.ai.counter('chat_text_errors').add(1, { model: model.id });
throw this.handleError(e);
}
}
@@ -298,16 +296,12 @@ export class FalProvider extends CopilotProvider<FalConfig> {
const model = this.selectModel(cond);
try {
metrics.ai
.counter('chat_text_stream_calls')
.add(1, this.metricLabels(model.id));
metrics.ai.counter('chat_text_stream_calls').add(1, { model: model.id });
const result = await this.text(cond, messages, options);
yield result;
} catch (e) {
metrics.ai
.counter('chat_text_stream_errors')
.add(1, this.metricLabels(model.id));
metrics.ai.counter('chat_text_stream_errors').add(1, { model: model.id });
throw e;
}
}
@@ -325,7 +319,7 @@ export class FalProvider extends CopilotProvider<FalConfig> {
try {
metrics.ai
.counter('generate_images_stream_calls')
.add(1, this.metricLabels(model.id));
.add(1, { model: model.id });
// by default, image prompt assumes there is only one message
const prompt = this.extractPrompt(
@@ -382,7 +376,7 @@ export class FalProvider extends CopilotProvider<FalConfig> {
} catch (e) {
metrics.ai
.counter('generate_images_stream_errors')
.add(1, this.metricLabels(model.id));
.add(1, { model: model.id });
throw this.handleError(e);
}
}

View File

@@ -664,7 +664,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
const model = this.selectModel(normalizedCond);
try {
metrics.ai.counter('chat_text_calls').add(1, this.metricLabels(model.id));
metrics.ai.counter('chat_text_calls').add(1, { model: model.id });
const backendConfig = this.createNativeConfig();
const middleware = this.getActiveProviderMiddleware();
const cap = this.getAttachCapability(model, ModelOutputType.Structured);
@@ -687,9 +687,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
const validated = schema.parse(parsed);
return JSON.stringify(validated);
} catch (e: any) {
metrics.ai
.counter('chat_text_errors')
.add(1, this.metricLabels(model.id));
metrics.ai.counter('chat_text_errors').add(1, { model: model.id });
throw this.handleError(e);
}
}
@@ -985,7 +983,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
metrics.ai
.counter('generate_images_stream_calls')
.add(1, this.metricLabels(model.id));
.add(1, { model: model.id });
const { content: prompt, attachments } = [...messages].pop() || {};
if (!prompt) throw new CopilotPromptInvalid('Prompt is required');
@@ -1023,9 +1021,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
}
return;
} catch (e: any) {
metrics.ai
.counter('generate_images_errors')
.add(1, this.metricLabels(model.id));
metrics.ai.counter('generate_images_errors').add(1, { model: model.id });
throw this.handleError(e);
}
}

View File

@@ -470,8 +470,7 @@ export abstract class CopilotProvider<C = any> {
});
const searchDocs = buildDocKeywordSearchGetter(
ac,
indexerService,
models
indexerService
);
tools.doc_keyword_search = createDocKeywordSearchTool(
searchDocs.bind(null, options)

View File

@@ -18,10 +18,7 @@ export const buildBlobContentGetter = (
chunk?: number
) => {
if (!options?.user || !options?.workspace || !blobId || !context) {
return toolError(
'Blob Read Failed',
'Missing workspace, user, blob id, or copilot context for blob_read.'
);
return;
}
const canAccess = await ac
.user(options.user)
@@ -32,10 +29,7 @@ export const buildBlobContentGetter = (
logger.warn(
`User ${options.user} does not have access workspace ${options.workspace}`
);
return toolError(
'Blob Read Failed',
'You do not have permission to access this workspace attachment.'
);
return;
}
const contextFile = context.files.find(
@@ -48,12 +42,7 @@ export const buildBlobContentGetter = (
context.getBlobContent(canonicalBlobId, chunk),
]);
const content = file?.trim() || blob?.trim();
if (!content) {
return toolError(
'Blob Read Failed',
`Attachment ${canonicalBlobId} is not available for reading in the current copilot context.`
);
}
if (!content) return;
const info = contextFile
? { fileName: contextFile.name, fileType: contextFile.mimeType }
: {};
@@ -64,7 +53,10 @@ export const buildBlobContentGetter = (
};
export const createBlobReadTool = (
getBlobContent: (targetId?: string, chunk?: number) => Promise<object>
getBlobContent: (
targetId?: string,
chunk?: number
) => Promise<object | undefined>
) => {
return defineTool({
description:
@@ -81,10 +73,13 @@ export const createBlobReadTool = (
execute: async ({ blob_id, chunk }) => {
try {
const blob = await getBlobContent(blob_id, chunk);
if (!blob) {
return;
}
return { ...blob };
} catch (err: any) {
logger.error(`Failed to read the blob ${blob_id} in context`, err);
return toolError('Blob Read Failed', err.message ?? String(err));
return toolError('Blob Read Failed', err.message);
}
},
});

View File

@@ -1,43 +1,27 @@
import { z } from 'zod';
import type { AccessController } from '../../../core/permission';
import type { Models } from '../../../models';
import type { IndexerService, SearchDoc } from '../../indexer';
import { workspaceSyncRequiredError } from './doc-sync';
import { toolError } from './error';
import { defineTool } from './tool';
import type { CopilotChatOptions } from './types';
export const buildDocKeywordSearchGetter = (
ac: AccessController,
indexerService: IndexerService,
models: Models
indexerService: IndexerService
) => {
const searchDocs = async (options: CopilotChatOptions, query?: string) => {
const queryTrimmed = query?.trim();
if (!options || !queryTrimmed || !options.user || !options.workspace) {
return toolError(
'Doc Keyword Search Failed',
'Missing workspace, user, or query for doc_keyword_search.'
);
}
const workspace = await models.workspace.get(options.workspace);
if (!workspace) {
return workspaceSyncRequiredError();
if (!options || !query?.trim() || !options.user || !options.workspace) {
return undefined;
}
const canAccess = await ac
.user(options.user)
.workspace(options.workspace)
.can('Workspace.Read');
if (!canAccess) {
return toolError(
'Doc Keyword Search Failed',
'You do not have permission to access this workspace.'
);
}
if (!canAccess) return undefined;
const docs = await indexerService.searchDocsByKeyword(
options.workspace,
queryTrimmed
query
);
// filter current user readable docs
@@ -45,15 +29,13 @@ export const buildDocKeywordSearchGetter = (
.user(options.user)
.workspace(options.workspace)
.docs(docs, 'Doc.Read');
return readableDocs ?? [];
return readableDocs;
};
return searchDocs;
};
export const createDocKeywordSearchTool = (
searchDocs: (
query: string
) => Promise<SearchDoc[] | ReturnType<typeof toolError>>
searchDocs: (query: string) => Promise<SearchDoc[] | undefined>
) => {
return defineTool({
description:
@@ -68,8 +50,8 @@ export const createDocKeywordSearchTool = (
execute: async ({ query }) => {
try {
const docs = await searchDocs(query);
if (!Array.isArray(docs)) {
return docs;
if (!docs) {
return;
}
return docs.map(doc => ({
docId: doc.docId,

View File

@@ -3,20 +3,13 @@ import { z } from 'zod';
import { DocReader } from '../../../core/doc';
import { AccessController } from '../../../core/permission';
import { Models } from '../../../models';
import {
documentSyncPendingError,
workspaceSyncRequiredError,
} from './doc-sync';
import { type ToolError, toolError } from './error';
import { Models, publicUserSelect } from '../../../models';
import { toolError } from './error';
import { defineTool } from './tool';
import type { CopilotChatOptions } from './types';
const logger = new Logger('DocReadTool');
const isToolError = (result: ToolError | object): result is ToolError =>
'type' in result && result.type === 'error';
export const buildDocContentGetter = (
ac: AccessController,
docReader: DocReader,
@@ -24,17 +17,8 @@ export const buildDocContentGetter = (
) => {
const getDoc = async (options: CopilotChatOptions, docId?: string) => {
if (!options?.user || !options?.workspace || !docId) {
return toolError(
'Doc Read Failed',
'Missing workspace, user, or document id for doc_read.'
);
return;
}
const workspace = await models.workspace.get(options.workspace);
if (!workspace) {
return workspaceSyncRequiredError();
}
const canAccess = await ac
.user(options.user)
.workspace(options.workspace)
@@ -44,15 +28,23 @@ export const buildDocContentGetter = (
logger.warn(
`User ${options.user} does not have access to doc ${docId} in workspace ${options.workspace}`
);
return toolError(
'Doc Read Failed',
`You do not have permission to read document ${docId} in this workspace.`
);
return;
}
const docMeta = await models.doc.getAuthors(options.workspace, docId);
const docMeta = await models.doc.getSnapshot(options.workspace, docId, {
select: {
createdAt: true,
updatedAt: true,
createdByUser: {
select: publicUserSelect,
},
updatedByUser: {
select: publicUserSelect,
},
},
});
if (!docMeta) {
return documentSyncPendingError(docId);
return;
}
const content = await docReader.getDocMarkdown(
@@ -61,7 +53,7 @@ export const buildDocContentGetter = (
true
);
if (!content) {
return documentSyncPendingError(docId);
return;
}
return {
@@ -77,12 +69,8 @@ export const buildDocContentGetter = (
return getDoc;
};
type DocReadToolResult = Awaited<
ReturnType<ReturnType<typeof buildDocContentGetter>>
>;
export const createDocReadTool = (
getDoc: (targetId?: string) => Promise<DocReadToolResult>
getDoc: (targetId?: string) => Promise<object | undefined>
) => {
return defineTool({
description:
@@ -93,10 +81,13 @@ export const createDocReadTool = (
execute: async ({ doc_id }) => {
try {
const doc = await getDoc(doc_id);
return isToolError(doc) ? doc : { ...doc };
if (!doc) {
return;
}
return { ...doc };
} catch (err: any) {
logger.error(`Failed to read the doc ${doc_id}`, err);
return toolError('Doc Read Failed', err.message ?? String(err));
return toolError('Doc Read Failed', err.message);
}
},
});

View File

@@ -7,7 +7,6 @@ import {
clearEmbeddingChunk,
type Models,
} from '../../../models';
import { workspaceSyncRequiredError } from './doc-sync';
import { toolError } from './error';
import { defineTool } from './tool';
import type {
@@ -28,24 +27,14 @@ export const buildDocSearchGetter = (
signal?: AbortSignal
) => {
if (!options || !query?.trim() || !options.user || !options.workspace) {
return toolError(
'Doc Semantic Search Failed',
'Missing workspace, user, or query for doc_semantic_search.'
);
}
const workspace = await models.workspace.get(options.workspace);
if (!workspace) {
return workspaceSyncRequiredError();
return `Invalid search parameters.`;
}
const canAccess = await ac
.user(options.user)
.workspace(options.workspace)
.can('Workspace.Read');
if (!canAccess)
return toolError(
'Doc Semantic Search Failed',
'You do not have permission to access this workspace.'
);
return 'You do not have permission to access this workspace.';
const [chunks, contextChunks] = await Promise.all([
context.matchWorkspaceAll(options.workspace, query, 10, signal),
docContext?.matchFiles(query, 10, signal) ?? [],
@@ -64,7 +53,7 @@ export const buildDocSearchGetter = (
fileChunks.push(...contextChunks);
}
if (!blobChunks.length && !docChunks.length && !fileChunks.length) {
return [];
return `No results found for "${query}".`;
}
const docIds = docChunks.map(c => ({
@@ -112,7 +101,7 @@ export const createDocSemanticSearchTool = (
searchDocs: (
query: string,
signal?: AbortSignal
) => Promise<ChunkSimilarity[] | ReturnType<typeof toolError>>
) => Promise<ChunkSimilarity[] | string | undefined>
) => {
return defineTool({
description:

View File

@@ -1,13 +0,0 @@
import { toolError } from './error';
export const LOCAL_WORKSPACE_SYNC_REQUIRED_MESSAGE =
'This workspace is local-only and does not have AFFiNE Cloud sync enabled yet. Ask the user to enable workspace sync, then try again.';
export const DOCUMENT_SYNC_PENDING_MESSAGE = (docId: string) =>
`Document ${docId} is not available on AFFiNE Cloud yet. Ask the user to wait for workspace sync to finish, then try again.`;
export const workspaceSyncRequiredError = () =>
toolError('Workspace Sync Required', LOCAL_WORKSPACE_SYNC_REQUIRED_MESSAGE);
export const documentSyncPendingError = (docId: string) =>
toolError('Document Sync Pending', DOCUMENT_SYNC_PENDING_MESSAGE(docId));

View File

@@ -7,8 +7,7 @@ import { defineTool } from './tool';
export const createExaSearchTool = (config: Config) => {
return defineTool({
description:
'Search the web using Exa, one of the best web search APIs for AI',
description: 'Search the web for information',
inputSchema: z.object({
query: z.string().describe('The query to search the web for.'),
mode: z

View File

@@ -4,75 +4,6 @@ The actual snapshot is saved in `manticoresearch.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## should search doc title match chinese word segmentation
> Snapshot 1
[
{
_id: '5373363211628325828',
_source: {
doc_id: 'doc-chinese',
workspace_id: 'workspace-test-doc-title-chinese',
},
fields: {
doc_id: [
'doc-chinese',
],
title: [
'AFFiNE 是一个基于云端的笔记应用',
],
},
highlights: undefined,
},
]
## should search block content match korean ngram
> Snapshot 1
[
{
_id: '1227635764506850985',
_source: {
doc_id: 'doc-korean',
workspace_id: 'workspace-test-block-content-korean',
},
fields: {
block_id: [
'block-korean',
],
content: [
'다람쥐 헌 쳇바퀴에 타고파',
],
},
highlights: undefined,
},
]
## should search block content match japanese kana ngram
> Snapshot 1
[
{
_id: '381498385699454292',
_source: {
doc_id: 'doc-japanese',
workspace_id: 'workspace-test-block-content-japanese',
},
fields: {
block_id: [
'block-japanese',
],
content: [
'いろはにほへと ちりぬるを',
],
},
highlights: undefined,
},
]
## should write document work
> Snapshot 1
@@ -958,7 +889,7 @@ Generated by [AVA](https://avajs.dev).
> Snapshot 1
{
equals: {
term: {
workspace_id: 'workspaceId1',
},
}
@@ -966,7 +897,7 @@ Generated by [AVA](https://avajs.dev).
> Snapshot 2
{
equals: {
term: {
workspace_id: 'workspaceId1',
},
}

View File

@@ -33,8 +33,8 @@ const user = await module.create(Mockers.User);
const workspace = await module.create(Mockers.Workspace);
test.before(async () => {
await searchProvider.recreateTable(SearchTable.block, blockSQL);
await searchProvider.recreateTable(SearchTable.doc, docSQL);
await searchProvider.createTable(SearchTable.block, blockSQL);
await searchProvider.createTable(SearchTable.doc, docSQL);
await searchProvider.write(
SearchTable.block,
@@ -163,135 +163,6 @@ test('should provider is manticoresearch', t => {
t.is(searchProvider.type, SearchProviderType.Manticoresearch);
});
test('should search doc title match chinese word segmentation', async t => {
const workspaceId = 'workspace-test-doc-title-chinese';
const docId = 'doc-chinese';
const title = 'AFFiNE 是一个基于云端的笔记应用';
await searchProvider.write(
SearchTable.doc,
[
{
workspace_id: workspaceId,
doc_id: docId,
title,
},
],
{
refresh: true,
}
);
const result = await searchProvider.search(SearchTable.doc, {
_source: ['workspace_id', 'doc_id'],
query: {
bool: {
must: [
{ term: { workspace_id: { value: workspaceId } } },
{ match: { title: '笔记' } },
],
},
},
fields: ['doc_id', 'title'],
sort: ['_score'],
});
t.true(result.total >= 1);
t.snapshot(
result.nodes
.filter(node => node._source.doc_id === docId)
.map(node => omit(node, ['_score']))
);
});
test('should search block content match korean ngram', async t => {
const workspaceId = 'workspace-test-block-content-korean';
const docId = 'doc-korean';
const blockId = 'block-korean';
const content = '다람쥐 헌 쳇바퀴에 타고파';
await searchProvider.write(
SearchTable.block,
[
{
workspace_id: workspaceId,
doc_id: docId,
block_id: blockId,
content,
flavour: 'affine:paragraph',
},
],
{
refresh: true,
}
);
const result = await searchProvider.search(SearchTable.block, {
_source: ['workspace_id', 'doc_id'],
query: {
bool: {
must: [
{ term: { workspace_id: { value: workspaceId } } },
{ match: { content: '쥐' } },
],
},
},
fields: ['block_id', 'content'],
sort: ['_score'],
});
t.true(result.total >= 1);
t.snapshot(
result.nodes
.filter(node => node.fields.block_id?.[0] === blockId)
.map(node => omit(node, ['_score']))
);
});
test('should search block content match japanese kana ngram', async t => {
const workspaceId = 'workspace-test-block-content-japanese';
const docId = 'doc-japanese';
const blockId = 'block-japanese';
const content = 'いろはにほへと ちりぬるを';
await searchProvider.write(
SearchTable.block,
[
{
workspace_id: workspaceId,
doc_id: docId,
block_id: blockId,
content,
flavour: 'affine:paragraph',
},
],
{
refresh: true,
}
);
const result = await searchProvider.search(SearchTable.block, {
_source: ['workspace_id', 'doc_id'],
query: {
bool: {
must: [
{ term: { workspace_id: { value: workspaceId } } },
{ match: { content: 'へ' } },
],
},
},
fields: ['block_id', 'content'],
sort: ['_score'],
});
t.true(result.total >= 1);
t.snapshot(
result.nodes
.filter(node => node.fields.block_id?.[0] === blockId)
.map(node => omit(node, ['_score']))
);
});
// #region write
test('should write document work', async t => {
@@ -318,7 +189,7 @@ test('should write document work', async t => {
let result = await searchProvider.search(SearchTable.block, {
_source: ['workspace_id', 'doc_id'],
query: { term: { doc_id: { value: docId } } },
query: { match: { doc_id: docId } },
fields: [
'flavour',
'flavour_indexed',
@@ -361,7 +232,7 @@ test('should write document work', async t => {
result = await searchProvider.search(SearchTable.block, {
_source: ['workspace_id', 'doc_id'],
query: { term: { doc_id: { value: docId } } },
query: { match: { doc_id: docId } },
fields: ['flavour', 'block_id', 'content', 'ref_doc_id'],
sort: ['_score'],
});
@@ -392,7 +263,7 @@ test('should write document work', async t => {
result = await searchProvider.search(SearchTable.block, {
_source: ['workspace_id', 'doc_id'],
query: { term: { doc_id: { value: docId } } },
query: { match: { doc_id: docId } },
fields: ['flavour', 'block_id', 'content', 'ref_doc_id'],
sort: ['_score'],
});
@@ -448,8 +319,8 @@ test('should handle ref_doc_id as string[]', async t => {
query: {
bool: {
must: [
{ term: { workspace_id: { value: workspaceId } } },
{ term: { doc_id: { value: docId } } },
{ match: { workspace_id: workspaceId } },
{ match: { doc_id: docId } },
],
},
},
@@ -500,8 +371,8 @@ test('should handle ref_doc_id as string[]', async t => {
query: {
bool: {
must: [
{ term: { workspace_id: { value: workspaceId } } },
{ term: { doc_id: { value: docId } } },
{ match: { workspace_id: workspaceId } },
{ match: { doc_id: docId } },
],
},
},
@@ -545,8 +416,8 @@ test('should handle content as string[]', async t => {
query: {
bool: {
must: [
{ term: { workspace_id: { value: workspaceId } } },
{ term: { doc_id: { value: docId } } },
{ match: { workspace_id: workspaceId } },
{ match: { doc_id: docId } },
],
},
},
@@ -584,8 +455,8 @@ test('should handle content as string[]', async t => {
query: {
bool: {
must: [
{ term: { workspace_id: { value: workspaceId } } },
{ term: { doc_id: { value: docId } } },
{ match: { workspace_id: workspaceId } },
{ match: { doc_id: docId } },
],
},
},
@@ -626,8 +497,8 @@ test('should handle blob as string[]', async t => {
query: {
bool: {
must: [
{ term: { workspace_id: { value: workspaceId } } },
{ term: { doc_id: { value: docId } } },
{ match: { workspace_id: workspaceId } },
{ match: { doc_id: docId } },
],
},
},
@@ -663,8 +534,8 @@ test('should handle blob as string[]', async t => {
query: {
bool: {
must: [
{ term: { workspace_id: { value: workspaceId } } },
{ term: { doc_id: { value: docId } } },
{ match: { workspace_id: workspaceId } },
{ match: { doc_id: docId } },
],
},
},
@@ -700,8 +571,8 @@ test('should handle blob as string[]', async t => {
query: {
bool: {
must: [
{ term: { workspace_id: { value: workspaceId } } },
{ term: { doc_id: { value: docId } } },
{ match: { workspace_id: workspaceId } },
{ match: { doc_id: docId } },
],
},
},
@@ -811,10 +682,8 @@ test('should search query all and get next cursor work', async t => {
'id',
],
query: {
term: {
workspace_id: {
value: workspaceId,
},
match: {
workspace_id: workspaceId,
},
},
fields: ['flavour', 'workspace_id', 'doc_id', 'block_id'],
@@ -839,10 +708,8 @@ test('should search query all and get next cursor work', async t => {
'id',
],
query: {
term: {
workspace_id: {
value: workspaceId,
},
match: {
workspace_id: workspaceId,
},
},
fields: ['flavour', 'workspace_id', 'doc_id', 'block_id'],
@@ -867,10 +734,8 @@ test('should search query all and get next cursor work', async t => {
'id',
],
query: {
term: {
workspace_id: {
value: workspaceId,
},
match: {
workspace_id: workspaceId,
},
},
fields: ['flavour', 'workspace_id', 'doc_id', 'block_id'],
@@ -915,20 +780,16 @@ test('should filter by workspace_id work', async t => {
bool: {
must: [
{
term: {
workspace_id: {
value: workspaceId,
},
match: {
workspace_id: workspaceId,
},
},
{
bool: {
must: [
{
term: {
doc_id: {
value: docId,
},
match: {
doc_id: docId,
},
},
],

View File

@@ -8,12 +8,11 @@ import { createModule } from '../../../__tests__/create-module';
import { Mockers } from '../../../__tests__/mocks';
import { ConfigModule } from '../../../base/config';
import { ServerConfigModule } from '../../../core/config';
import { Models } from '../../../models';
import { SearchProviderFactory } from '../factory';
import { IndexerModule, IndexerService } from '../index';
import { ManticoresearchProvider } from '../providers';
import { UpsertDoc } from '../service';
import { blockSQL, docSQL, SearchTable } from '../tables';
import { SearchTable } from '../tables';
import {
AggregateInput,
SearchInput,
@@ -36,7 +35,6 @@ const module = await createModule({
const indexerService = module.get(IndexerService);
const searchProviderFactory = module.get(SearchProviderFactory);
const manticoresearch = module.get(ManticoresearchProvider);
const models = module.get(Models);
const user = await module.create(Mockers.User);
const workspace = await module.create(Mockers.Workspace, {
snapshot: true,
@@ -52,8 +50,7 @@ test.after.always(async () => {
});
test.before(async () => {
await manticoresearch.recreateTable(SearchTable.block, blockSQL);
await manticoresearch.recreateTable(SearchTable.doc, docSQL);
await indexerService.createTables();
});
test.afterEach.always(async () => {
@@ -2314,29 +2311,3 @@ test('should search docs by keyword work', async t => {
});
// #endregion
test('should rebuild manticore indexes and requeue workspaces', async t => {
const workspace1 = await module.create(Mockers.Workspace, {
indexed: true,
});
const workspace2 = await module.create(Mockers.Workspace, {
indexed: true,
});
const queueCount = module.queue.count('indexer.indexWorkspace');
await indexerService.rebuildManticoreIndexes();
const queuedWorkspaceIds = new Set(
module.queue.add
.getCalls()
.filter(call => call.args[0] === 'indexer.indexWorkspace')
.slice(queueCount)
.map(call => call.args[1].workspaceId)
);
t.true(queuedWorkspaceIds.has(workspace1.id));
t.true(queuedWorkspaceIds.has(workspace2.id));
t.is((await models.workspace.get(workspace1.id))?.indexed, false);
t.is((await models.workspace.get(workspace2.id))?.indexed, false);
});

View File

@@ -38,17 +38,6 @@ const SupportIndexedAttributes = [
'parent_block_id',
];
const SupportExactTermFields = new Set([
'workspace_id',
'doc_id',
'block_id',
'flavour',
'parent_flavour',
'parent_block_id',
'created_by_user_id',
'updated_by_user_id',
]);
const ConvertEmptyStringToNullValueFields = new Set([
'ref_doc_id',
'ref',
@@ -66,20 +55,23 @@ export class ManticoresearchProvider extends ElasticsearchProvider {
table: SearchTable,
mapping: string
): Promise<void> {
const text = await this.#executeSQL(mapping);
const url = `${this.config.provider.endpoint}/cli`;
const response = await fetch(url, {
method: 'POST',
body: mapping,
headers: {
'Content-Type': 'text/plain',
},
});
// manticoresearch cli response is not json, so we need to handle it manually
const text = (await response.text()).trim();
if (!response.ok) {
this.logger.error(`failed to create table ${table}, response: ${text}`);
throw new InternalServerError();
}
this.logger.log(`created table ${table}, response: ${text}`);
}
async dropTable(table: SearchTable): Promise<void> {
const text = await this.#executeSQL(`DROP TABLE IF EXISTS ${table}`);
this.logger.log(`dropped table ${table}, response: ${text}`);
}
async recreateTable(table: SearchTable, mapping: string): Promise<void> {
await this.dropTable(table);
await this.createTable(table, mapping);
}
override async write(
table: SearchTable,
documents: Record<string, unknown>[],
@@ -260,12 +252,6 @@ export class ManticoresearchProvider extends ElasticsearchProvider {
// 1750389254 => new Date(1750389254 * 1000)
return new Date(value * 1000);
}
if (value && typeof value === 'string') {
const timestamp = Date.parse(value);
if (!Number.isNaN(timestamp)) {
return new Date(timestamp);
}
}
return value;
}
@@ -316,10 +302,8 @@ export class ManticoresearchProvider extends ElasticsearchProvider {
// workspace_id: 'workspaceId1'
// }
// }
let termField = options?.termMappingField ?? 'term';
let field = Object.keys(query.term)[0];
let termField =
options?.termMappingField ??
(SupportExactTermFields.has(field) ? 'equals' : 'term');
let value = query.term[field];
if (typeof value === 'object' && 'value' in value) {
if ('boost' in value) {
@@ -448,28 +432,4 @@ export class ManticoresearchProvider extends ElasticsearchProvider {
}
return value;
}
async #executeSQL(sql: string) {
const url = `${this.config.provider.endpoint}/cli`;
const headers: Record<string, string> = {
'Content-Type': 'text/plain',
};
if (this.config.provider.apiKey) {
headers.Authorization = `ApiKey ${this.config.provider.apiKey}`;
} else if (this.config.provider.password) {
headers.Authorization = `Basic ${Buffer.from(`${this.config.provider.username}:${this.config.provider.password}`).toString('base64')}`;
}
const response = await fetch(url, {
method: 'POST',
body: sql,
headers,
});
const text = (await response.text()).trim();
if (!response.ok) {
this.logger.error(`failed to execute SQL "${sql}", response: ${text}`);
throw new InternalServerError();
}
return text;
}
}

View File

@@ -14,7 +14,6 @@ import {
AggregateQueryDSL,
BaseQueryDSL,
HighlightDSL,
ManticoresearchProvider,
OperationOptions,
SearchNode,
SearchProvider,
@@ -131,63 +130,6 @@ export class IndexerService {
}
}
async rebuildManticoreIndexes() {
let searchProvider: SearchProvider | undefined;
try {
searchProvider = this.factory.get();
} catch (err) {
if (err instanceof SearchProviderNotFound) {
this.logger.debug('No search provider found, skip rebuilding tables');
return;
}
throw err;
}
if (!(searchProvider instanceof ManticoresearchProvider)) {
this.logger.debug(
`Search provider ${searchProvider.type} does not need manticore rebuild`
);
return;
}
const mappings = SearchTableMappingStrings[searchProvider.type];
for (const table of Object.keys(mappings) as SearchTable[]) {
await searchProvider.recreateTable(table, mappings[table]);
}
let lastWorkspaceSid = 0;
while (true) {
const workspaces = await this.models.workspace.list(
{ sid: { gt: lastWorkspaceSid } },
{ id: true, sid: true },
100
);
if (!workspaces.length) {
break;
}
for (const workspace of workspaces) {
await this.models.workspace.update(
workspace.id,
{ indexed: false },
false
);
await this.queue.add(
'indexer.indexWorkspace',
{
workspaceId: workspace.id,
},
{
jobId: `indexWorkspace/${workspace.id}`,
priority: 100,
}
);
}
lastWorkspaceSid = workspaces[workspaces.length - 1].sid;
}
}
async write<T extends SearchTable>(
table: T,
documents: UpsertTypeByTable<T>[],

View File

@@ -150,8 +150,6 @@ CREATE TABLE IF NOT EXISTS block (
updated_at timestamp
)
morphology = 'jieba_chinese, lemmatize_en_all, lemmatize_de_all, lemmatize_ru_all, libstemmer_ar, libstemmer_ca, stem_cz, libstemmer_da, libstemmer_nl, libstemmer_fi, libstemmer_fr, libstemmer_el, libstemmer_hi, libstemmer_hu, libstemmer_id, libstemmer_ga, libstemmer_it, libstemmer_lt, libstemmer_ne, libstemmer_no, libstemmer_pt, libstemmer_ro, libstemmer_es, libstemmer_sv, libstemmer_ta, libstemmer_tr'
charset_table = 'non_cjk, chinese'
ngram_len = '1'
ngram_chars = 'U+1100..U+11FF, U+3130..U+318F, U+A960..U+A97F, U+AC00..U+D7AF, U+D7B0..U+D7FF, U+3040..U+30FF, U+0E00..U+0E7F'
charset_table = 'non_cjk, cjk'
index_field_lengths = '1'
`;

View File

@@ -109,8 +109,6 @@ CREATE TABLE IF NOT EXISTS doc (
updated_at timestamp
)
morphology = 'jieba_chinese, lemmatize_en_all, lemmatize_de_all, lemmatize_ru_all, libstemmer_ar, libstemmer_ca, stem_cz, libstemmer_da, libstemmer_nl, libstemmer_fi, libstemmer_fr, libstemmer_el, libstemmer_hi, libstemmer_hu, libstemmer_id, libstemmer_ga, libstemmer_it, libstemmer_lt, libstemmer_ne, libstemmer_no, libstemmer_pt, libstemmer_ro, libstemmer_es, libstemmer_sv, libstemmer_ta, libstemmer_tr'
charset_table = 'non_cjk, chinese'
ngram_len = '1'
ngram_chars = 'U+1100..U+11FF, U+3130..U+318F, U+A960..U+A97F, U+AC00..U+D7AF, U+D7B0..U+D7FF, U+3040..U+30FF, U+0E00..U+0E7F'
charset_table = 'non_cjk, cjk'
index_field_lengths = '1'
`;

View File

@@ -1,10 +1,9 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { createRemoteJWKSet, type JWTPayload, jwtVerify } from 'jose';
import { omit } from 'lodash-es';
import { z } from 'zod';
import {
ExponentialBackoffScheduler,
InvalidAuthState,
InvalidOauthResponse,
URLHelper,
@@ -36,7 +35,7 @@ const OIDCUserInfoSchema = z
.object({
sub: z.string(),
preferred_username: z.string().optional(),
email: z.string().optional(),
email: z.string().email(),
name: z.string().optional(),
email_verified: z
.union([z.boolean(), z.enum(['true', 'false', '1', '0', 'yes', 'no'])])
@@ -45,8 +44,6 @@ const OIDCUserInfoSchema = z
})
.passthrough();
const OIDCEmailSchema = z.string().email();
const OIDCConfigurationSchema = z.object({
authorization_endpoint: z.string().url(),
token_endpoint: z.string().url(),
@@ -57,28 +54,16 @@ const OIDCConfigurationSchema = z.object({
type OIDCConfiguration = z.infer<typeof OIDCConfigurationSchema>;
const OIDC_DISCOVERY_INITIAL_RETRY_DELAY = 1000;
const OIDC_DISCOVERY_MAX_RETRY_DELAY = 60_000;
@Injectable()
export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
export class OIDCProvider extends OAuthProvider {
override provider = OAuthProviderName.OIDC;
#endpoints: OIDCConfiguration | null = null;
#jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
readonly #retryScheduler = new ExponentialBackoffScheduler({
baseDelayMs: OIDC_DISCOVERY_INITIAL_RETRY_DELAY,
maxDelayMs: OIDC_DISCOVERY_MAX_RETRY_DELAY,
});
#validationGeneration = 0;
constructor(private readonly url: URLHelper) {
super();
}
onModuleDestroy() {
this.#retryScheduler.clear();
}
override get requiresPkce() {
return true;
}
@@ -102,109 +87,58 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
}
protected override setup() {
const generation = ++this.#validationGeneration;
this.#retryScheduler.clear();
const validate = async () => {
this.#endpoints = null;
this.#jwks = null;
this.validateAndSync(generation).catch(() => {
if (super.configured) {
const config = this.config as OAuthOIDCProviderConfig;
if (!config.issuer) {
this.logger.error('Missing OIDC issuer configuration');
super.setup();
return;
}
try {
const res = await fetch(
`${config.issuer}/.well-known/openid-configuration`,
{
method: 'GET',
headers: { Accept: 'application/json' },
}
);
if (res.ok) {
const configuration = OIDCConfigurationSchema.parse(
await res.json()
);
if (
this.normalizeIssuer(config.issuer) !==
this.normalizeIssuer(configuration.issuer)
) {
this.logger.error(
`OIDC issuer mismatch, expected ${config.issuer}, got ${configuration.issuer}`
);
} else {
this.#endpoints = configuration;
this.#jwks = createRemoteJWKSet(new URL(configuration.jwks_uri));
}
} else {
this.logger.error(`Invalid OIDC issuer ${config.issuer}`);
}
} catch (e) {
this.logger.error('Failed to validate OIDC configuration', e);
}
}
super.setup();
};
validate().catch(() => {
/* noop */
});
}
private async validateAndSync(generation: number) {
if (generation !== this.#validationGeneration) {
return;
}
if (!super.configured) {
this.resetState();
this.#retryScheduler.reset();
super.setup();
return;
}
const config = this.config as OAuthOIDCProviderConfig;
if (!config.issuer) {
this.logger.error('Missing OIDC issuer configuration');
this.resetState();
this.#retryScheduler.reset();
super.setup();
return;
}
try {
const res = await fetch(
`${config.issuer}/.well-known/openid-configuration`,
{
method: 'GET',
headers: { Accept: 'application/json' },
}
);
if (generation !== this.#validationGeneration) {
return;
}
if (!res.ok) {
this.logger.error(`Invalid OIDC issuer ${config.issuer}`);
this.onValidationFailure(generation);
return;
}
const configuration = OIDCConfigurationSchema.parse(await res.json());
if (
this.normalizeIssuer(config.issuer) !==
this.normalizeIssuer(configuration.issuer)
) {
this.logger.error(
`OIDC issuer mismatch, expected ${config.issuer}, got ${configuration.issuer}`
);
this.onValidationFailure(generation);
return;
}
this.#endpoints = configuration;
this.#jwks = createRemoteJWKSet(new URL(configuration.jwks_uri));
this.#retryScheduler.reset();
super.setup();
} catch (e) {
if (generation !== this.#validationGeneration) {
return;
}
this.logger.error('Failed to validate OIDC configuration', e);
this.onValidationFailure(generation);
}
}
private onValidationFailure(generation: number) {
this.resetState();
super.setup();
this.scheduleRetry(generation);
}
private scheduleRetry(generation: number) {
if (generation !== this.#validationGeneration) {
return;
}
const delay = this.#retryScheduler.schedule(() => {
this.validateAndSync(generation).catch(() => {
/* noop */
});
});
if (delay === null) {
return;
}
this.logger.warn(
`OIDC discovery validation failed, retrying in ${delay}ms`
);
}
private resetState() {
this.#endpoints = null;
this.#jwks = null;
}
getAuthUrl(state: string): string {
const parsedState = this.parseStatePayload(state);
const nonce = parsedState?.state ?? state;
@@ -357,68 +291,6 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
return undefined;
}
private claimCandidates(
configuredClaim: string | undefined,
defaultClaim: string
) {
if (typeof configuredClaim === 'string' && configuredClaim.length > 0) {
return [configuredClaim];
}
return [defaultClaim];
}
private formatClaimCandidates(claims: string[]) {
return claims.map(claim => `"${claim}"`).join(', ');
}
private resolveStringClaim(
claims: string[],
...sources: Array<Record<string, unknown>>
) {
for (const claim of claims) {
for (const source of sources) {
const value = this.extractString(source[claim]);
if (value) {
return value;
}
}
}
return undefined;
}
private resolveBooleanClaim(
claims: string[],
...sources: Array<Record<string, unknown>>
) {
for (const claim of claims) {
for (const source of sources) {
const value = this.extractBoolean(source[claim]);
if (value !== undefined) {
return value;
}
}
}
return undefined;
}
private resolveEmailClaim(
claims: string[],
...sources: Array<Record<string, unknown>>
) {
for (const claim of claims) {
for (const source of sources) {
const value = this.extractString(source[claim]);
if (value && OIDCEmailSchema.safeParse(value).success) {
return value;
}
}
}
return undefined;
}
async getUser(tokens: Tokens, state: OAuthState): Promise<OAuthAccount> {
if (!tokens.idToken) {
throw new InvalidOauthResponse({
@@ -443,8 +315,6 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
{ treatServerErrorAsInvalid: true }
);
const user = OIDCUserInfoSchema.parse(rawUser);
const userClaims = user as Record<string, unknown>;
const idTokenClaimsRecord = idTokenClaims as Record<string, unknown>;
if (!user.sub || !idTokenClaims.sub) {
throw new InvalidOauthResponse({
@@ -457,29 +327,22 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
}
const args = this.config.args ?? {};
const idClaims = this.claimCandidates(args.claim_id, 'sub');
const emailClaims = this.claimCandidates(args.claim_email, 'email');
const nameClaims = this.claimCandidates(args.claim_name, 'name');
const emailVerifiedClaims = this.claimCandidates(
args.claim_email_verified,
'email_verified'
);
const accountId = this.resolveStringClaim(
idClaims,
userClaims,
idTokenClaimsRecord
);
const email = this.resolveEmailClaim(
emailClaims,
userClaims,
idTokenClaimsRecord
);
const emailVerified = this.resolveBooleanClaim(
emailVerifiedClaims,
userClaims,
idTokenClaimsRecord
);
const claimsMap = {
id: args.claim_id || 'sub',
email: args.claim_email || 'email',
name: args.claim_name || 'name',
emailVerified: args.claim_email_verified || 'email_verified',
};
const accountId =
this.extractString(user[claimsMap.id]) ?? idTokenClaims.sub;
const email =
this.extractString(user[claimsMap.email]) ||
this.extractString(idTokenClaims.email);
const emailVerified =
this.extractBoolean(user[claimsMap.emailVerified]) ??
this.extractBoolean(idTokenClaims.email_verified);
if (!accountId) {
throw new InvalidOauthResponse({
@@ -489,7 +352,7 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
if (!email) {
throw new InvalidOauthResponse({
reason: `Missing valid email claim in OIDC response. Tried userinfo and ID token claims: ${this.formatClaimCandidates(emailClaims)}`,
reason: 'Missing required claim for email',
});
}
@@ -504,11 +367,9 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
email,
};
const name = this.resolveStringClaim(
nameClaims,
userClaims,
idTokenClaimsRecord
);
const name =
this.extractString(user[claimsMap.name]) ||
this.extractString(idTokenClaims.name);
if (name) {
account.name = name;
}

View File

@@ -18,7 +18,7 @@
"@affine/templates": "workspace:*",
"@preact/signals-core": "^1.8.0",
"eventemitter2": "^6.4.9",
"foxact": "^0.3.0",
"foxact": "^0.2.49",
"fractional-indexing": "^3.2.0",
"fuse.js": "^7.0.0",
"graphemer": "^1.4.0",

View File

@@ -10,7 +10,6 @@ interface TestOps extends OpSchema {
add: [{ a: number; b: number }, number];
bin: [Uint8Array, Uint8Array];
sub: [Uint8Array, number];
init: [{ fastText?: boolean } | undefined, { ok: true }];
}
declare module 'vitest' {
@@ -85,55 +84,6 @@ describe('op client', () => {
expect(data.byteLength).toBe(0);
});
it('should send optional payload call with abort signal', async ctx => {
const abortController = new AbortController();
const result = ctx.producer.call(
'init',
{ fastText: true },
abortController.signal
);
expect(ctx.postMessage.mock.calls[0][0]).toMatchInlineSnapshot(`
{
"id": "init:1",
"name": "init",
"payload": {
"fastText": true,
},
"type": "call",
}
`);
ctx.handlers.return({
type: 'return',
id: 'init:1',
data: { ok: true },
});
await expect(result).resolves.toEqual({ ok: true });
});
it('should send undefined payload for optional input call', async ctx => {
const result = ctx.producer.call('init', undefined);
expect(ctx.postMessage.mock.calls[0][0]).toMatchInlineSnapshot(`
{
"id": "init:1",
"name": "init",
"payload": undefined,
"type": "call",
}
`);
ctx.handlers.return({
type: 'return',
id: 'init:1',
data: { ok: true },
});
await expect(result).resolves.toEqual({ ok: true });
});
it('should cancel call', async ctx => {
const promise = ctx.producer.call('add', { a: 1, b: 2 });

View File

@@ -40,14 +40,18 @@ describe('op consumer', () => {
it('should throw if no handler registered', async ctx => {
ctx.handlers.call({ type: 'call', id: 'add:1', name: 'add', payload: {} });
await vi.advanceTimersToNextTimerAsync();
expect(ctx.postMessage.mock.lastCall?.[0]).toMatchObject({
type: 'return',
id: 'add:1',
error: {
message: 'Handler for operation [add] is not registered.',
name: 'Error',
},
});
expect(ctx.postMessage.mock.lastCall).toMatchInlineSnapshot(`
[
{
"error": {
"message": "Handler for operation [add] is not registered.",
"name": "Error",
},
"id": "add:1",
"type": "return",
},
]
`);
});
it('should handle call message', async ctx => {
@@ -69,38 +73,6 @@ describe('op consumer', () => {
`);
});
it('should serialize string errors with message', async ctx => {
ctx.consumer.register('any', () => {
throw 'worker panic';
});
ctx.handlers.call({ type: 'call', id: 'any:1', name: 'any', payload: {} });
await vi.advanceTimersToNextTimerAsync();
expect(ctx.postMessage.mock.calls[0][0]).toMatchObject({
type: 'return',
id: 'any:1',
error: {
name: 'Error',
message: 'worker panic',
},
});
});
it('should serialize plain object errors with fallback message', async ctx => {
ctx.consumer.register('any', () => {
throw { reason: 'panic', code: 'E_PANIC' };
});
ctx.handlers.call({ type: 'call', id: 'any:1', name: 'any', payload: {} });
await vi.advanceTimersToNextTimerAsync();
const message = ctx.postMessage.mock.calls[0][0]?.error?.message;
expect(typeof message).toBe('string');
expect(message).toContain('"reason":"panic"');
expect(message).toContain('"code":"E_PANIC"');
});
it('should handle cancel message', async ctx => {
ctx.consumer.register('add', ({ a, b }, { signal }) => {
const { reject, resolve, promise } = Promise.withResolvers<number>();

View File

@@ -16,96 +16,6 @@ import {
} from './message';
import type { OpInput, OpNames, OpOutput, OpSchema } from './types';
const SERIALIZABLE_ERROR_FIELDS = [
'name',
'message',
'code',
'type',
'status',
'data',
'stacktrace',
] as const;
type SerializableErrorShape = Partial<
Record<(typeof SERIALIZABLE_ERROR_FIELDS)[number], unknown>
> & {
name?: string;
message?: string;
};
function getFallbackErrorMessage(error: unknown): string {
if (typeof error === 'string') {
return error;
}
if (error instanceof Error && error.message) {
return error.message;
}
if (
typeof error === 'number' ||
typeof error === 'boolean' ||
typeof error === 'bigint' ||
typeof error === 'symbol'
) {
return String(error);
}
if (error === null || error === undefined) {
return 'Unknown error';
}
try {
const jsonMessage = JSON.stringify(error);
if (jsonMessage && jsonMessage !== '{}') {
return jsonMessage;
}
} catch {
return 'Unknown error';
}
return 'Unknown error';
}
function serializeError(error: unknown): Error {
const valueToPick =
error && typeof error === 'object'
? error
: ({} as Record<string, unknown>);
const serialized = pick(
valueToPick,
SERIALIZABLE_ERROR_FIELDS
) as SerializableErrorShape;
if (!serialized.message || typeof serialized.message !== 'string') {
serialized.message = getFallbackErrorMessage(error);
}
if (!serialized.name || typeof serialized.name !== 'string') {
if (error instanceof Error && error.name) {
serialized.name = error.name;
} else if (error && typeof error === 'object') {
const constructorName = error.constructor?.name;
serialized.name =
typeof constructorName === 'string' && constructorName.length > 0
? constructorName
: 'Error';
} else {
serialized.name = 'Error';
}
}
if (
!serialized.stacktrace &&
error instanceof Error &&
typeof error.stack === 'string'
) {
serialized.stacktrace = error.stack;
}
return serialized as Error;
}
interface OpCallContext {
signal: AbortSignal;
}
@@ -161,7 +71,15 @@ export class OpConsumer<Ops extends OpSchema> extends AutoMessageHandler {
this.port.postMessage({
type: 'return',
id: msg.id,
error: serializeError(error),
error: pick(error, [
'name',
'message',
'code',
'type',
'status',
'data',
'stacktrace',
]),
} satisfies ReturnMessage);
},
complete: () => {
@@ -191,7 +109,15 @@ export class OpConsumer<Ops extends OpSchema> extends AutoMessageHandler {
this.port.postMessage({
type: 'error',
id: msg.id,
error: serializeError(error),
error: pick(error, [
'name',
'message',
'code',
'type',
'status',
'data',
'stacktrace',
]),
} satisfies SubscriptionErrorMessage);
},
complete: () => {

View File

@@ -12,16 +12,7 @@ export interface OpSchema {
[key: string]: [any, any?];
}
type IsAny<T> = 0 extends 1 & T ? true : false;
type RequiredInput<In> =
IsAny<In> extends true
? [In]
: [In] extends [never]
? []
: [In] extends [void]
? []
: [In];
type RequiredInput<In> = In extends void ? [] : In extends never ? [] : [In];
export type OpNames<T extends OpSchema> = ValuesOf<KeyToKey<T>>;
export type OpInput<

View File

@@ -2,7 +2,6 @@
edition = "2024"
license-file = "LICENSE"
name = "affine_common"
publish = false
version = "0.1.0"
[features]

View File

@@ -1,235 +1,18 @@
import 'fake-indexeddb/auto';
import * as reader from '@affine/reader';
import { NEVER } from 'rxjs';
import { afterEach, expect, test, vi } from 'vitest';
import { expect, test } from 'vitest';
import { Doc as YDoc, encodeStateAsUpdate } from 'yjs';
import { DummyConnection } from '../connection';
import {
IndexedDBBlobStorage,
IndexedDBBlobSyncStorage,
IndexedDBDocStorage,
IndexedDBDocSyncStorage,
} from '../impls/idb';
import {
type AggregateOptions,
type AggregateResult,
type CrawlResult,
type DocClock,
type DocClocks,
type DocDiff,
type DocIndexedClock,
type DocRecord,
type DocStorage,
type DocUpdate,
type IndexerDocument,
type IndexerSchema,
IndexerStorageBase,
IndexerSyncStorageBase,
type Query,
type SearchOptions,
type SearchResult,
SpaceStorage,
} from '../storage';
import { SpaceStorage } from '../storage';
import { Sync } from '../sync';
import { IndexerSyncImpl } from '../sync/indexer';
import { expectYjsEqual } from './utils';
afterEach(() => {
vi.restoreAllMocks();
});
function deferred<T = void>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
class TestDocStorage implements DocStorage {
readonly storageType = 'doc' as const;
readonly connection = new DummyConnection();
readonly isReadonly = false;
private readonly subscribers = new Set<
(update: DocRecord, origin?: string) => void
>();
constructor(
readonly spaceId: string,
private readonly timestamps: Map<string, Date>,
private readonly crawlDocDataImpl: (
docId: string
) => Promise<CrawlResult | null>
) {}
async getDoc(_docId: string): Promise<DocRecord | null> {
return null;
}
async getDocDiff(
_docId: string,
_state?: Uint8Array
): Promise<DocDiff | null> {
return null;
}
async pushDocUpdate(update: DocUpdate, origin?: string): Promise<DocClock> {
const timestamp = this.timestamps.get(update.docId) ?? new Date();
const record = { ...update, timestamp };
this.timestamps.set(update.docId, timestamp);
for (const subscriber of this.subscribers) {
subscriber(record, origin);
}
return { docId: update.docId, timestamp };
}
async getDocTimestamp(docId: string): Promise<DocClock | null> {
const timestamp = this.timestamps.get(docId);
return timestamp ? { docId, timestamp } : null;
}
async getDocTimestamps(): Promise<DocClocks> {
return Object.fromEntries(this.timestamps);
}
async deleteDoc(docId: string): Promise<void> {
this.timestamps.delete(docId);
}
subscribeDocUpdate(callback: (update: DocRecord, origin?: string) => void) {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
async crawlDocData(docId: string): Promise<CrawlResult | null> {
return this.crawlDocDataImpl(docId);
}
}
class TrackingIndexerStorage extends IndexerStorageBase {
override readonly connection = new DummyConnection();
override readonly isReadonly = false;
constructor(
private readonly calls: string[],
override readonly recommendRefreshInterval: number
) {
super();
}
override async search<
T extends keyof IndexerSchema,
const O extends SearchOptions<T>,
>(_table: T, _query: Query<T>, _options?: O): Promise<SearchResult<T, O>> {
return {
pagination: { count: 0, limit: 0, skip: 0, hasMore: false },
nodes: [],
} as SearchResult<T, O>;
}
override async aggregate<
T extends keyof IndexerSchema,
const O extends AggregateOptions<T>,
>(
_table: T,
_query: Query<T>,
_field: keyof IndexerSchema[T],
_options?: O
): Promise<AggregateResult<T, O>> {
return {
pagination: { count: 0, limit: 0, skip: 0, hasMore: false },
buckets: [],
} as AggregateResult<T, O>;
}
override search$<
T extends keyof IndexerSchema,
const O extends SearchOptions<T>,
>(_table: T, _query: Query<T>, _options?: O) {
return NEVER;
}
override aggregate$<
T extends keyof IndexerSchema,
const O extends AggregateOptions<T>,
>(_table: T, _query: Query<T>, _field: keyof IndexerSchema[T], _options?: O) {
return NEVER;
}
override async deleteByQuery<T extends keyof IndexerSchema>(
table: T,
_query: Query<T>
): Promise<void> {
this.calls.push(`deleteByQuery:${String(table)}`);
}
override async insert<T extends keyof IndexerSchema>(
table: T,
document: IndexerDocument<T>
): Promise<void> {
this.calls.push(`insert:${String(table)}:${document.id}`);
}
override async delete<T extends keyof IndexerSchema>(
table: T,
id: string
): Promise<void> {
this.calls.push(`delete:${String(table)}:${id}`);
}
override async update<T extends keyof IndexerSchema>(
table: T,
document: IndexerDocument<T>
): Promise<void> {
this.calls.push(`update:${String(table)}:${document.id}`);
}
override async refresh<T extends keyof IndexerSchema>(
_table: T
): Promise<void> {
return;
}
override async refreshIfNeed(): Promise<void> {
this.calls.push('refresh');
}
override async indexVersion(): Promise<number> {
return 1;
}
}
class TrackingIndexerSyncStorage extends IndexerSyncStorageBase {
override readonly connection = new DummyConnection();
private readonly clocks = new Map<string, DocIndexedClock>();
constructor(private readonly calls: string[]) {
super();
}
override async getDocIndexedClock(
docId: string
): Promise<DocIndexedClock | null> {
return this.clocks.get(docId) ?? null;
}
override async setDocIndexedClock(clock: DocIndexedClock): Promise<void> {
this.calls.push(`setClock:${clock.docId}`);
this.clocks.set(clock.docId, clock);
}
override async clearDocIndexedClock(docId: string): Promise<void> {
this.calls.push(`clearClock:${docId}`);
this.clocks.delete(docId);
}
}
test('doc', async () => {
const doc = new YDoc();
doc.getMap('test').set('hello', 'world');
@@ -424,114 +207,3 @@ test('blob', async () => {
expect(c?.data).toEqual(new Uint8Array([4, 3, 2, 1]));
}
});
test('indexer defers indexed clock persistence until a refresh happens on delayed refresh storages', async () => {
const calls: string[] = [];
const docsInRootDoc = new Map([['doc1', { title: 'Doc 1' }]]);
const docStorage = new TestDocStorage(
'workspace-id',
new Map([['doc1', new Date('2026-01-01T00:00:00.000Z')]]),
async () => ({
title: 'Doc 1',
summary: 'summary',
blocks: [
{ blockId: 'block-1', flavour: 'affine:image', blob: ['blob-1'] },
],
})
);
const indexer = new TrackingIndexerStorage(calls, 30_000);
const indexerSyncStorage = new TrackingIndexerSyncStorage(calls);
const sync = new IndexerSyncImpl(
docStorage,
{
local: indexer,
remotes: {},
},
indexerSyncStorage
);
vi.spyOn(reader, 'readAllDocsFromRootDoc').mockImplementation(
() => new Map(docsInRootDoc)
);
try {
sync.start();
await sync.waitForCompleted();
expect(calls).not.toContain('setClock:doc1');
sync.stop();
await vi.waitFor(() => {
expect(calls).toContain('setClock:doc1');
});
const lastRefreshIndex = calls.lastIndexOf('refresh');
const setClockIndex = calls.indexOf('setClock:doc1');
expect(lastRefreshIndex).toBeGreaterThanOrEqual(0);
expect(setClockIndex).toBeGreaterThan(lastRefreshIndex);
} finally {
sync.stop();
}
});
test('indexer completion waits for the current job to finish', async () => {
const docsInRootDoc = new Map([['doc1', { title: 'Doc 1' }]]);
const crawlStarted = deferred<void>();
const releaseCrawl = deferred<void>();
const docStorage = new TestDocStorage(
'workspace-id',
new Map([['doc1', new Date('2026-01-01T00:00:00.000Z')]]),
async () => {
crawlStarted.resolve();
await releaseCrawl.promise;
return {
title: 'Doc 1',
summary: 'summary',
blocks: [
{ blockId: 'block-1', flavour: 'affine:image', blob: ['blob-1'] },
],
};
}
);
const sync = new IndexerSyncImpl(
docStorage,
{
local: new TrackingIndexerStorage([], 30_000),
remotes: {},
},
new TrackingIndexerSyncStorage([])
);
vi.spyOn(reader, 'readAllDocsFromRootDoc').mockImplementation(
() => new Map(docsInRootDoc)
);
try {
sync.start();
await crawlStarted.promise;
let completed = false;
let docCompleted = false;
const waitForCompleted = sync.waitForCompleted().then(() => {
completed = true;
});
const waitForDocCompleted = sync.waitForDocCompleted('doc1').then(() => {
docCompleted = true;
});
await new Promise(resolve => setTimeout(resolve, 20));
expect(completed).toBe(false);
expect(docCompleted).toBe(false);
releaseCrawl.resolve();
await waitForCompleted;
await waitForDocCompleted;
} finally {
sync.stop();
}
});

View File

@@ -112,10 +112,6 @@ export class IndexerSyncImpl implements IndexerSync {
private readonly indexer: IndexerStorage;
private readonly remote?: IndexerStorage;
private readonly pendingIndexedClocks = new Map<
string,
{ docId: string; timestamp: Date; indexerVersion: number }
>();
private lastRefreshed = Date.now();
@@ -376,13 +372,12 @@ export class IndexerSyncImpl implements IndexerSync {
field: 'docId',
match: docId,
});
this.pendingIndexedClocks.delete(docId);
await this.indexerSync.clearDocIndexedClock(docId);
this.status.docsInIndexer.delete(docId);
this.status.statusUpdatedSubject$.next(docId);
}
}
await this.refreshIfNeed(true);
await this.refreshIfNeed();
// #endregion
} else {
// #region crawl doc
@@ -399,8 +394,7 @@ export class IndexerSyncImpl implements IndexerSync {
}
const docIndexedClock =
this.pendingIndexedClocks.get(docId) ??
(await this.indexerSync.getDocIndexedClock(docId));
await this.indexerSync.getDocIndexedClock(docId);
if (
docIndexedClock &&
docIndexedClock.timestamp.getTime() ===
@@ -466,12 +460,13 @@ export class IndexerSyncImpl implements IndexerSync {
);
}
this.pendingIndexedClocks.set(docId, {
await this.refreshIfNeed();
await this.indexerSync.setDocIndexedClock({
docId,
timestamp: docClock.timestamp,
indexerVersion: indexVersion,
});
await this.refreshIfNeed();
// #endregion
}
@@ -481,7 +476,7 @@ export class IndexerSyncImpl implements IndexerSync {
this.status.completeJob();
}
} finally {
await this.refreshIfNeed(true);
await this.refreshIfNeed();
unsubscribe();
}
}
@@ -489,27 +484,18 @@ export class IndexerSyncImpl implements IndexerSync {
// ensure the indexer is refreshed according to recommendRefreshInterval
// recommendRefreshInterval <= 0 means force refresh on each operation
// recommendRefreshInterval > 0 means refresh if the last refresh is older than recommendRefreshInterval
private async refreshIfNeed(force = false): Promise<void> {
private async refreshIfNeed(): Promise<void> {
const recommendRefreshInterval = this.indexer.recommendRefreshInterval ?? 0;
const needRefresh =
recommendRefreshInterval > 0 &&
this.lastRefreshed + recommendRefreshInterval < Date.now();
const forceRefresh = recommendRefreshInterval <= 0;
if (force || needRefresh || forceRefresh) {
if (needRefresh || forceRefresh) {
await this.indexer.refreshIfNeed();
await this.flushPendingIndexedClocks();
this.lastRefreshed = Date.now();
}
}
private async flushPendingIndexedClocks() {
if (this.pendingIndexedClocks.size === 0) return;
for (const [docId, clock] of this.pendingIndexedClocks) {
await this.indexerSync.setDocIndexedClock(clock);
this.pendingIndexedClocks.delete(docId);
}
}
/**
* Get all docs from the root doc, without deleted docs
*/
@@ -720,10 +706,7 @@ class IndexerSyncStatus {
indexing: this.jobs.length() + (this.currentJob ? 1 : 0),
total: this.docsInRootDoc.size + 1,
errorMessage: this.errorMessage,
completed:
this.rootDocReady &&
this.jobs.length() === 0 &&
this.currentJob === null,
completed: this.rootDocReady && this.jobs.length() === 0,
batterySaveMode: this.batterySaveMode,
paused: this.paused !== null,
});
@@ -751,10 +734,9 @@ class IndexerSyncStatus {
completed: true,
});
} else {
const indexing = this.jobs.has(docId) || this.currentJob === docId;
subscribe.next({
indexing,
completed: this.docsInIndexer.has(docId) && !indexing,
indexing: this.jobs.has(docId),
completed: this.docsInIndexer.has(docId) && !this.jobs.has(docId),
});
}
};

View File

@@ -1,5 +1,5 @@
export const encodeLink = (link: string) =>
encodeURI(link)
.replaceAll('(', '%28')
.replaceAll(')', '%29')
.replace(/\(/g, '%28')
.replace(/\)/g, '%29')
.replace(/(\?|&)response-content-disposition=attachment.*$/, '');

View File

@@ -7,7 +7,7 @@
},
"dependencies": {
"aws4": "^1.13.2",
"fast-xml-parser": "^5.5.7",
"fast-xml-parser": "^5.3.4",
"s3mini": "^0.9.1"
},
"devDependencies": {

View File

@@ -74,12 +74,6 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.1"
@@ -174,17 +168,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures",
"rand_core 0.10.0",
]
[[package]]
name = "clap"
version = "4.5.38"
@@ -241,15 +224,6 @@ dependencies = [
"loom",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -264,7 +238,7 @@ checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown 0.14.5",
"hashbrown",
"lock_api",
"once_cell",
"parking_lot_core",
@@ -281,12 +255,6 @@ dependencies = [
"syn",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "event-listener"
version = "5.4.0"
@@ -318,12 +286,6 @@ dependencies = [
"getrandom 0.2.16",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "generator"
version = "0.8.4"
@@ -358,69 +320,22 @@ checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.2.0",
"r-efi",
"wasi 0.14.2+wasi-0.2.4",
]
[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.0",
"wasip2",
"wasip3",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
"serde",
"serde_core",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
@@ -459,12 +374,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lib0"
version = "0.16.10"
@@ -484,9 +393,9 @@ checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
[[package]]
name = "libfuzzer-sys"
version = "0.4.12"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d"
checksum = "cf78f52d400cf2d84a3a973a78a592b4adc535739e0a5597a0da6f0c357adc75"
dependencies = [
"arbitrary",
"cc",
@@ -621,30 +530,29 @@ dependencies = [
[[package]]
name = "phf"
version = "0.13.1"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros",
"phf_shared",
"serde",
]
[[package]]
name = "phf_generator"
version = "0.13.1"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"fastrand",
"phf_shared",
"rand 0.8.5",
]
[[package]]
name = "phf_macros"
version = "0.13.1"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator",
"phf_shared",
@@ -655,9 +563,9 @@ dependencies = [
[[package]]
name = "phf_shared"
version = "0.13.1"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
@@ -677,16 +585,6 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.95"
@@ -711,12 +609,6 @@ version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.5"
@@ -730,13 +622,12 @@ dependencies = [
[[package]]
name = "rand"
version = "0.10.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
dependencies = [
"chacha20",
"getrandom 0.4.2",
"rand_core 0.10.0",
"rand_chacha 0.9.0",
"rand_core 0.9.3",
]
[[package]]
@@ -751,12 +642,12 @@ dependencies = [
[[package]]
name = "rand_chacha"
version = "0.10.0"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.10.0",
"rand_core 0.9.3",
]
[[package]]
@@ -770,18 +661,21 @@ dependencies = [
[[package]]
name = "rand_core"
version = "0.10.0"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
dependencies = [
"getrandom 0.3.3",
]
[[package]]
name = "rand_distr"
version = "0.6.0"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8"
checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463"
dependencies = [
"num-traits",
"rand 0.10.0",
"rand 0.9.1",
]
[[package]]
@@ -834,36 +728,20 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
@@ -936,9 +814,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.117"
version = "2.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf"
dependencies = [
"proc-macro2",
"quote",
@@ -1050,12 +928,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "utf8parse"
version = "0.2.2"
@@ -1089,24 +961,6 @@ dependencies = [
"wit-bindgen-rt",
]
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.100"
@@ -1164,40 +1018,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "windows"
version = "0.58.0"
@@ -1335,26 +1155,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rt"
version = "0.39.0"
@@ -1364,74 +1164,6 @@ dependencies = [
"bitflags",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "y-octo"
version = "0.0.2"
@@ -1445,8 +1177,8 @@ dependencies = [
"nanoid",
"nom",
"ordered-float",
"rand 0.10.0",
"rand_chacha 0.10.0",
"rand 0.9.1",
"rand_chacha 0.9.0",
"rand_distr",
"serde",
"serde_json",
@@ -1460,8 +1192,8 @@ version = "0.0.0"
dependencies = [
"lib0",
"libfuzzer-sys",
"rand 0.10.0",
"rand_chacha 0.10.0",
"rand 0.9.1",
"rand_chacha 0.9.0",
"y-octo",
"y-octo-utils",
"yrs",
@@ -1475,17 +1207,17 @@ dependencies = [
"clap",
"lib0",
"phf",
"rand 0.10.0",
"rand_chacha 0.10.0",
"rand 0.9.1",
"rand_chacha 0.9.0",
"y-octo",
"yrs",
]
[[package]]
name = "yrs"
version = "0.25.0"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6893d39bc55d014e4a1d0e71d06c0c41590d5cdeac35c126be44998bc320cff"
checksum = "4a7cab84724ae7f361a8c92465f5160922cbb941a499e1a8cacd103351ab9c78"
dependencies = [
"arc-swap",
"async-lock",
@@ -1496,7 +1228,7 @@ dependencies = [
"serde_json",
"smallstr",
"smallvec",
"thiserror 2.0.12",
"thiserror 1.0.69",
]
[[package]]

View File

@@ -10,9 +10,9 @@ version = "0.0.0"
[dependencies]
lib0 = "=0.16.10"
libfuzzer-sys = "0.4"
rand = "0.10"
rand_chacha = "0.10"
yrs = "=0.25.0"
rand = "0.9"
rand_chacha = "0.9"
yrs = "=0.23.1"
y-octo-utils = { path = "..", features = ["fuzz"] }

View File

@@ -45,7 +45,7 @@
"embla-carousel-react": "^8.5.1",
"input-otp": "^1.4.1",
"lodash-es": "^4.17.23",
"lucide-react": "^0.577.0",
"lucide-react": "^0.508.0",
"next-themes": "^0.4.4",
"react": "^19.2.1",
"react-day-picker": "^9.4.3",

View File

@@ -19,7 +19,6 @@ import app.affine.pro.plugin.AFFiNEThemePlugin
import app.affine.pro.plugin.AuthPlugin
import app.affine.pro.plugin.HashCashPlugin
import app.affine.pro.plugin.NbStorePlugin
import app.affine.pro.plugin.PreviewPlugin
import app.affine.pro.service.GraphQLService
import app.affine.pro.service.SSEService
import app.affine.pro.service.WebService
@@ -53,7 +52,6 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugi
AuthPlugin::class.java,
HashCashPlugin::class.java,
NbStorePlugin::class.java,
PreviewPlugin::class.java,
)
)
}

View File

@@ -1,6 +1,8 @@
package app.affine.pro.ai.chat
import com.affine.pro.graphql.GetCopilotHistoriesQuery
import com.affine.pro.graphql.fragment.CopilotChatHistory
import com.affine.pro.graphql.fragment.CopilotChatMessage
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
@@ -51,7 +53,7 @@ data class ChatMessage(
createAt = Clock.System.now(),
)
fun from(message: CopilotChatHistory.Message) = ChatMessage(
fun from(message: CopilotChatMessage) = ChatMessage(
id = message.id,
role = Role.fromValue(message.role),
content = message.content,

View File

@@ -1,106 +0,0 @@
package app.affine.pro.plugin
import android.net.Uri
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers
import timber.log.Timber
import uniffi.affine_mobile_native.renderMermaidPreviewSvg
import uniffi.affine_mobile_native.renderTypstPreviewSvg
import java.io.File
private fun JSObject.getOptionalString(key: String): String? {
return if (has(key) && !isNull(key)) getString(key) else null
}
private fun JSObject.getOptionalDouble(key: String): Double? {
return if (has(key) && !isNull(key)) getDouble(key) else null
}
private fun resolveLocalFontDir(fontUrl: String): String? {
val uri = Uri.parse(fontUrl)
val path = when {
uri.scheme == null -> {
val file = File(fontUrl)
if (!file.isAbsolute) {
return null
}
file.path
}
uri.scheme == "file" -> uri.path
else -> null
} ?: return null
val file = File(path)
val directory = if (file.isDirectory) file else file.parentFile ?: return null
return directory.absolutePath
}
private fun JSObject.resolveTypstFontDirs(): List<String>? {
if (!has("fontUrls") || isNull("fontUrls")) {
return null
}
val fontUrls = optJSONArray("fontUrls")
?: throw IllegalArgumentException("Typst preview fontUrls must be an array of strings.")
val fontDirs = buildList(fontUrls.length()) {
repeat(fontUrls.length()) { index ->
val fontUrl = fontUrls.optString(index, null)
?: throw IllegalArgumentException("Typst preview fontUrls must be strings.")
val fontDir = resolveLocalFontDir(fontUrl)
?: throw IllegalArgumentException("Typst preview on mobile only supports local font file URLs or absolute font directories.")
add(fontDir)
}
}
return fontDirs.distinct()
}
@CapacitorPlugin(name = "Preview")
class PreviewPlugin : Plugin() {
@PluginMethod
fun renderMermaidSvg(call: PluginCall) {
launch(Dispatchers.IO) {
try {
val code = call.getStringEnsure("code")
val options = call.getObject("options")
val svg = renderMermaidPreviewSvg(
code = code,
theme = options?.getOptionalString("theme"),
fontFamily = options?.getOptionalString("fontFamily"),
fontSize = options?.getOptionalDouble("fontSize"),
)
call.resolve(JSObject().apply {
put("svg", svg)
})
} catch (e: Exception) {
Timber.e(e, "Failed to render Mermaid preview.")
call.reject("Failed to render Mermaid preview.", null, e)
}
}
}
@PluginMethod
fun renderTypstSvg(call: PluginCall) {
launch(Dispatchers.IO) {
try {
val code = call.getStringEnsure("code")
val options = call.getObject("options")
val svg = renderTypstPreviewSvg(
code = code,
fontDirs = options?.resolveTypstFontDirs(),
cacheDir = context.cacheDir.absolutePath,
)
call.resolve(JSObject().apply {
put("svg", svg)
})
} catch (e: Exception) {
Timber.e(e, "Failed to render Typst preview.")
call.reject("Failed to render Typst preview.", null, e)
}
}
}
}

View File

@@ -72,7 +72,7 @@ class GraphQLService @Inject constructor() {
).mapCatching { data ->
data.currentUser?.copilot?.chats?.paginatedCopilotChats?.edges?.map { item -> item.node.copilotChatHistory }?.firstOrNull { history ->
history.sessionId == sessionId
}?.messages ?: emptyList()
}?.messages?.map { msg -> msg.copilotChatMessage } ?: emptyList()
}
suspend fun getCopilotHistoryIds(

View File

@@ -792,10 +792,6 @@ internal interface UniffiForeignFutureCompleteVoid : com.sun.jna.Callback {
@@ -820,10 +816,6 @@ internal interface IntegrityCheckingUniffiLib : Library {
): Short
fun uniffi_affine_mobile_native_checksum_func_new_doc_storage_pool(
): Short
fun uniffi_affine_mobile_native_checksum_func_render_mermaid_preview_svg(
): Short
fun uniffi_affine_mobile_native_checksum_func_render_typst_preview_svg(
): Short
fun uniffi_affine_mobile_native_checksum_method_docstoragepool_clear_clocks(
): Short
fun uniffi_affine_mobile_native_checksum_method_docstoragepool_connect(
@@ -1025,10 +1017,6 @@ fun uniffi_affine_mobile_native_fn_func_hashcash_mint(`resource`: RustBuffer.ByV
): RustBuffer.ByValue
fun uniffi_affine_mobile_native_fn_func_new_doc_storage_pool(uniffi_out_err: UniffiRustCallStatus,
): Pointer
fun uniffi_affine_mobile_native_fn_func_render_mermaid_preview_svg(`code`: RustBuffer.ByValue,`theme`: RustBuffer.ByValue,`fontFamily`: RustBuffer.ByValue,`fontSize`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus,
): RustBuffer.ByValue
fun uniffi_affine_mobile_native_fn_func_render_typst_preview_svg(`code`: RustBuffer.ByValue,`fontDirs`: RustBuffer.ByValue,`cacheDir`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus,
): RustBuffer.ByValue
fun ffi_affine_mobile_native_rustbuffer_alloc(`size`: Long,uniffi_out_err: UniffiRustCallStatus,
): RustBuffer.ByValue
fun ffi_affine_mobile_native_rustbuffer_from_bytes(`bytes`: ForeignBytes.ByValue,uniffi_out_err: UniffiRustCallStatus,
@@ -1161,12 +1149,6 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) {
if (lib.uniffi_affine_mobile_native_checksum_func_new_doc_storage_pool() != 32882.toShort()) {
throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
}
if (lib.uniffi_affine_mobile_native_checksum_func_render_mermaid_preview_svg() != 54334.toShort()) {
throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
}
if (lib.uniffi_affine_mobile_native_checksum_func_render_typst_preview_svg() != 42796.toShort()) {
throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
}
if (lib.uniffi_affine_mobile_native_checksum_method_docstoragepool_clear_clocks() != 51151.toShort()) {
throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
}
@@ -3196,38 +3178,6 @@ public object FfiConverterOptionalLong: FfiConverterRustBuffer<kotlin.Long?> {
/**
* @suppress
*/
public object FfiConverterOptionalDouble: FfiConverterRustBuffer<kotlin.Double?> {
override fun read(buf: ByteBuffer): kotlin.Double? {
if (buf.get().toInt() == 0) {
return null
}
return FfiConverterDouble.read(buf)
}
override fun allocationSize(value: kotlin.Double?): ULong {
if (value == null) {
return 1UL
} else {
return 1UL + FfiConverterDouble.allocationSize(value)
}
}
override fun write(value: kotlin.Double?, buf: ByteBuffer) {
if (value == null) {
buf.put(0)
} else {
buf.put(1)
FfiConverterDouble.write(value, buf)
}
}
}
/**
* @suppress
*/
@@ -3634,24 +3584,4 @@ public object FfiConverterSequenceTypeSearchHit: FfiConverterRustBuffer<List<Sea
}
@Throws(UniffiException::class) fun `renderMermaidPreviewSvg`(`code`: kotlin.String, `theme`: kotlin.String?, `fontFamily`: kotlin.String?, `fontSize`: kotlin.Double?): kotlin.String {
return FfiConverterString.lift(
uniffiRustCallWithError(UniffiException) { _status ->
UniffiLib.INSTANCE.uniffi_affine_mobile_native_fn_func_render_mermaid_preview_svg(
FfiConverterString.lower(`code`),FfiConverterOptionalString.lower(`theme`),FfiConverterOptionalString.lower(`fontFamily`),FfiConverterOptionalDouble.lower(`fontSize`),_status)
}
)
}
@Throws(UniffiException::class) fun `renderTypstPreviewSvg`(`code`: kotlin.String, `fontDirs`: List<kotlin.String>?, `cacheDir`: kotlin.String?): kotlin.String {
return FfiConverterString.lift(
uniffiRustCallWithError(UniffiException) { _status ->
UniffiLib.INSTANCE.uniffi_affine_mobile_native_fn_func_render_typst_preview_svg(
FfiConverterString.lower(`code`),FfiConverterOptionalSequenceString.lower(`fontDirs`),FfiConverterOptionalString.lower(`cacheDir`),_status)
}
)
}

View File

@@ -9,7 +9,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.2'
classpath 'com.android.tools.build:gradle:8.10.0'
}
}

View File

@@ -1,44 +1,44 @@
[versions]
android-gradle-plugin = "8.13.2"
androidx-activity-compose = "1.13.0"
androidx-appcompat = "1.7.1"
androidx-browser = "1.9.0"
androidx-compose-bom = "2025.12.01"
android-gradle-plugin = "8.10.0"
androidx-activity-compose = "1.10.1"
androidx-appcompat = "1.7.0"
androidx-browser = "1.8.0"
androidx-compose-bom = "2025.05.00"
androidx-coordinatorlayout = "1.3.0"
androidx-core-ktx = "1.18.0"
androidx-core-splashscreen = "1.2.0"
androidx-datastore-preferences = "1.2.1"
androidx-espresso-core = "3.7.0"
androidx-junit = "1.3.0"
androidx-lifecycle-compose = "2.10.0"
androidx-core-ktx = "1.16.0"
androidx-core-splashscreen = "1.0.1"
androidx-datastore-preferences = "1.2.0-alpha02"
androidx-espresso-core = "3.6.1"
androidx-junit = "1.2.1"
androidx-lifecycle-compose = "2.9.0"
androidx-material3 = "1.3.1"
androidx-navigation = "2.9.7"
apollo = "4.4.2"
apollo-kotlin-adapters = "0.7.0"
androidx-navigation = "2.9.0"
apollo = "4.2.0"
apollo-kotlin-adapters = "0.0.6"
# @keep
compileSdk = "36"
firebase-bom = "33.16.0"
firebase-crashlytics = "3.0.6"
google-services = "4.4.4"
gradle-versions = "0.53.0"
hilt = "2.59.2"
hilt-ext = "1.3.0"
jna = "5.18.1"
firebase-bom = "33.13.0"
firebase-crashlytics = "3.0.3"
google-services = "4.4.2"
gradle-versions = "0.52.0"
hilt = "2.56.2"
hilt-ext = "1.2.0"
jna = "5.17.0"
junit = "4.13.2"
kotlin = "2.3.20"
kotlin = "2.1.20"
kotlinx-coroutines = "1.10.2"
kotlinx-datetime = "0.7.1-0.6.x-compat"
kotlinx-serialization-json = "1.10.0"
ksp = "2.3.6"
kotlinx-datetime = "0.6.2"
kotlinx-serialization-json = "1.8.1"
ksp = "2.1.20-2.0.1"
# @keep
minSdk = "23"
mozilla-rust-android = "0.9.6"
okhttp-bom = "5.3.2"
richtext = "1.0.0-alpha03"
okhttp-bom = "5.0.0-alpha.14"
richtext = "1.0.0-alpha02"
# @keep
targetSdk = "35"
timber = "5.0.1"
version-catalog-update = "1.1.0"
version-catalog-update = "1.0.0"
[libraries]
android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "android-gradle-plugin" }

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View File

@@ -15,7 +15,6 @@ import {
ServersService,
ValidatorProvider,
} from '@affine/core/modules/cloud';
import { registerNativePreviewHandlers } from '@affine/core/modules/code-block-preview-renderer';
import { DocsService } from '@affine/core/modules/doc';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { I18nProvider } from '@affine/core/modules/i18n';
@@ -55,7 +54,6 @@ import { AIButton } from './plugins/ai-button';
import { Auth } from './plugins/auth';
import { HashCash } from './plugins/hashcash';
import { NbStoreNativeDBApis } from './plugins/nbstore';
import { Preview } from './plugins/preview';
import { writeEndpointToken } from './proxy';
const storeManagerClient = createStoreManagerClient();
@@ -87,11 +85,6 @@ framework.impl(NbstoreProvider, {
});
const frameworkProvider = framework.provider();
registerNativePreviewHandlers({
renderMermaidSvg: request => Preview.renderMermaidSvg(request),
renderTypstSvg: request => Preview.renderTypstSvg(request),
});
framework.impl(PopupWindowProvider, {
open: (url: string) => {
InAppBrowser.open({

View File

@@ -433,9 +433,7 @@ export const NbStoreNativeDBApis: NativeDBApis = {
id: string,
docId: string
): Promise<DocIndexedClock | null> {
return NbStore.getDocIndexedClock({ id, docId }).then(clock =>
clock ? { ...clock, timestamp: new Date(clock.timestamp) } : null
);
return NbStore.getDocIndexedClock({ id, docId });
},
setDocIndexedClock: function (
id: string,

View File

@@ -1,16 +0,0 @@
export interface PreviewPlugin {
renderMermaidSvg(options: {
code: string;
options?: {
theme?: string;
fontFamily?: string;
fontSize?: number;
};
}): Promise<{ svg: string }>;
renderTypstSvg(options: {
code: string;
options?: {
fontUrls?: string[];
};
}): Promise<{ svg: string }>;
}

View File

@@ -1,8 +0,0 @@
import { registerPlugin } from '@capacitor/core';
import type { PreviewPlugin } from './definitions';
const Preview = registerPlugin<PreviewPlugin>('Preview');
export * from './definitions';
export { Preview };

View File

@@ -46,10 +46,7 @@ export function setupEvents(frameworkProvider: FrameworkProvider) {
const { workspace } = currentWorkspace;
const docsService = workspace.scope.get(DocsService);
const page =
type === 'default'
? docsService.createDoc()
: docsService.createDoc({ primaryMode: type });
const page = docsService.createDoc({ primaryMode: type });
workspace.scope.get(WorkbenchService).workbench.openDoc(page.id);
})
.catch(err => {

View File

@@ -13,19 +13,6 @@ import type { FrameworkProvider } from '@toeverything/infra';
import { getCurrentWorkspace, isAiEnabled } from './utils';
const logger = new DebugLogger('electron-renderer:recording');
const RECORDING_PROCESS_RETRY_MS = 1000;
const NATIVE_RECORDING_MIME_TYPE = 'audio/ogg';
type ProcessingRecordingStatus = {
id: number;
status: 'processing';
appName?: string;
blockCreationStatus?: undefined;
filepath: string;
startTime: number;
};
type WorkspaceHandle = NonNullable<ReturnType<typeof getCurrentWorkspace>>;
async function readRecordingFile(filepath: string) {
if (apis?.recording?.readRecordingFile) {
@@ -58,217 +45,118 @@ async function saveRecordingBlob(blobEngine: BlobEngine, filepath: string) {
logger.debug('Saving recording', filepath);
const opusBuffer = await readRecordingFile(filepath);
const blob = new Blob([opusBuffer], {
type: NATIVE_RECORDING_MIME_TYPE,
type: 'audio/mp4',
});
const blobId = await blobEngine.set(blob);
logger.debug('Recording saved', blobId);
return { blob, blobId };
}
function shouldProcessRecording(
status: unknown
): status is ProcessingRecordingStatus {
return (
!!status &&
typeof status === 'object' &&
'status' in status &&
status.status === 'processing' &&
'filepath' in status &&
typeof status.filepath === 'string' &&
!('blockCreationStatus' in status && status.blockCreationStatus)
);
}
async function createRecordingDoc(
frameworkProvider: FrameworkProvider,
workspace: WorkspaceHandle['workspace'],
status: ProcessingRecordingStatus
) {
const docsService = workspace.scope.get(DocsService);
const aiEnabled = isAiEnabled(frameworkProvider);
const recordingFilepath = status.filepath;
const timestamp = i18nTime(status.startTime, {
absolute: {
accuracy: 'minute',
noYear: true,
},
});
await new Promise<void>((resolve, reject) => {
const docProps: DocProps = {
onStoreLoad: (doc, { noteId }) => {
void (async () => {
// it takes a while to save the blob, so we show the attachment first
const { blobId, blob } = await saveRecordingBlob(
doc.workspace.blobSync,
recordingFilepath
);
// name + timestamp(readable) + extension
const attachmentName =
(status.appName ?? 'System Audio') + ' ' + timestamp + '.opus';
const attachmentId = doc.addBlock(
'affine:attachment',
{
name: attachmentName,
type: NATIVE_RECORDING_MIME_TYPE,
size: blob.size,
sourceId: blobId,
embed: true,
},
noteId
);
const model = doc.getBlock(attachmentId)
?.model as AttachmentBlockModel;
if (!aiEnabled) {
return;
}
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
if (!currentWorkspace) {
return;
}
const { workspace } = currentWorkspace;
using audioAttachment = workspace.scope
.get(AudioAttachmentService)
.get(model);
audioAttachment?.obj
.transcribe()
.then(() => {
track.doc.editor.audioBlock.transcribeRecording({
type: 'Meeting record',
method: 'success',
option: 'Auto transcribing',
});
})
.catch(err => {
logger.error('Failed to transcribe recording', err);
});
})().then(resolve, reject);
},
};
const page = docsService.createDoc({
docProps,
title:
'Recording ' + (status.appName ?? 'System Audio') + ' ' + timestamp,
primaryMode: 'page',
});
workspace.scope.get(WorkbenchService).workbench.openDoc(page.id);
});
}
export function setupRecordingEvents(frameworkProvider: FrameworkProvider) {
let pendingStatus: ProcessingRecordingStatus | null = null;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let processingStatusId: number | null = null;
const clearRetry = () => {
if (retryTimer !== null) {
clearTimeout(retryTimer);
retryTimer = null;
}
};
const clearPending = (id?: number) => {
if (id === undefined || pendingStatus?.id === id) {
pendingStatus = null;
clearRetry();
}
if (id === undefined || processingStatusId === id) {
processingStatusId = null;
}
};
const scheduleRetry = () => {
if (!pendingStatus || retryTimer !== null) {
return;
}
retryTimer = setTimeout(() => {
retryTimer = null;
void processPendingStatus().catch(console.error);
}, RECORDING_PROCESS_RETRY_MS);
};
const processPendingStatus = async () => {
const status = pendingStatus;
if (!status || processingStatusId === status.id) {
return;
}
let isActiveTab = false;
try {
isActiveTab = !!(await apis?.ui.isActiveTab());
} catch (error) {
logger.error('Failed to probe active recording tab', error);
scheduleRetry();
return;
}
if (!isActiveTab) {
scheduleRetry();
return;
}
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
if (!currentWorkspace) {
// Workspace can lag behind the post-recording status update for a short
// time; keep retrying instead of permanently failing the import.
scheduleRetry();
return;
}
processingStatusId = status.id;
try {
await createRecordingDoc(
frameworkProvider,
currentWorkspace.workspace,
status
);
await apis?.recording.setRecordingBlockCreationStatus(
status.id,
'success'
);
clearPending(status.id);
} catch (error) {
logger.error('Failed to create recording block', error);
try {
await apis?.recording.setRecordingBlockCreationStatus(
status.id,
'failed',
error instanceof Error ? error.message : undefined
);
} finally {
clearPending(status.id);
}
} finally {
if (pendingStatus?.id === status.id) {
processingStatusId = null;
scheduleRetry();
}
}
};
events?.recording.onRecordingStatusChanged(status => {
if (shouldProcessRecording(status)) {
pendingStatus = status;
clearRetry();
void processPendingStatus().catch(console.error);
return;
}
(async () => {
if ((await apis?.ui.isActiveTab()) && status?.status === 'ready') {
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
if (!currentWorkspace) {
// maybe the workspace is not ready yet, eg. for shared workspace view
await apis?.recording.handleBlockCreationFailed(status.id);
return;
}
const { workspace } = currentWorkspace;
const docsService = workspace.scope.get(DocsService);
const aiEnabled = isAiEnabled(frameworkProvider);
if (!status) {
clearPending();
return;
}
const timestamp = i18nTime(status.startTime, {
absolute: {
accuracy: 'minute',
noYear: true,
},
});
if (pendingStatus?.id === status.id) {
clearPending(status.id);
}
const docProps: DocProps = {
onStoreLoad: (doc, { noteId }) => {
(async () => {
if (status.filepath) {
// it takes a while to save the blob, so we show the attachment first
const { blobId, blob } = await saveRecordingBlob(
doc.workspace.blobSync,
status.filepath
);
// name + timestamp(readable) + extension
const attachmentName =
(status.appName ?? 'System Audio') +
' ' +
timestamp +
'.opus';
// add size and sourceId to the attachment later
const attachmentId = doc.addBlock(
'affine:attachment',
{
name: attachmentName,
type: 'audio/opus',
size: blob.size,
sourceId: blobId,
embed: true,
},
noteId
);
const model = doc.getBlock(attachmentId)
?.model as AttachmentBlockModel;
if (!aiEnabled) {
return;
}
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
if (!currentWorkspace) {
return;
}
const { workspace } = currentWorkspace;
using audioAttachment = workspace.scope
.get(AudioAttachmentService)
.get(model);
audioAttachment?.obj
.transcribe()
.then(() => {
track.doc.editor.audioBlock.transcribeRecording({
type: 'Meeting record',
method: 'success',
option: 'Auto transcribing',
});
})
.catch(err => {
logger.error('Failed to transcribe recording', err);
});
} else {
throw new Error('No attachment model found');
}
})()
.then(async () => {
await apis?.recording.handleBlockCreationSuccess(status.id);
})
.catch(error => {
logger.error('Failed to transcribe recording', error);
return apis?.recording.handleBlockCreationFailed(
status.id,
error
);
})
.catch(error => {
console.error('unknown error', error);
});
},
};
const page = docsService.createDoc({
docProps,
title:
'Recording ' + (status.appName ?? 'System Audio') + ' ' + timestamp,
primaryMode: 'page',
});
workspace.scope.get(WorkbenchService).workbench.openDoc(page.id);
}
})().catch(console.error);
});
}

View File

@@ -1,17 +1,28 @@
import { Button } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { appIconMap } from '@affine/core/utils';
import {
createStreamEncoder,
encodeRawBufferToOpus,
type OpusStreamEncoder,
} from '@affine/core/utils/opus-encoding';
import { apis, events } from '@affine/electron-api';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import * as styles from './styles.css';
type Status = {
id: number;
status: 'new' | 'recording' | 'processing' | 'ready';
blockCreationStatus?: 'success' | 'failed';
status:
| 'new'
| 'recording'
| 'paused'
| 'stopped'
| 'ready'
| 'create-block-success'
| 'create-block-failed';
appName?: string;
appGroupId?: number;
icon?: Buffer;
@@ -47,7 +58,6 @@ const appIcon = appIconMap[BUILD_CONFIG.appBuildType];
export function Recording() {
const status = useRecordingStatus();
const trackedNewRecordingIdsRef = useRef<Set<number>>(new Set());
const t = useI18n();
const textElement = useMemo(() => {
@@ -56,19 +66,14 @@ export function Recording() {
}
if (status.status === 'new') {
return t['com.affine.recording.new']();
} else if (
status.status === 'ready' &&
status.blockCreationStatus === 'success'
) {
} else if (status.status === 'create-block-success') {
return t['com.affine.recording.success.prompt']();
} else if (
status.status === 'ready' &&
status.blockCreationStatus === 'failed'
) {
} else if (status.status === 'create-block-failed') {
return t['com.affine.recording.failed.prompt']();
} else if (
status.status === 'recording' ||
status.status === 'processing'
status.status === 'ready' ||
status.status === 'stopped'
) {
if (status.appName) {
return t['com.affine.recording.recording']({
@@ -100,16 +105,106 @@ export function Recording() {
await apis?.recording?.stopRecording(status.id);
}, [status]);
useEffect(() => {
if (!status || status.status !== 'new') return;
if (trackedNewRecordingIdsRef.current.has(status.id)) return;
const handleProcessStoppedRecording = useAsyncCallback(
async (currentStreamEncoder?: OpusStreamEncoder) => {
let id: number | undefined;
try {
const result = await apis?.recording?.getCurrentRecording();
trackedNewRecordingIdsRef.current.add(status.id);
track.popup.$.recordingBar.toggleRecordingBar({
type: 'Meeting record',
appName: status.appName || 'System Audio',
if (!result) {
return;
}
id = result.id;
const { filepath, sampleRate, numberOfChannels } = result;
if (!filepath || !sampleRate || !numberOfChannels) {
return;
}
const [buffer] = await Promise.all([
currentStreamEncoder
? currentStreamEncoder.finish()
: encodeRawBufferToOpus({
filepath,
sampleRate,
numberOfChannels,
}),
new Promise<void>(resolve => {
setTimeout(() => {
resolve();
}, 500); // wait at least 500ms for better user experience
}),
]);
await apis?.recording.readyRecording(result.id, buffer);
} catch (error) {
console.error('Failed to stop recording', error);
await apis?.popup?.dismissCurrentRecording();
if (id) {
await apis?.recording.removeRecording(id);
}
}
},
[]
);
useEffect(() => {
let removed = false;
let currentStreamEncoder: OpusStreamEncoder | undefined;
apis?.recording
.getCurrentRecording()
.then(status => {
if (status) {
return handleRecordingStatusChanged(status);
}
return;
})
.catch(console.error);
const handleRecordingStatusChanged = async (status: Status) => {
if (removed) {
return;
}
if (status?.status === 'new') {
track.popup.$.recordingBar.toggleRecordingBar({
type: 'Meeting record',
appName: status.appName || 'System Audio',
});
}
if (
status?.status === 'recording' &&
status.sampleRate &&
status.numberOfChannels &&
(!currentStreamEncoder || currentStreamEncoder.id !== status.id)
) {
currentStreamEncoder?.close();
currentStreamEncoder = createStreamEncoder(status.id, {
sampleRate: status.sampleRate,
numberOfChannels: status.numberOfChannels,
});
currentStreamEncoder.poll().catch(console.error);
}
if (status?.status === 'stopped') {
handleProcessStoppedRecording(currentStreamEncoder);
currentStreamEncoder = undefined;
}
};
// allow processing stopped event in tray menu as well:
const unsubscribe = events?.recording.onRecordingStatusChanged(status => {
if (status) {
handleRecordingStatusChanged(status).catch(console.error);
}
});
}, [status]);
return () => {
removed = true;
unsubscribe?.();
currentStreamEncoder?.close();
};
}, [handleProcessStoppedRecording]);
const handleStartRecording = useAsyncCallback(async () => {
if (!status) {
@@ -154,10 +249,7 @@ export function Recording() {
{t['com.affine.recording.stop']()}
</Button>
);
} else if (
status.status === 'processing' ||
(status.status === 'ready' && !status.blockCreationStatus)
) {
} else if (status.status === 'stopped' || status.status === 'ready') {
return (
<Button
variant="error"
@@ -166,19 +258,13 @@ export function Recording() {
disabled
/>
);
} else if (
status.status === 'ready' &&
status.blockCreationStatus === 'success'
) {
} else if (status.status === 'create-block-success') {
return (
<Button variant="primary" onClick={handleDismiss}>
{t['com.affine.recording.success.button']()}
</Button>
);
} else if (
status.status === 'ready' &&
status.blockCreationStatus === 'failed'
) {
} else if (status.status === 'create-block-failed') {
return (
<>
<Button variant="plain" onClick={handleDismiss}>

View File

@@ -62,7 +62,7 @@
"electron-log": "^5.4.3",
"electron-squirrel-startup": "1.0.1",
"electron-window-state": "^5.0.3",
"esbuild": "^0.27.0",
"esbuild": "^0.25.0",
"fs-extra": "^11.2.0",
"glob": "^11.0.0",
"lodash-es": "^4.17.23",

View File

@@ -1,11 +1,10 @@
import { parse, resolve } from 'node:path';
import { parse } from 'node:path';
import { DocStorage, ValidationResult } from '@affine/native';
import { parseUniversalId } from '@affine/nbstore';
import fs from 'fs-extra';
import { nanoid } from 'nanoid';
import { isPathInsideBase } from '../../shared/utils';
import { logger } from '../logger';
import { mainRPC } from '../main-rpc';
import { getDocStoragePool } from '../nbstore';
@@ -39,6 +38,31 @@ export interface SelectDBFileLocationResult {
canceled?: boolean;
}
// provide a backdoor to set dialog path for testing in playwright
export interface FakeDialogResult {
canceled?: boolean;
filePath?: string;
filePaths?: string[];
}
// result will be used in the next call to showOpenDialog
// if it is being read once, it will be reset to undefined
let fakeDialogResult: FakeDialogResult | undefined = undefined;
function getFakedResult() {
const result = fakeDialogResult;
fakeDialogResult = undefined;
return result;
}
export function setFakeDialogResult(result: FakeDialogResult | undefined) {
fakeDialogResult = result;
// for convenience, we will fill filePaths with filePath if it is not set
if (result?.filePaths === undefined && result?.filePath !== undefined) {
result.filePaths = [result.filePath];
}
}
const extension = 'affine';
function getDefaultDBFileName(name: string, id: string) {
@@ -47,55 +71,10 @@ function getDefaultDBFileName(name: string, id: string) {
return fileName.replace(/[/\\?%*:|"<>]/g, '-');
}
async function resolveExistingPath(path: string) {
if (!(await fs.pathExists(path))) {
return null;
}
try {
return await fs.realpath(path);
} catch {
return resolve(path);
}
}
async function isSameFilePath(sourcePath: string, targetPath: string) {
if (resolve(sourcePath) === resolve(targetPath)) {
return true;
}
const [resolvedSourcePath, resolvedTargetPath] = await Promise.all([
resolveExistingPath(sourcePath),
resolveExistingPath(targetPath),
]);
return !!resolvedSourcePath && resolvedSourcePath === resolvedTargetPath;
}
async function normalizeImportDBPath(selectedPath: string) {
if (!(await fs.pathExists(selectedPath))) {
return null;
}
const [normalizedPath, workspacesBasePath] = await Promise.all([
resolveExistingPath(selectedPath),
resolveExistingPath(await getWorkspacesBasePath()),
]);
const resolvedSelectedPath = normalizedPath ?? resolve(selectedPath);
const resolvedWorkspacesBasePath =
workspacesBasePath ?? resolve(await getWorkspacesBasePath());
if (isPathInsideBase(resolvedWorkspacesBasePath, resolvedSelectedPath)) {
logger.warn('loadDBFile: db file in app data dir');
return null;
}
return resolvedSelectedPath;
}
/**
* This function is called when the user clicks the "Save" button in the "Save Workspace" dialog.
*
* It will export a compacted database file to the given path
* It will just copy the file to the given path
*/
export async function saveDBFileAs(
universalId: string,
@@ -110,53 +89,44 @@ export async function saveDBFileAs(
await pool.connect(universalId, dbPath);
await pool.checkpoint(universalId); // make sure all changes (WAL) are written to db
const fakedResult = getFakedResult();
if (!dbPath) {
return {
error: 'DB_FILE_PATH_INVALID',
};
}
const ret = await mainRPC.showSaveDialog({
properties: ['showOverwriteConfirmation'],
title: 'Save Workspace',
showsTagField: false,
buttonLabel: 'Save',
filters: [
{
extensions: [extension],
name: '',
},
],
defaultPath: getDefaultDBFileName(name, id),
message: 'Save Workspace as a SQLite Database file',
});
const ret =
fakedResult ??
(await mainRPC.showSaveDialog({
properties: ['showOverwriteConfirmation'],
title: 'Save Workspace',
showsTagField: false,
buttonLabel: 'Save',
filters: [
{
extensions: [extension],
name: '',
},
],
defaultPath: getDefaultDBFileName(name, id),
message: 'Save Workspace as a SQLite Database file',
}));
const filePath = ret.filePath;
if (ret.canceled || !filePath) {
return { canceled: true };
return {
canceled: true,
};
}
if (await isSameFilePath(dbPath, filePath)) {
return { error: 'DB_FILE_PATH_INVALID' };
}
const tempFilePath = `${filePath}.${nanoid(6)}.tmp`;
if (await fs.pathExists(tempFilePath)) {
await fs.remove(tempFilePath);
}
try {
await pool.vacuumInto(universalId, tempFilePath);
await fs.move(tempFilePath, filePath, { overwrite: true });
} finally {
if (await fs.pathExists(tempFilePath)) {
await fs.remove(tempFilePath);
}
}
await fs.copyFile(dbPath, filePath);
logger.log('saved', filePath);
mainRPC.showItemInFolder(filePath).catch(err => {
console.error(err);
});
if (!fakedResult) {
mainRPC.showItemInFolder(filePath).catch(err => {
console.error(err);
});
}
return { filePath };
} catch (err) {
logger.error('saveDBFileAs', err);
@@ -168,13 +138,15 @@ export async function saveDBFileAs(
export async function selectDBFileLocation(): Promise<SelectDBFileLocationResult> {
try {
const ret = await mainRPC.showOpenDialog({
properties: ['openDirectory'],
title: 'Set Workspace Storage Location',
buttonLabel: 'Select',
defaultPath: await mainRPC.getPath('documents'),
message: "Select a location to store the workspace's database file",
});
const ret =
getFakedResult() ??
(await mainRPC.showOpenDialog({
properties: ['openDirectory'],
title: 'Set Workspace Storage Location',
buttonLabel: 'Select',
defaultPath: await mainRPC.getPath('documents'),
message: "Select a location to store the workspace's database file",
}));
const dir = ret.filePaths?.[0];
if (ret.canceled || !dir) {
return {
@@ -204,29 +176,43 @@ export async function selectDBFileLocation(): Promise<SelectDBFileLocationResult
* update the local workspace id list and then connect to it.
*
*/
export async function loadDBFile(): Promise<LoadDBFileResult> {
export async function loadDBFile(
dbFilePath?: string
): Promise<LoadDBFileResult> {
try {
const ret = await mainRPC.showOpenDialog({
properties: ['openFile'],
title: 'Load Workspace',
buttonLabel: 'Load',
filters: [
{
name: 'SQLite Database',
// do we want to support other file format?
extensions: ['db', 'affine'],
},
],
message: 'Load Workspace from a AFFiNE file',
});
const selectedPath = ret.filePaths?.[0];
if (ret.canceled || !selectedPath) {
const provided =
getFakedResult() ??
(dbFilePath
? {
filePath: dbFilePath,
filePaths: [dbFilePath],
canceled: false,
}
: undefined);
const ret =
provided ??
(await mainRPC.showOpenDialog({
properties: ['openFile'],
title: 'Load Workspace',
buttonLabel: 'Load',
filters: [
{
name: 'SQLite Database',
// do we want to support other file format?
extensions: ['db', 'affine'],
},
],
message: 'Load Workspace from a AFFiNE file',
}));
const originalPath = ret.filePaths?.[0];
if (ret.canceled || !originalPath) {
logger.info('loadDBFile canceled');
return { canceled: true };
}
const originalPath = await normalizeImportDBPath(selectedPath);
if (!originalPath) {
// the imported file should not be in app data dir
if (originalPath.startsWith(await getWorkspacesBasePath())) {
logger.warn('loadDBFile: db file in app data dir');
return { error: 'DB_FILE_PATH_INVALID' };
}
@@ -238,10 +224,6 @@ export async function loadDBFile(): Promise<LoadDBFileResult> {
return await cpV1DBFile(originalPath, workspaceId);
}
if (!(await storage.validateImportSchema())) {
return { error: 'DB_FILE_INVALID' };
}
// v2 import logic
const internalFilePath = await getSpaceDBPath(
'local',
@@ -249,8 +231,8 @@ export async function loadDBFile(): Promise<LoadDBFileResult> {
workspaceId
);
await fs.ensureDir(parse(internalFilePath).dir);
await storage.vacuumInto(internalFilePath);
logger.info(`loadDBFile, vacuum: ${originalPath} -> ${internalFilePath}`);
await fs.copy(originalPath, internalFilePath);
logger.info(`loadDBFile, copy: ${originalPath} -> ${internalFilePath}`);
storage = new DocStorage(internalFilePath);
await storage.setSpaceId(workspaceId);
@@ -278,27 +260,24 @@ async function cpV1DBFile(
return { error: 'DB_FILE_INVALID' }; // invalid db file
}
// checkout to make sure wal is flushed
const connection = new SqliteConnection(originalPath);
try {
if (!(await connection.validateImportSchema())) {
return { error: 'DB_FILE_INVALID' };
}
await connection.connect();
await connection.checkpoint();
await connection.close();
const internalFilePath = await getWorkspaceDBPath('workspace', workspaceId);
const internalFilePath = await getWorkspaceDBPath('workspace', workspaceId);
await fs.ensureDir(parse(internalFilePath).dir);
await connection.vacuumInto(internalFilePath);
logger.info(`loadDBFile, vacuum: ${originalPath} -> ${internalFilePath}`);
await fs.ensureDir(await getWorkspacesBasePath());
await fs.copy(originalPath, internalFilePath);
logger.info(`loadDBFile, copy: ${originalPath} -> ${internalFilePath}`);
await storeWorkspaceMeta(workspaceId, {
id: workspaceId,
mainDBPath: internalFilePath,
});
await storeWorkspaceMeta(workspaceId, {
id: workspaceId,
mainDBPath: internalFilePath,
});
return {
workspaceId,
};
} finally {
await connection.close();
}
return {
workspaceId,
};
}

View File

@@ -1,8 +1,13 @@
import { loadDBFile, saveDBFileAs, selectDBFileLocation } from './dialog';
import {
loadDBFile,
saveDBFileAs,
selectDBFileLocation,
setFakeDialogResult,
} from './dialog';
export const dialogHandlers = {
loadDBFile: async () => {
return loadDBFile();
loadDBFile: async (dbFilePath?: string) => {
return loadDBFile(dbFilePath);
},
saveDBFileAs: async (universalId: string, name: string) => {
return saveDBFileAs(universalId, name);
@@ -10,4 +15,9 @@ export const dialogHandlers = {
selectDBFileLocation: async () => {
return selectDBFileLocation();
},
setFakeDialogResult: async (
result: Parameters<typeof setFakeDialogResult>[0]
) => {
return setFakeDialogResult(result);
},
};

View File

@@ -1,6 +1,5 @@
import { dialogHandlers } from './dialog';
import { dbEventsV1, dbHandlersV1, nbstoreHandlers } from './nbstore';
import { previewHandlers } from './preview';
import { provideExposed } from './provide';
import { workspaceEvents, workspaceHandlers } from './workspace';
@@ -9,7 +8,6 @@ export const handlers = {
nbstore: nbstoreHandlers,
workspace: workspaceHandlers,
dialog: dialogHandlers,
preview: previewHandlers,
};
export const events = {

View File

@@ -1,69 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import {
type MermaidRenderRequest,
type MermaidRenderResult,
renderMermaidSvg,
renderTypstSvg,
type TypstRenderRequest,
type TypstRenderResult,
} from '@affine/native';
const TYPST_FONT_DIRS_ENV = 'AFFINE_TYPST_FONT_DIRS';
function parseTypstFontDirsFromEnv() {
const value = process.env[TYPST_FONT_DIRS_ENV];
if (!value) {
return [];
}
return value
.split(path.delimiter)
.map(dir => dir.trim())
.filter(Boolean);
}
function getTypstFontDirCandidates() {
const resourcesPath = process.resourcesPath ?? '';
return [
...parseTypstFontDirsFromEnv(),
path.join(resourcesPath, 'fonts'),
path.join(resourcesPath, 'js', 'fonts'),
path.join(resourcesPath, 'app.asar.unpacked', 'fonts'),
path.join(resourcesPath, 'app.asar.unpacked', 'js', 'fonts'),
];
}
function resolveTypstFontDirs() {
return Array.from(
new Set(getTypstFontDirCandidates().map(dir => path.resolve(dir)))
).filter(dir => fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory());
}
function withTypstFontDirs(
request: TypstRenderRequest,
fontDirs: string[]
): TypstRenderRequest {
const nextOptions = request.options ? { ...request.options } : {};
if (!nextOptions.fontDirs?.length) {
nextOptions.fontDirs = fontDirs;
}
return { ...request, options: nextOptions };
}
const typstFontDirs = resolveTypstFontDirs();
export const previewHandlers = {
renderMermaidSvg: async (
request: MermaidRenderRequest
): Promise<MermaidRenderResult> => {
return renderMermaidSvg(request);
},
renderTypstSvg: async (
request: TypstRenderRequest
): Promise<TypstRenderResult> => {
return renderTypstSvg(withTypstFontDirs(request, typstFontDirs));
},
};

Some files were not shown because too many files have changed in this diff Show More