mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-03-24 16:18:39 +08:00
Compare commits
1 Commits
6987321864
...
renovate/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb538791e0 |
2
.github/deployment/node/Dockerfile
vendored
2
.github/deployment/node/Dockerfile
vendored
@@ -1,4 +1,4 @@
|
||||
# syntax=docker/dockerfile:1.22
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:22-bookworm-slim AS assets
|
||||
WORKDIR /app
|
||||
|
||||
4
.github/renovate.json
vendored
4
.github/renovate.json
vendored
@@ -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+)?)\""
|
||||
],
|
||||
|
||||
11
.github/workflows/build-test.yml
vendored
11
.github/workflows/build-test.yml
vendored
@@ -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()
|
||||
|
||||
2
.github/workflows/release-mobile.yml
vendored
2
.github/workflows/release-mobile.yml
vendored
@@ -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
942
.yarn/releases/yarn-4.12.0.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
940
.yarn/releases/yarn-4.13.0.cjs
vendored
940
.yarn/releases/yarn-4.13.0.cjs
vendored
File diff suppressed because one or more lines are too long
@@ -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
3303
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
54
Cargo.toml
54
Cargo.toml
@@ -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
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -1,14 +0,0 @@
|
||||
> [!custom] Panel
|
||||
> Body line
|
||||
|
||||
![[archive.zip]]
|
||||
|
||||
[^1]
|
||||
|
||||
---
|
||||
|
||||
after note
|
||||
|
||||
[[linked]]
|
||||
|
||||
[^1]: reference body
|
||||
@@ -1 +0,0 @@
|
||||
plain linked page
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"playwright": "=1.58.2",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"exports": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"playwright": "=1.58.2",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"exports": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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('/'));
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 ``;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"playwright": "=1.58.2",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"exports": {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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",
|
||||
|
||||
48
deny.toml
48
deny.toml
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
edition = "2024"
|
||||
license-file = "LICENSE"
|
||||
name = "affine_server_native"
|
||||
publish = false
|
||||
version = "1.0.0"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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));
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
edition = "2024"
|
||||
license-file = "LICENSE"
|
||||
name = "affine_common"
|
||||
publish = false
|
||||
version = "0.1.0"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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.*$/, '');
|
||||
|
||||
@@ -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": {
|
||||
|
||||
354
packages/common/y-octo/utils/fuzz/Cargo.lock
generated
354
packages/common/y-octo/utils/fuzz/Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }>;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { registerPlugin } from '@capacitor/core';
|
||||
|
||||
import type { PreviewPlugin } from './definitions';
|
||||
|
||||
const Preview = registerPlugin<PreviewPlugin>('Preview');
|
||||
|
||||
export * from './definitions';
|
||||
export { Preview };
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
@@ -1,18 +1,13 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { DocStorage, ValidationResult } from '@affine/native';
|
||||
import { DocStorage } from '@affine/native';
|
||||
import {
|
||||
parseUniversalId,
|
||||
universalId as generateUniversalId,
|
||||
} from '@affine/nbstore';
|
||||
import fs from 'fs-extra';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { applyUpdate, Doc as YDoc } from 'yjs';
|
||||
|
||||
import {
|
||||
normalizeWorkspaceIdForPath,
|
||||
resolveExistingPathInBase,
|
||||
} from '../../shared/utils';
|
||||
import { logger } from '../logger';
|
||||
import { getDocStoragePool } from '../nbstore';
|
||||
import { ensureSQLiteDisconnected } from '../nbstore/v1/ensure-db';
|
||||
@@ -23,7 +18,6 @@ import {
|
||||
getSpaceBasePath,
|
||||
getSpaceDBPath,
|
||||
getWorkspaceBasePathV1,
|
||||
getWorkspaceDBPath,
|
||||
getWorkspaceMeta,
|
||||
} from './meta';
|
||||
|
||||
@@ -64,7 +58,7 @@ export async function trashWorkspace(universalId: string) {
|
||||
|
||||
const dbPath = await getSpaceDBPath(peer, type, id);
|
||||
const basePath = await getDeletedWorkspacesBasePath();
|
||||
const movedPath = path.join(basePath, normalizeWorkspaceIdForPath(id));
|
||||
const movedPath = path.join(basePath, `${id}`);
|
||||
try {
|
||||
const storage = new DocStorage(dbPath);
|
||||
if (await storage.validate()) {
|
||||
@@ -264,88 +258,12 @@ export async function getDeletedWorkspaces() {
|
||||
};
|
||||
}
|
||||
|
||||
async function importLegacyWorkspaceDb(
|
||||
originalPath: string,
|
||||
workspaceId: string
|
||||
) {
|
||||
const { SqliteConnection } = await import('@affine/native');
|
||||
|
||||
const validationResult = await SqliteConnection.validate(originalPath);
|
||||
if (validationResult !== ValidationResult.Valid) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const connection = new SqliteConnection(originalPath);
|
||||
if (!(await connection.validateImportSchema())) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const internalFilePath = await getWorkspaceDBPath('workspace', workspaceId);
|
||||
await fs.ensureDir(path.parse(internalFilePath).dir);
|
||||
await connection.vacuumInto(internalFilePath);
|
||||
logger.info(
|
||||
`recoverBackupWorkspace, vacuum: ${originalPath} -> ${internalFilePath}`
|
||||
);
|
||||
|
||||
await storeWorkspaceMeta(workspaceId, {
|
||||
id: workspaceId,
|
||||
mainDBPath: internalFilePath,
|
||||
});
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
async function importWorkspaceDb(originalPath: string) {
|
||||
const workspaceId = nanoid(10);
|
||||
let storage = new DocStorage(originalPath);
|
||||
|
||||
if (!(await storage.validate())) {
|
||||
return await importLegacyWorkspaceDb(originalPath, workspaceId);
|
||||
}
|
||||
|
||||
if (!(await storage.validateImportSchema())) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const internalFilePath = await getSpaceDBPath(
|
||||
'local',
|
||||
'workspace',
|
||||
workspaceId
|
||||
);
|
||||
await fs.ensureDir(path.parse(internalFilePath).dir);
|
||||
await storage.vacuumInto(internalFilePath);
|
||||
logger.info(
|
||||
`recoverBackupWorkspace, vacuum: ${originalPath} -> ${internalFilePath}`
|
||||
);
|
||||
|
||||
storage = new DocStorage(internalFilePath);
|
||||
await storage.setSpaceId(workspaceId);
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteBackupWorkspace(id: string) {
|
||||
const basePath = await getDeletedWorkspacesBasePath();
|
||||
const workspacePath = path.join(basePath, normalizeWorkspaceIdForPath(id));
|
||||
const workspacePath = path.join(basePath, id);
|
||||
await fs.rmdir(workspacePath, { recursive: true });
|
||||
logger.info(
|
||||
'deleteBackupWorkspace',
|
||||
`Deleted backup workspace: ${workspacePath}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function recoverBackupWorkspace(id: string) {
|
||||
const basePath = await getDeletedWorkspacesBasePath();
|
||||
const workspacePath = path.join(basePath, normalizeWorkspaceIdForPath(id));
|
||||
const dbPath = await resolveExistingPathInBase(
|
||||
basePath,
|
||||
path.join(workspacePath, 'storage.db'),
|
||||
{ label: 'backup workspace filepath' }
|
||||
);
|
||||
|
||||
return await importWorkspaceDb(dbPath);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
deleteWorkspace,
|
||||
getDeletedWorkspaces,
|
||||
listLocalWorkspaceIds,
|
||||
recoverBackupWorkspace,
|
||||
trashWorkspace,
|
||||
} from './handlers';
|
||||
|
||||
@@ -20,6 +19,5 @@ export const workspaceHandlers = {
|
||||
return getDeletedWorkspaces();
|
||||
},
|
||||
deleteBackupWorkspace: async (id: string) => deleteBackupWorkspace(id),
|
||||
recoverBackupWorkspace: async (id: string) => recoverBackupWorkspace(id),
|
||||
listLocalWorkspaceIds: async () => listLocalWorkspaceIds(),
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from 'node:path';
|
||||
|
||||
import { type SpaceType } from '@affine/nbstore';
|
||||
|
||||
import { normalizeWorkspaceIdForPath } from '../../shared/utils';
|
||||
import { isWindows } from '../../shared/utils';
|
||||
import { mainRPC } from '../main-rpc';
|
||||
import type { WorkspaceMeta } from '../type';
|
||||
|
||||
@@ -24,11 +24,10 @@ export async function getWorkspaceBasePathV1(
|
||||
spaceType: SpaceType,
|
||||
workspaceId: string
|
||||
) {
|
||||
const safeWorkspaceId = normalizeWorkspaceIdForPath(workspaceId);
|
||||
return path.join(
|
||||
await getAppDataPath(),
|
||||
spaceType === 'userspace' ? 'userspaces' : 'workspaces',
|
||||
safeWorkspaceId
|
||||
isWindows() ? workspaceId.replace(':', '_') : workspaceId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,11 +52,10 @@ export async function getSpaceDBPath(
|
||||
spaceType: SpaceType,
|
||||
id: string
|
||||
) {
|
||||
const safeId = normalizeWorkspaceIdForPath(id);
|
||||
return path.join(
|
||||
await getSpaceBasePath(spaceType),
|
||||
escapeFilename(peer),
|
||||
safeId,
|
||||
id,
|
||||
'storage.db'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function createApplicationMenu() {
|
||||
click: async () => {
|
||||
await initAndShowMainWindow();
|
||||
// fixme: if the window is just created, the new page action will not be triggered
|
||||
applicationMenuSubjects.newPageAction$.next('default');
|
||||
applicationMenuSubjects.newPageAction$.next('page');
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MainEventRegister } from '../type';
|
||||
import { applicationMenuSubjects, type NewPageAction } from './subject';
|
||||
import { applicationMenuSubjects } from './subject';
|
||||
|
||||
export * from './create';
|
||||
export * from './subject';
|
||||
@@ -11,7 +11,7 @@ export const applicationMenuEvents = {
|
||||
/**
|
||||
* File -> New Doc
|
||||
*/
|
||||
onNewPageAction: (fn: (type: NewPageAction) => void) => {
|
||||
onNewPageAction: (fn: (type: 'page' | 'edgeless') => void) => {
|
||||
const sub = applicationMenuSubjects.newPageAction$.subscribe(fn);
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
export type NewPageAction = 'page' | 'edgeless' | 'default';
|
||||
|
||||
export const applicationMenuSubjects = {
|
||||
newPageAction$: new Subject<NewPageAction>(),
|
||||
newPageAction$: new Subject<'page' | 'edgeless'>(),
|
||||
openJournal$: new Subject<void>(),
|
||||
openInSettingModal$: new Subject<{
|
||||
activeTab: string;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export const mainHost = '.';
|
||||
export const anotherHost = 'another-host';
|
||||
export const internalHosts = new Set([mainHost, anotherHost]);
|
||||
|
||||
export const mainWindowOrigin = `assets://${mainHost}`;
|
||||
export const anotherOrigin = `assets://${anotherHost}`;
|
||||
@@ -14,12 +13,3 @@ export const customThemeViewUrl = `${mainWindowOrigin}/theme-editor`;
|
||||
// Notes from electron official docs:
|
||||
// "The zoom policy at the Chromium level is same-origin, meaning that the zoom level for a specific domain propagates across all instances of windows with the same domain. Differentiating the window URLs will make zoom work per-window."
|
||||
export const popupViewUrl = `${anotherOrigin}/popup.html`;
|
||||
|
||||
export const isInternalUrl = (url: string) => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'assets:' && internalHosts.has(parsed.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -9,7 +9,6 @@ import { beforeAppQuit } from './cleanup';
|
||||
import { logger } from './logger';
|
||||
import { powerEvents } from './power';
|
||||
import { recordingEvents } from './recording';
|
||||
import { checkSource } from './security-restrictions';
|
||||
import { sharedStorageEvents } from './shared-storage';
|
||||
import { uiEvents } from './ui/events';
|
||||
import { updaterEvents } from './updater/event';
|
||||
@@ -71,7 +70,7 @@ export function registerEvents() {
|
||||
action: 'subscribe' | 'unsubscribe',
|
||||
channel: string
|
||||
) => {
|
||||
if (!checkSource(event) || typeof channel !== 'string') return;
|
||||
if (typeof channel !== 'string') return;
|
||||
if (action === 'subscribe') {
|
||||
addSubscription(event.sender, channel);
|
||||
if (channel === 'power:power-source') {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { configStorageHandlers } from './config-storage';
|
||||
import { findInPageHandlers } from './find-in-page';
|
||||
import { getLogFilePath, logger, revealLogFile } from './logger';
|
||||
import { recordingHandlers } from './recording';
|
||||
import { checkSource } from './security-restrictions';
|
||||
import { sharedStorageHandlers } from './shared-storage';
|
||||
import { uiHandlers } from './ui/handlers';
|
||||
import { updaterHandlers } from './updater';
|
||||
@@ -50,7 +49,7 @@ export const registerHandlers = () => {
|
||||
...args: any[]
|
||||
) => {
|
||||
// args[0] is the `{namespace:key}`
|
||||
if (!checkSource(e) || typeof args[0] !== 'string') {
|
||||
if (typeof args[0] !== 'string') {
|
||||
logger.error('invalid ipc message', args);
|
||||
return;
|
||||
}
|
||||
@@ -98,8 +97,6 @@ export const registerHandlers = () => {
|
||||
});
|
||||
|
||||
ipcMain.on(AFFINE_API_CHANNEL_NAME, (e, ...args: any[]) => {
|
||||
if (!checkSource(e)) return;
|
||||
|
||||
handleIpcMessage(e, ...args)
|
||||
.then(ret => {
|
||||
e.returnValue = ret;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import './security-restrictions';
|
||||
|
||||
import path from 'node:path';
|
||||
|
||||
import * as Sentry from '@sentry/electron/main';
|
||||
@@ -13,7 +15,6 @@ import { registerHandlers } from './handlers';
|
||||
import { logger } from './logger';
|
||||
import { registerProtocol } from './protocol';
|
||||
import { setupRecordingFeature } from './recording/feature';
|
||||
import { registerSecurityRestrictions } from './security-restrictions';
|
||||
import { setupTrayState } from './tray';
|
||||
import { registerUpdater } from './updater';
|
||||
import { launch } from './windows-manager/launcher';
|
||||
@@ -104,7 +105,6 @@ app.on('activate', () => {
|
||||
});
|
||||
|
||||
setupDeepLink(app);
|
||||
registerSecurityRestrictions();
|
||||
|
||||
/**
|
||||
* Create app window when background process will be ready
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user