Compare commits

...

6 Commits

Author SHA1 Message Date
renovate[bot]
f5290f3d2a chore: bump up all non-major npm dependencies 2026-05-07 08:12:06 +00:00
DarkSky
5813e7dd77 chore: update i18n 2026-05-07 11:32:55 +08:00
karl-kaefer
ac37d07e74 feat(editor): add Bear backup import and markdown zip folder hierarchy (#14599)
## Summary

- Add Bear `.bear2bk` backup importer (TextBundle-based zip format)
- Enhance markdown zip import to preserve folder structure from zip
paths
- Add colored highlight (`<mark data-color="...">`) support to HTML
adapter

### Bear Import Details

Bear backups are zip archives of TextBundle directories. The importer:
- Parses Bear-specific markdown (highlights `==text==`, callouts `>
[!NOTE]`, inline tags `#tag`)
- Extracts creation/modification dates from `info.json` metadata
- Filters out trashed notes
- Converts Bear tags to AFFiNE tags (consolidated by root segment)
- Builds folder hierarchy from nested tag paths (e.g.,
`#work/projects/alpha`)
- Uses JSZip for lazy decompression to handle large backups without OOM

### Markdown Zip Folder Hierarchy

`importMarkdownZip` now returns `{ docIds, folderHierarchy }` instead of
just `docIds[]`, enabling the UI to recreate the zip's directory
structure as AFFiNE folders.

## Related Issues

- Implements the TextBundle-based import approach suggested in #14115 /
Discussion #14142
- Addresses folder structure preservation requested in #10003
- Partially addresses frontmatter metadata import from #11286

## Test Plan

- [ ] Import a Bear `.bear2bk` backup file via the import dialog
- [ ] Verify tags are created and assigned to documents
- [ ] Verify folder hierarchy matches Bear's nested tag structure
- [ ] Verify creation/modification dates are preserved
- [ ] Verify highlighted text and callouts render correctly
- [ ] Verify images and attachments are imported
- [ ] Import a markdown zip with nested folders, verify folder structure
is recreated
- [ ] Verify trashed Bear notes are excluded

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Bear (.bear2bk) backup import: bulk import notes, convert/dedupe tags,
create nested folders, and return imported doc IDs plus folder
hierarchy; UI import option and progress integrated.
* Markdown ZIP import now returns an optional folder hierarchy alongside
created doc IDs.

* **Bug Fixes / Improvements**
* Highlighting: mark elements validate color names, default safely, and
apply consistent background styling.

* **Chores**
  * Added runtime dependency for ZIP handling.

* **Documentation**
  * Added localization strings and i18n accessors for Bear import UI.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
2026-05-07 11:29:40 +08:00
renovate[bot]
429e7f495d chore: bump up link-preview-js version to v4.0.1 [SECURITY] (#14917)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[link-preview-js](https://redirect.github.com/OP-Engineering/link-preview-js)
| [`4.0.0` →
`4.0.1`](https://renovatebot.com/diffs/npm/link-preview-js/4.0.0/4.0.1)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/link-preview-js/4.0.1?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/link-preview-js/4.0.0/4.0.1?slim=true)
|

---

### link-preview-js vulnerable to IPv6 and internal loopback attacks
[CVE-2026-43897](https://nvd.nist.gov/vuln/detail/CVE-2026-43897) /
[GHSA-4gp8-rjrq-ch6q](https://redirect.github.com/advisories/GHSA-4gp8-rjrq-ch6q)

<details>
<summary>More information</summary>

#### Details
##### Impact
The library did not check for IPv6 loopback attacks. There was also a
DNS attack, where an address could be resolved into an internal IP. This
could cause internal data leaks.

##### Patches
Problem has been patched in version 4.0.1. However, it cannot be
completely solved by the package alone. The regex used for validation
has been tightened for IPv6 addresses.

The DNS resolving, however, is more difficult. The regex has been
tightened to prohibit .internal, .local, .nip.io and .sslip.io
addresses, however there can be other services not on the list,
therefore it is imperative that users use the resolveDNSHost option to
do DNS resolution before fetching content. To that regard a (scary)
error message has been added when the option is not set.

##### Workarounds
Users can do their own validation before fetching content.

Reported by https://github.com/Andrew-most-likely

#### Severity
- CVSS Score: 8.7 / 10 (High)
- Vector String:
`CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N`

#### References
-
[https://github.com/OP-Engineering/link-preview-js/security/advisories/GHSA-4gp8-rjrq-ch6q](https://redirect.github.com/OP-Engineering/link-preview-js/security/advisories/GHSA-4gp8-rjrq-ch6q)
-
[https://github.com/OP-Engineering/link-preview-js/pull/179](https://redirect.github.com/OP-Engineering/link-preview-js/pull/179)
-
[4396d48909)
-
[https://github.com/OP-Engineering/link-preview-js/releases/tag/4.0.1](https://redirect.github.com/OP-Engineering/link-preview-js/releases/tag/4.0.1)
-
[https://github.com/advisories/GHSA-4gp8-rjrq-ch6q](https://redirect.github.com/advisories/GHSA-4gp8-rjrq-ch6q)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-4gp8-rjrq-ch6q)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Release Notes

<details>
<summary>OP-Engineering/link-preview-js (link-preview-js)</summary>

###
[`v4.0.1`](https://redirect.github.com/OP-Engineering/link-preview-js/releases/tag/4.0.1)

[Compare
Source](https://redirect.github.com/OP-Engineering/link-preview-js/compare/4.0.0...4.0.1)

#### What's Changed

- Loopback fixes by
[@&#8203;ospfranco](https://redirect.github.com/ospfranco) in
[#&#8203;179](https://redirect.github.com/OP-Engineering/link-preview-js/pull/179)

**Full Changelog**:
<https://github.com/OP-Engineering/link-preview-js/compare/4.0.0...4.0.1>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - ""
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNTkuMiIsInVwZGF0ZWRJblZlciI6IjQzLjE1OS4yIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-07 11:29:35 +08:00
Davide Conte
339f89220a fix(core): prevent navigation panel from reordering while typing (#14831) 2026-05-07 11:28:19 +08:00
Adarsh Singh
440ff0c342 fix(editor): resolve UX inconsistencies in the AI chat interface (#14850)
# Closes #14189.

Fixes the three UX issues reported in the original bug report, plus one
small
adjacent polish on the right-sidebar toggle that was requested during
review.

Each concern in the issue is addressed end-to-end, with the same
treatment
applied to both places the AI chat panel lives: the **sidebar chat
panel**
(right panel on a doc page) and the **standalone `/chat` page**.

---

## 1. `+` button → persistent multi-session tabs (issue point 1)

**Before:** clicking `+` called `createFreshSession()` (standalone) or
`newSession()` (sidebar), both of which tore down the current chat
content
and replaced it in place. There was no way to keep two chats open at
once.

**After:** a browser/IDE-style tab strip lives above the chat content.
Each
open session gets its own tab with a close `×`; the active tab is
highlighted; `+` now adds a tab rather than replacing the chat.

### Details
- New Lit component `ai-chat-tabs`
([packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-chat-tabs.ts](packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-chat-tabs.ts)).
- Tab title is derived from `session.title` → first user message → `"New
chat"`.
- Horizontal scroll when tabs overflow, with a `wheel` handler that
converts
    mouse wheel / trackpad vertical swipe into horizontal scroll (native
horizontal trackpad swipes also work natively via `overflow-x: auto`).
- Auto `scrollIntoView({ inline: 'nearest' })` on active tab change, so
a
newly created or newly selected tab slides into view instead of staying
    hidden behind the toolbar.
- Close `×` removes the tab from the strip but leaves the session on the
server (matches the existing **Chat history** dropdown semantics — the
session is still reachable there). Closing the active tab switches to an
    adjacent one; closing the last tab starts a fresh session.
- Persistence: open session IDs are saved per-workspace in
`localStorage`
under `ai-chat-open-tabs:{workspaceId}`. On mount, the React pages
hydrate
  those IDs via `AIProvider.session.getSession` /
  `CopilotClient.getSession` — no new backend or schema work.
- Wiring: identical effects on both variants
([chat.tsx
(sidebar)](packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx)
and
[chat/index.tsx
(standalone)](packages/frontend/core/src/desktop/pages/workspace/chat/index.tsx))
  — hydrate → sync active session into tabs → persist.
- The tab strip sits on the same row as the existing toolbar icons
  (pin / history / `+`), separated by `flex: 1` + `min-width: 0` so the
  tabs scroll cleanly up to the toolbar boundary.
- The `ShadowlessElement` base class injects its static CSS globally,
and the
`:host` selector does not match in a React-rooted DOM — the component
uses
  tag-selector CSS (`ai-chat-tabs { display: flex; … }`) instead.

## 2. Drag-and-drop attachments (issue point 2)

**Before:** the chat input accepted no DnD. Attaching anything required
the
`+` → file-picker flow.

**After:** the chat input accepts OS files via native HTML5 DnD and
AFFiNE
documents via the repo's existing pragmatic-drag-and-drop
infrastructure.

### Details
- Native handlers (`dragenter/over/leave/drop`) on

[ai-chat-input.ts](packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts)
accept OS files: images go into the image preview grid, other files
become
  attachment chips, with the same 50 MB per-file cap as the `+` picker.
- Internal AFFiNE document drags from the nav panel land as doc chips,
  handled via `dropTargetForElements` from
  `@atlaskit/pragmatic-drag-and-drop` (same library the rest of the app
  already uses for internal DnD).
- A "Drop to attach" overlay appears during drag, reusing the existing
focused-border token (`--affine-v2-layer-insideBorder-primaryBorder`)
for
  visual consistency with the focused state.
- The image/file routing logic that previously lived inline in
  `add-popover.ts` was factored into a shared helper

[attachment-utils.ts](packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/attachment-utils.ts)
  (`addFilesToChat`), so the `+` picker and the drop handler stay in
  lockstep.
- Analytics: extended the `addEmbeddingDoc.control` union in
[events.ts](packages/frontend/track/src/events.ts) with `'dragDrop'` so
  drag-originated attachments are distinguishable from button-initiated
  ones in telemetry.
- `@atlaskit/pragmatic-drag-and-drop` is promoted from a transitive
  dependency (via `@affine/component`) to a direct dependency of
  `@affine/core` and `yarn.lock` is refreshed accordingly.

## 3. Chat-history tooltip + icon (issue point 3)

**Before:** hovering the chat-history button showed a tooltip whose
background did not invert for dark theme (`--affine-tooltip` is not
theme-aware), and the icon was `ArrowDownSmallIcon` — a chevron that
does
not convey "history."

**After:** the tooltip primitive itself is theme-aware (every tooltip in
the app benefits, not just the chat one), and the icon is the
semantically-clear `HistoryIcon`.

### Details
- [tooltip.ts](blocksuite/affine/components/src/tooltip/tooltip.ts) now
uses
  `var(--affine-v2-tooltips-background, var(--affine-tooltip))` and
  `var(--affine-v2-tooltips-foreground, var(--affine-white))`. The V2
  tokens auto-invert with theme; the old vars remain as fallbacks so
  components that override via the existing `tooltipStyle` escape hatch
  continue to work.
- Triangle arrow colors updated to use the same V2 token.
-
[ai-chat-toolbar.ts](packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-chat-toolbar.ts):
  `ArrowDownSmallIcon` → `HistoryIcon`; added
  `data-testid="ai-panel-chat-history"` for future e2e coverage.

## 4. Right-sidebar toggle: tooltips + open-state icon *(adjacent
polish)*

Not part of the original issue, but surfaced while testing the tab strip
—
neither of the two right-sidebar toggle buttons had hover affordance,
and
both used the same icon regardless of the sidebar's state.

- Added `tooltip="Open sidebar"` on the route-container button shown
when
  the sidebar is hidden.
- Added `tooltip="Close sidebar"` on the sidebar-header button shown
when
  the sidebar is expanded.
- The close button now renders a small inline `RightSidebarOpenIcon`
  variant: same outline as `RightSidebarIcon`, but with the right panel
  filled in the AFFiNE accent color to convey the open state. Icon shape
  change is self-contained — no new icon asset added to
  `@blocksuite/icons`.

---

## Commits

- `2adc0c7` — fix(ai-chat): theme-aware tooltip + semantic chat-history
icon *(2 files)*
- `bf26974` — feat(ai-chat): drag-and-drop file and doc attachments in
chat input *(7 files)*
- `fca29c8` — feat(ai-chat): persistent multi-session tab strip *(8
files)*
- `7d5dffe` — feat(workbench): tooltips and open-state icon for the
right-sidebar toggle *(2 files)*

Kept ordered smallest → largest blast radius so the history is easy to
bisect.

---

## Test plan

Verified locally against a fresh server stack (postgres / redis /
mailpit via
compose, migrations run) signed in as `dev@affine.pro`, in both `/chat`
and
the sidebar chat on a doc page, in light and dark themes:

- [x] Tooltip: hover the chat-history icon in dark mode → tooltip is
dark-on-light; toggle to light mode → tooltip is light-on-dark. Existing
tooltips on other surfaces (slash menu, edgeless, linked-doc) still
render correctly.
- [x] Icon: chat-history button renders the history glyph (clock), not a
chevron.
- [x] Drag-and-drop (OS file): drop a PDF / PNG / TXT onto the input →
overlay shows → chips/images appear; file > 50 MB → rejected silently
(same as `+` picker).
- [x] Drag-and-drop (internal doc): drag an AFFiNE doc from the nav
panel → becomes a doc chip.
- [x] Pin-picker, `+` picker, paste-image — all unchanged.
- [x] Tab strip: first chat auto-becomes a tab on first message; `+`
adds tab; click tab switches chat; `×` removes tab and switches to
adjacent; close last tab → new fresh tab spawns.
- [x] Reload browser → tab strip rehydrates from localStorage with the
same sessions.
- [x] Tab overflow: 12+ tabs → horizontal scroll via trackpad vertical
swipe, trackpad horizontal swipe, and mouse wheel; active tab
auto-scrolls into view on `+` click.
- [x] Right-sidebar: hover both toggle buttons → tooltips appear; open
the sidebar → close button shows the filled right-panel icon.
- [x] `yarn lint:ox` and lint-staged both clean on every commit.

Not verified locally (no local model key configured): the assistant
actually
streams a response. Drop/chip flow is independent of that path.

## Out of scope / follow-ups

- No new unit or Playwright tests — the fixes are visually verifiable
and
  reuse existing reducer / state paths. Happy to add tests if reviewers
  prefer.
- `@affine/native` is not required for the web dev stack; I only built
  `@affine/server-native`. Irrelevant to the PR diff.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Multi-tab chat UI with a tabs component, open/close/switch actions,
and per-workspace persistence/restoration.
  * Drag-and-drop attachments into chat input (files and docs).

* **UI/UX**
  * Tooltip theming moved to v2 variables (includes arrow color).
  * Sidebar toggle/close buttons now show tooltips.
  * “Drop to attach” overlay and updated history icon.

* **Behavior**
  * Unified attachment handling with 50MB validation and toast notices.

* **Analytics**
  * Attachment events record drag-and-drop as a control method.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
2026-05-07 04:04:43 +08:00
63 changed files with 6245 additions and 5508 deletions

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

1085
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,15 +30,15 @@ resolver = "3"
chrono = "0.4"
clap = { version = "4.4", features = ["derive"] }
core-foundation = "0.10"
coreaudio-rs = "0.12"
cpal = "0.15"
criterion = { version = "0.5", features = ["html_reports"] }
coreaudio-rs = "0.14"
cpal = "0.17"
criterion = { version = "0.8", 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" }
dotenvy = "0.15"
file-format = { version = "0.28", features = ["reader"] }
file-format = { version = "0.29", features = ["reader"] }
homedir = "0.3"
image = { version = "0.25.9", default-features = false, features = [
"bmp",
@@ -57,13 +57,13 @@ resolver = "3"
llm_runtime = { version = "0.2", default-features = false }
log = "0.4"
loom = { version = "0.7", features = ["checkpoint"] }
lru = "0.16"
lru = "0.18"
matroska = "0.30"
memory-indexer = "0.3.1"
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"
nanoid = "0.5"
napi = { version = "3.7.0", features = [
"async",
"chrono_date",
@@ -83,22 +83,22 @@ resolver = "3"
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.11", features = ["macros"] }
phf = { version = "0.13", features = ["macros"] }
proptest = "1.3"
proptest-derive = "0.5"
proptest-derive = "0.8"
pulldown-cmark = "0.13"
rand = "0.9"
rand_chacha = "0.9"
rand_distr = "0.5"
rand = "0.10"
rand_chacha = "0.10"
rand_distr = "0.6"
rayon = "1.10"
readability = { version = "0.3.0", default-features = false }
regex = "1.10"
rubato = "0.16"
schemars = "0.8"
screencapturekit = "0.3"
schemars = "0.9"
screencapturekit = "0.4"
serde = "1"
serde_json = "1"
sha3 = "0.10"
sha3 = "0.11"
smol_str = "0.3"
sqlx = { version = "0.8", default-features = false, features = [
"chrono",
@@ -108,23 +108,23 @@ resolver = "3"
"sqlite",
"tls-rustls",
] }
strum_macros = "0.27.0"
strum_macros = "0.28.0"
symphonia = { version = "0.5", features = ["all", "opt-simd"] }
text-splitter = "0.27"
text-splitter = "0.30"
thiserror = "2"
tiktoken-rs = "0.7"
tiktoken-rs = "0.11"
tokio = "1.45"
tree-sitter = { version = "0.25" }
tree-sitter = { version = "0.26" }
tree-sitter-c = { version = "0.24" }
tree-sitter-c-sharp = { version = "0.23" }
tree-sitter-cpp = { version = "0.23" }
tree-sitter-go = { version = "0.23" }
tree-sitter-go = { version = "0.25" }
tree-sitter-java = { version = "0.23" }
tree-sitter-javascript = { version = "0.23" }
tree-sitter-javascript = { version = "0.25" }
tree-sitter-kotlin-ng = { version = "1.1" }
tree-sitter-python = { version = "0.23" }
tree-sitter-python = { version = "0.25" }
tree-sitter-rust = { version = "0.24" }
tree-sitter-scala = { version = "0.24" }
tree-sitter-scala = { version = "0.26" }
tree-sitter-typescript = { version = "0.23" }
typst = "0.14.2"
typst-as-lib = { version = "0.15.4", default-features = false, features = [
@@ -134,11 +134,11 @@ resolver = "3"
"ureq",
] }
typst-svg = "0.14.2"
uniffi = "0.29"
uniffi = "0.31"
url = { version = "2.5" }
uuid = "1.8"
v_htmlescape = "0.15"
windows = { version = "0.61", features = [
v_htmlescape = "0.17"
windows = { version = "0.62", features = [
"Win32_Devices_FunctionDiscovery",
"Win32_Foundation",
"Win32_Media_Audio",
@@ -150,10 +150,10 @@ resolver = "3"
"Win32_System_Variant",
"Win32_UI_Shell_PropertiesSystem",
] }
windows-core = { version = "0.61" }
windows-core = { version = "0.62" }
y-octo = { path = "./packages/common/y-octo/core" }
y-sync = { version = "0.4" }
yrs = "0.23.0"
yrs = "0.26.0"
[profile.dev.package.sqlx-macros]
opt-level = 3

View File

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

View File

@@ -24,8 +24,8 @@ const styles = css`
font-size: var(--affine-font-sm);
border-radius: 4px;
padding: 6px 12px;
color: var(--affine-white);
background: var(--affine-tooltip);
color: var(--affine-v2-tooltips-foreground, var(--affine-white));
background: var(--affine-v2-tooltips-background, var(--affine-tooltip));
overflow-wrap: anywhere;
white-space: normal;
@@ -40,6 +40,9 @@ const styles = css`
}
`;
const TOOLTIP_ARROW_COLOR =
'var(--affine-v2-tooltips-background, var(--affine-tooltip))';
// See http://apps.eky.hk/css-triangle-generator/
const TRIANGLE_HEIGHT = 6;
const triangleMap = {
@@ -47,25 +50,25 @@ const triangleMap = {
bottom: '-6px',
borderStyle: 'solid',
borderWidth: '6px 5px 0 5px',
borderColor: 'var(--affine-tooltip) transparent transparent transparent',
borderColor: `${TOOLTIP_ARROW_COLOR} transparent transparent transparent`,
},
right: {
left: '-6px',
borderStyle: 'solid',
borderWidth: '5px 6px 5px 0',
borderColor: 'transparent var(--affine-tooltip) transparent transparent',
borderColor: `transparent ${TOOLTIP_ARROW_COLOR} transparent transparent`,
},
bottom: {
top: '-6px',
borderStyle: 'solid',
borderWidth: '0 5px 6px 5px',
borderColor: 'transparent transparent var(--affine-tooltip) transparent',
borderColor: `transparent transparent ${TOOLTIP_ARROW_COLOR} transparent`,
},
left: {
right: '-6px',
borderStyle: 'solid',
borderWidth: '5px 0 5px 6px',
borderColor: 'transparent transparent transparent var(--affine-tooltip)',
borderColor: `transparent transparent transparent ${TOOLTIP_ARROW_COLOR}`,
},
};

View File

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

View File

@@ -320,9 +320,21 @@ export const htmlMarkElementToDeltaMatcher = HtmlASTToDeltaExtension({
if (!isElement(ast)) {
return [];
}
const dataColor =
typeof ast.properties?.dataColor === 'string'
? ast.properties.dataColor
: '';
const colorName =
dataColor &&
/^(red|orange|yellow|green|teal|blue|purple|grey)$/.test(dataColor)
? dataColor
: 'yellow';
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false }).map(delta => {
delta.attributes = { ...delta.attributes };
delta.attributes = {
...delta.attributes,
background: `var(--affine-text-highlight-${colorName})`,
};
return delta;
})
);

View File

@@ -38,7 +38,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.2.20",
"pdfmake": "^0.3.0",
"quick-lru": "^7.3.0",
"rehype-parse": "^9.0.0",
"rehype-stringify": "^10.0.0",
@@ -72,7 +72,7 @@
"!dist/__tests__"
],
"devDependencies": {
"@types/pdfmake": "^0.2.12",
"@types/pdfmake": "^0.3.0",
"vitest": "^4.0.18"
},
"version": "0.26.3"

View File

@@ -25,6 +25,7 @@
"@types/lodash-es": "^4.17.12",
"fflate": "^0.8.2",
"js-yaml": "^4.1.1",
"jszip": "^3.10.1",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"mammoth": "^1.11.0",

View File

@@ -0,0 +1,531 @@
import {
defaultImageProxyMiddleware,
docLinkBaseURLMiddleware,
fileNameMiddleware,
filePathMiddleware,
MarkdownAdapter,
} from '@blocksuite/affine-shared/adapters';
import { Container } from '@blocksuite/global/di';
import { sha } from '@blocksuite/global/utils';
import type { ExtensionType, Schema, Workspace } from '@blocksuite/store';
import { extMimeMap, Transformer } from '@blocksuite/store';
import JSZip from 'jszip';
import { createCollectionDocCRUD } from './markdown.js';
/** Recursive tree node representing a tag-based folder hierarchy. */
type FolderHierarchy = {
name: string;
path: string;
children: Map<string, FolderHierarchy>;
pageId?: string;
parentPath?: string;
};
type BearImportOptions = {
collection: Workspace;
schema: Schema;
imported: Blob;
extensions: ExtensionType[];
};
type BearImportResult = {
docIds: string[];
tags: Map<string, string[]>;
folderHierarchy: FolderHierarchy;
};
type BundleEntry = {
bundlePath: string;
markdownPath: string | null;
infoJsonPath: string | null;
assetPaths: string[];
};
/** Create a DI provider from the given extensions. */
function getProvider(extensions: ExtensionType[]) {
const container = new Container();
extensions.forEach(ext => {
ext.setup(container);
});
return container.provider();
}
/**
* Extract Bear tags from the trailing footer of a markdown document.
* Bear places tags (e.g. `#tag`, `#multi word tag#`, `#nested/tag`) at the end
* of notes. This scans from the bottom up, collecting tag-only lines (up to 5)
* and returns the deduplicated tags plus the content with those lines removed.
*/
function parseBearTags(markdown: string): {
tags: string[];
content: string;
} {
const lines = markdown.split('\n');
const codeFenceState: boolean[] = [];
let inCodeBlock = false;
for (const line of lines) {
if (line.trimStart().startsWith('```')) {
inCodeBlock = !inCodeBlock;
}
codeFenceState.push(inCodeBlock);
}
const tags: string[] = [];
const tagLineIndices = new Set<number>();
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim();
if (!line) continue;
if (codeFenceState[i]) break;
const lineTags = extractTagsFromLine(line);
if (lineTags.length > 0) {
for (const tag of lineTags) {
tags.push(tag);
}
tagLineIndices.add(i);
} else {
break;
}
if (tagLineIndices.size >= 5) break;
}
const filteredLines = lines.filter((_, i) => !tagLineIndices.has(i));
while (
filteredLines.length > 0 &&
filteredLines[filteredLines.length - 1].trim() === ''
) {
filteredLines.pop();
}
return {
tags: deduplicateTags(tags),
content: filteredLines.join('\n'),
};
}
/**
* Parse Bear tags from a single line. Supports open tags (`#tag`),
* closed tags (`#multi word tag#`), and nested tags (`#parent/child`).
* Returns an empty array if the line contains non-tag content.
*/
function extractTagsFromLine(line: string): string[] {
const tags: string[] = [];
let remaining = line;
while (remaining.length > 0) {
remaining = remaining.trimStart();
if (!remaining) break;
if (remaining.startsWith('[')) return [];
if (remaining.startsWith('#')) {
if (remaining.length > 1 && remaining[1] === ' ') return [];
if (remaining.length > 2 && remaining[1] === '#') return [];
const closedMatch = remaining.match(/^#([^#\n]+)#/);
if (closedMatch) {
const tagValue = closedMatch[1].trim();
if (tagValue) {
tags.push(tagValue);
remaining = remaining.slice(closedMatch[0].length);
continue;
}
}
const openMatch = remaining.match(
/^#([\p{L}\p{N}_][\p{L}\p{N}_/-]*)(.*)$/u
);
if (openMatch) {
const tagValue = openMatch[1];
const after = openMatch[2].trim();
if (tagValue) {
tags.push(tagValue);
remaining = after;
continue;
}
}
return [];
} else {
return [];
}
}
return tags;
}
/**
* Deduplicate tags case-insensitively while preserving the original
* capitalization of the first occurrence of each tag.
*/
function deduplicateTags(tags: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const tag of tags) {
const normalized = tag.toLowerCase();
if (!seen.has(normalized)) {
seen.add(normalized);
result.push(tag);
}
}
return result;
}
/**
* Build a nested folder hierarchy from Bear tags.
* Tags like `parent/child` create nested folders. Documents are attached
* as leaf nodes under their tag's folder using `__doc__` prefixed keys.
*/
function buildFolderHierarchyFromTags(
tagDocMap: Map<string, string[]>
): FolderHierarchy {
const root: FolderHierarchy = {
name: '',
path: '',
children: new Map(),
};
for (const [tag, docIds] of tagDocMap) {
const parts = tag.split('/');
let current = root;
let currentPath = '';
for (const part of parts) {
const parentPath = currentPath;
currentPath = currentPath ? `${currentPath}/${part}` : part;
if (!current.children.has(part)) {
current.children.set(part, {
name: part,
path: currentPath,
parentPath: parentPath || undefined,
children: new Map(),
});
}
current = current.children.get(part)!;
}
for (const docId of docIds) {
const docNodeKey = `__doc__${docId}`;
if (!current.children.has(docNodeKey)) {
current.children.set(docNodeKey, {
name: docNodeKey,
path: `${current.path}/${docNodeKey}`,
parentPath: current.path,
children: new Map(),
pageId: docId,
});
}
}
}
return root;
}
const GFM_CALLOUT_MAP: Record<string, string> = {
IMPORTANT: '\u26A0',
NOTE: '\uD83D\uDCDD',
WARNING: '\u26A0',
TIP: '\uD83D\uDCA1',
CAUTION: '\uD83D\uDD34',
};
/**
* Convert GFM-style callouts (`> [!NOTE]`, `> [!WARNING]`, etc.) to
* emoji-based callouts that AFFiNE's remark-callout plugin understands.
* Skips content inside fenced code blocks.
*/
function convertGfmCallouts(markdown: string): string {
const lines = markdown.split('\n');
let inCodeBlock = false;
for (let i = 0; i < lines.length; i++) {
if (lines[i].trimStart().startsWith('```')) {
inCodeBlock = !inCodeBlock;
continue;
}
if (!inCodeBlock) {
lines[i] = lines[i].replace(
/^(>\s*)\[!(\w+)\]/,
(_match, prefix: string, type: string) => {
const emoji = GFM_CALLOUT_MAP[type.toUpperCase()];
return emoji ? `${prefix}[!${emoji}]` : _match;
}
);
}
}
return lines.join('\n');
}
const HIGHLIGHT_COLOR_MAP: Record<string, string> = {
'\uD83D\uDFE2': 'green',
'\uD83D\uDD35': 'blue',
'\uD83D\uDFE3': 'purple',
'\uD83D\uDD34': 'red',
'\uD83D\uDFE1': 'yellow',
'\uD83D\uDFE0': 'orange',
};
/** Escape HTML special characters to prevent markup injection. */
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Convert Bear `==highlight==` syntax to `<mark>` HTML elements.
* Supports colored highlights via leading color emoji (e.g. `==🟢green text==`).
* Skips content inside fenced code blocks.
*/
function convertHighlights(markdown: string): string {
const lines = markdown.split('\n');
let inCodeBlock = false;
for (let i = 0; i < lines.length; i++) {
if (lines[i].trimStart().startsWith('```')) {
inCodeBlock = !inCodeBlock;
continue;
}
if (!inCodeBlock) {
lines[i] = lines[i].replace(
/==(\S(?:[^=]|=[^=])*?)==/g,
(_match, content: string) => {
const firstChar = String.fromCodePoint(content.codePointAt(0)!);
const color = HIGHLIGHT_COLOR_MAP[firstChar];
if (color) {
const text = content.slice(firstChar.length);
return `<mark data-color="${color}">${escapeHtml(text)}</mark>`;
}
return `<mark>${escapeHtml(content)}</mark>`;
}
);
}
}
return lines.join('\n');
}
/** Extract the document title from the first `# heading` or fall back to the bundle name. */
function extractTitle(markdown: string, bundleName: string): string {
const lines = markdown.split('\n');
let inCodeBlock = false;
for (const line of lines) {
if (line.trimStart().startsWith('```')) {
inCodeBlock = !inCodeBlock;
continue;
}
if (inCodeBlock) continue;
const match = line.match(/^#\s+(.+)/);
if (match) {
const title = match[1].trim();
if (title) return title;
}
}
return bundleName.replace(/\.textbundle$/i, '') || 'Untitled';
}
/**
* Import a Bear .bear2bk backup file.
* Uses JSZip for lazy/streaming decompression to handle large backups.
*/
async function importBearBackup({
collection,
schema,
imported,
extensions,
}: BearImportOptions): Promise<BearImportResult> {
const provider = getProvider(extensions);
// JSZip reads the zip directory without decompressing all entries
const zip = await JSZip.loadAsync(imported);
// Scan entries and group by textbundle
const bundleMap = new Map<string, BundleEntry>();
zip.forEach((path, _entry) => {
if (path.includes('__MACOSX') || path.includes('.DS_Store')) return;
const tbMatch = path.match(/^(.+?\.textbundle)\/(.*)/i);
if (!tbMatch) return;
const bundlePath = tbMatch[1];
const innerPath = tbMatch[2];
if (!bundleMap.has(bundlePath)) {
bundleMap.set(bundlePath, {
bundlePath,
markdownPath: null,
infoJsonPath: null,
assetPaths: [],
});
}
const bundle = bundleMap.get(bundlePath)!;
if (innerPath === 'text.md' || innerPath === 'text.txt') {
bundle.markdownPath = path;
} else if (innerPath === 'info.json') {
bundle.infoJsonPath = path;
} else if (innerPath.startsWith('assets/') && innerPath !== 'assets/') {
bundle.assetPaths.push(path);
}
});
// Read info.json for all bundles to filter out trashed notes
// (info.json is tiny, safe to read all at once)
const validBundles: Array<{
entry: BundleEntry;
bearMeta: Record<string, unknown> | undefined;
}> = [];
for (const entry of bundleMap.values()) {
if (!entry.markdownPath) continue;
let info: Record<string, unknown> = {};
if (entry.infoJsonPath) {
try {
const text = await zip.file(entry.infoJsonPath)!.async('string');
info = JSON.parse(text);
} catch {
// Invalid JSON
}
}
const bearMeta = info['net.shinyfrog.bear'] as
| Record<string, unknown>
| undefined;
if (bearMeta?.trashed === 1) continue;
validBundles.push({ entry, bearMeta });
}
if (validBundles.length === 0) {
throw new Error(
'No valid Bear textbundles found in the archive. Please select a .bear2bk backup file.'
);
}
const docIds: string[] = [];
const tagDocMap = new Map<string, string[]>();
// Process bundles sequentially to limit memory.
// Each bundle is wrapped in try/catch so one bad note does not abort the
// entire import after earlier notes have already been written.
for (const { entry, bearMeta } of validBundles) {
try {
// Read markdown (decompress on demand)
const rawMarkdown = await zip.file(entry.markdownPath!)!.async('string');
if (!rawMarkdown.trim()) continue;
const { tags, content: cleanedMarkdown } = parseBearTags(rawMarkdown);
const bundleDirName =
entry.bundlePath.split('/').findLast(Boolean) ?? 'Untitled';
const title = extractTitle(cleanedMarkdown, bundleDirName);
const markdown = convertHighlights(
convertGfmCallouts(
cleanedMarkdown.replace(/<!--\s*\{[^}]*\}\s*-->/g, '')
)
);
// Read assets on demand (decompress only this bundle's assets)
const pendingAssets = new Map<string, File>();
const pendingPathBlobIdMap = new Map<string, string>();
for (const assetFullPath of entry.assetPaths) {
try {
const data = await zip.file(assetFullPath)!.async('arraybuffer');
const tbMatch = assetFullPath.match(/^.+?\.textbundle\/(.*)/i);
const assetRelPath = tbMatch ? tbMatch[1] : assetFullPath;
const ext = assetRelPath.split('.').at(-1) ?? '';
const mime = extMimeMap.get(ext.toLowerCase()) ?? '';
const key = await sha(data);
// Map both the full zip path and the relative path (assets/...)
pendingPathBlobIdMap.set(assetFullPath, key);
pendingPathBlobIdMap.set(assetRelPath, key);
try {
const decodedRel = decodeURIComponent(assetRelPath);
if (decodedRel !== assetRelPath) {
pendingPathBlobIdMap.set(decodedRel, key);
}
const decodedFull = decodeURIComponent(assetFullPath);
if (decodedFull !== assetFullPath) {
pendingPathBlobIdMap.set(decodedFull, key);
}
} catch {
// Invalid URI encoding
}
const fileName = assetRelPath.split('/').pop() ?? '';
pendingAssets.set(key, new File([data], fileName, { type: mime }));
} catch {
// Failed to read asset, skip
}
}
const fullPath = `${entry.bundlePath}/text.md`;
const job = new Transformer({
schema,
blobCRUD: collection.blobSync,
docCRUD: createCollectionDocCRUD(collection),
middlewares: [
defaultImageProxyMiddleware,
fileNameMiddleware(title),
filePathMiddleware(fullPath),
docLinkBaseURLMiddleware(collection.id),
],
});
const assets = job.assets;
const pathBlobIdMap = job.assetsManager.getPathBlobIdMap();
for (const [p, key] of pendingPathBlobIdMap.entries()) {
pathBlobIdMap.set(p, key);
}
for (const [key, file] of pendingAssets.entries()) {
assets.set(key, file);
}
const mdAdapter = new MarkdownAdapter(job, provider);
const doc = await mdAdapter.toDoc({
file: markdown,
assets: job.assetsManager,
});
if (doc) {
docIds.push(doc.id);
const metaPatch: Record<string, unknown> = {};
if (bearMeta?.creationDate) {
const ts = Date.parse(String(bearMeta.creationDate));
if (!isNaN(ts)) metaPatch.createDate = ts;
}
if (bearMeta?.modificationDate) {
const ts = Date.parse(String(bearMeta.modificationDate));
if (!isNaN(ts)) metaPatch.updatedDate = ts;
}
if (Object.keys(metaPatch).length) {
collection.meta.setDocMeta(doc.id, metaPatch);
}
for (const tag of tags) {
if (!tagDocMap.has(tag)) {
tagDocMap.set(tag, []);
}
tagDocMap.get(tag)!.push(doc.id);
}
}
} catch (err) {
console.warn(`Failed to import bundle: ${entry.bundlePath}`, err);
}
}
const folderHierarchy = buildFolderHierarchyFromTags(tagDocMap);
return { docIds, tags: tagDocMap, folderHierarchy };
}
/** Public API for importing Bear .bear2bk backup archives. */
export const BearTransformer = {
importBearBackup,
};

View File

@@ -1,3 +1,4 @@
export { BearTransformer } from './bear.js';
export { DocxTransformer } from './docx.js';
export { HtmlTransformer } from './html.js';
export { MarkdownTransformer } from './markdown.js';

View File

@@ -462,12 +462,23 @@ async function importMarkdownToDoc({
* @param options.imported The zip file as a Blob
* @returns A Promise that resolves to an array of IDs of the newly created docs
*/
type FolderHierarchy = {
name: string;
path: string;
children: Map<string, FolderHierarchy>;
pageId?: string;
parentPath?: string;
};
async function importMarkdownZip({
collection,
schema,
imported,
extensions,
}: ImportMarkdownZipOptions) {
}: ImportMarkdownZipOptions): Promise<{
docIds: string[];
folderHierarchy?: FolderHierarchy;
}> {
const provider = getProvider(extensions);
const unzip = new Unzip();
await unzip.load(imported);
@@ -476,6 +487,7 @@ async function importMarkdownZip({
const pendingAssets: AssetMap = new Map();
const pendingPathBlobIdMap: PathBlobIdMap = new Map();
const markdownBlobs: ImportedFileEntry[] = [];
const docPathMap: Array<{ fullPath: string; docId: string }> = [];
// Iterate over all files in the zip
for (const { path, content: blob } of unzip) {
@@ -527,10 +539,94 @@ async function importMarkdownZip({
if (doc) {
applyMetaPatch(collection, doc.id, meta);
docIds.push(doc.id);
docPathMap.push({ fullPath, docId: doc.id });
}
})
);
return docIds;
// Build folder hierarchy from zip paths
const folderHierarchy = buildMarkdownZipFolderHierarchy(docPathMap);
return { docIds, folderHierarchy };
}
/**
* Builds a tree of {@link FolderHierarchy} nodes from the zip paths of
* imported markdown files. Returns `undefined` when every entry sits at
* the same level (no real subfolder structure). A common root directory
* shared by all entries is stripped automatically so that the resulting
* hierarchy starts one level deeper.
*/
function buildMarkdownZipFolderHierarchy(
entries: Array<{ fullPath: string; docId: string }>
): FolderHierarchy | undefined {
if (entries.length === 0) return undefined;
// Check if any entries have folder structure
const hasSubfolders = entries.some(e => {
const parts = e.fullPath.split('/').filter(Boolean);
// More than just "root/file.md" -- need at least one real subfolder
return parts.length > 2;
});
if (!hasSubfolders) {
// All files are at the same level, no folder hierarchy needed
return undefined;
}
const root: FolderHierarchy = {
name: '',
path: '',
children: new Map(),
};
// Check once whether all entries share a common root directory
const candidateRoot = entries[0]?.fullPath.split('/').find(Boolean);
const skipRoot =
!!candidateRoot &&
entries.every(e => e.fullPath.startsWith(candidateRoot + '/'));
for (const { fullPath, docId } of entries) {
const parts = fullPath.split('/').filter(Boolean);
const fileName = parts.pop(); // Remove filename
if (!fileName) continue;
let folderParts = skipRoot ? parts.slice(1) : parts;
if (folderParts.length === 0) {
// Root-level file, no folder needed
continue;
}
let current = root;
let currentPath = '';
for (const folderName of folderParts) {
const parentPath = currentPath;
currentPath = currentPath ? `${currentPath}/${folderName}` : folderName;
if (!current.children.has(folderName)) {
current.children.set(folderName, {
name: folderName,
path: currentPath,
parentPath: parentPath || undefined,
children: new Map(),
});
}
current = current.children.get(folderName)!;
}
// Add the doc as a leaf
const docNodeKey = `__doc__${docId}`;
current.children.set(docNodeKey, {
name: docNodeKey,
path: `${current.path}/${docNodeKey}`,
parentPath: current.path,
children: new Map(),
pageId: docId,
});
}
return root.children.size > 0 ? root : undefined;
}
export const MarkdownTransformer = {

View File

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

View File

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

View File

@@ -436,7 +436,7 @@ export class StarterDebugMenu extends ShadowlessElement {
try {
const file = await openSingleFileWith('Zip');
if (!file) return;
const result = await MarkdownTransformer.importMarkdownZip({
const { docIds } = await MarkdownTransformer.importMarkdownZip({
collection: this.collection,
schema: this.editor.doc.schema,
imported: file,
@@ -445,7 +445,7 @@ export class StarterDebugMenu extends ShadowlessElement {
if (!this.editor.host) return;
toast(
this.editor.host,
`Successfully imported ${result.length} markdown files.`
`Successfully imported ${docIds.length} markdown files.`
);
} catch (error) {
console.error('Import markdown zip files failed:', error);

View File

@@ -56,7 +56,7 @@
"@faker-js/faker": "^10.1.0",
"@istanbuljs/schema": "^0.1.3",
"@magic-works/i18n-codegen": "^0.6.1",
"@playwright/test": "=1.58.2",
"@playwright/test": "=1.59.1",
"@smarttools/eslint-plugin-rxjs": "^1.0.8",
"@taplo/cli": "^0.7.0",
"@toeverything/infra": "workspace:*",
@@ -93,7 +93,7 @@
"vite": "^7.2.7",
"vitest": "^4.0.18"
},
"packageManager": "yarn@4.13.0",
"packageManager": "yarn@4.14.1",
"resolutions": {
"array-buffer-byte-length": "npm:@nolyfill/array-buffer-byte-length@^1",
"array-includes": "npm:@nolyfill/array-includes@^1",
@@ -167,7 +167,7 @@
"typedarray": "npm:@nolyfill/typedarray@^1",
"macos-alias": "npm:@napi-rs/macos-alias@0.0.4",
"fs-xattr": "npm:@napi-rs/xattr@latest",
"ioredis": "5.8.2",
"ioredis": "5.10.1",
"decode-named-character-reference@npm:^1.0.0": "patch:decode-named-character-reference@npm%3A1.0.2#~/.yarn/patches/decode-named-character-reference-npm-1.0.2-db17a755fd.patch",
"@atlaskit/pragmatic-drag-and-drop": "patch:@atlaskit/pragmatic-drag-and-drop@npm%3A1.4.0#~/.yarn/patches/@atlaskit-pragmatic-drag-and-drop-npm-1.4.0-75c45f52d3.patch",
"yjs": "patch:yjs@npm%3A13.6.21#~/.yarn/patches/yjs-npm-13.6.21-c9f1f3397c.patch"

View File

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

View File

@@ -65,7 +65,7 @@
"@queuedash/api": "^3.16.0",
"@react-email/components": "^0.5.7",
"@socket.io/redis-adapter": "^8.3.0",
"bullmq": "5.53.0",
"bullmq": "5.76.6",
"commander": "^13.1.0",
"cookie-parser": "^1.4.7",
"cross-env": "^10.1.0",
@@ -81,7 +81,7 @@
"graphql-scalars": "^1.24.0",
"graphql-upload": "^17.0.0",
"html-validate": "^9.0.0",
"htmlrewriter": "^0.0.12",
"htmlrewriter": "^0.0.13",
"http-errors": "^2.0.0",
"ioredis": "^5.8.2",
"is-mobile": "^5.0.0",
@@ -97,7 +97,7 @@
"piscina": "^5.1.4",
"prisma": "^6.6.0",
"react": "^19.2.1",
"react-dom": "19.2.1",
"react-dom": "19.2.6",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"semver": "^7.7.4",

View File

@@ -74,6 +74,12 @@ 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"
@@ -168,6 +174,17 @@ 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.1",
]
[[package]]
name = "clap"
version = "4.5.38"
@@ -224,6 +241,15 @@ 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"
@@ -238,7 +264,7 @@ checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
@@ -255,6 +281,12 @@ 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"
@@ -286,6 +318,12 @@ 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"
@@ -320,22 +358,69 @@ checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"r-efi 5.2.0",
"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.1",
"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.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[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.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.0",
"serde",
"serde_core",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
@@ -374,6 +459,12 @@ 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"
@@ -393,9 +484,9 @@ checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
[[package]]
name = "libfuzzer-sys"
version = "0.4.9"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf78f52d400cf2d84a3a973a78a592b4adc535739e0a5597a0da6f0c357adc75"
checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d"
dependencies = [
"arbitrary",
"cc",
@@ -455,11 +546,11 @@ checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "nanoid"
version = "0.4.0"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8"
checksum = "8628de41fe064cc3f0cf07f3d299ee3e73521adaff72278731d5c8cae3797873"
dependencies = [
"rand 0.8.6",
"rand 0.9.4",
]
[[package]]
@@ -530,29 +621,30 @@ dependencies = [
[[package]]
name = "phf"
version = "0.11.3"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros",
"phf_shared",
"serde",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared",
"rand 0.8.6",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
"phf_generator",
"phf_shared",
@@ -563,9 +655,9 @@ dependencies = [
[[package]]
name = "phf_shared"
version = "0.11.3"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
dependencies = [
"siphasher",
]
@@ -585,6 +677,16 @@ 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"
@@ -610,15 +712,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
[[package]]
name = "rand"
version = "0.8.6"
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
@@ -631,13 +728,14 @@ dependencies = [
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
name = "rand"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
"chacha20",
"getrandom 0.4.2",
"rand_core 0.10.1",
]
[[package]]
@@ -651,12 +749,13 @@ dependencies = [
]
[[package]]
name = "rand_core"
version = "0.6.4"
name = "rand_chacha"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb"
dependencies = [
"getrandom 0.2.16",
"ppv-lite86",
"rand_core 0.10.1",
]
[[package]]
@@ -669,13 +768,19 @@ dependencies = [
]
[[package]]
name = "rand_distr"
version = "0.5.1"
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_distr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8"
dependencies = [
"num-traits",
"rand 0.9.4",
"rand 0.10.1",
]
[[package]]
@@ -729,19 +834,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.219"
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
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"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.219"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
@@ -814,9 +935,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.101"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
@@ -928,6 +1049,12 @@ 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"
@@ -961,6 +1088,24 @@ dependencies = [
"wit-bindgen-rt",
]
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen 0.57.1",
]
[[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 0.51.0",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.100"
@@ -1018,6 +1163,40 @@ 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"
@@ -1155,6 +1334,32 @@ 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"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[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"
@@ -1164,6 +1369,74 @@ 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"
@@ -1177,8 +1450,8 @@ dependencies = [
"nanoid",
"nom",
"ordered-float",
"rand 0.9.4",
"rand_chacha 0.9.0",
"rand 0.10.1",
"rand_chacha 0.10.0",
"rand_distr",
"serde",
"serde_json",
@@ -1192,8 +1465,8 @@ version = "0.0.0"
dependencies = [
"lib0",
"libfuzzer-sys",
"rand 0.9.4",
"rand_chacha 0.9.0",
"rand 0.10.1",
"rand_chacha 0.10.0",
"y-octo",
"y-octo-utils",
"yrs",
@@ -1207,17 +1480,17 @@ dependencies = [
"clap",
"lib0",
"phf",
"rand 0.9.4",
"rand_chacha 0.9.0",
"rand 0.10.1",
"rand_chacha 0.10.0",
"y-octo",
"yrs",
]
[[package]]
name = "yrs"
version = "0.23.1"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a7cab84724ae7f361a8c92465f5160922cbb941a499e1a8cacd103351ab9c78"
checksum = "89512f2d869f9947e1c58d57ef86c8f4ca1b1e8ccf24d6e1ff8c7cdbd67d54df"
dependencies = [
"arc-swap",
"async-lock",
@@ -1228,7 +1501,7 @@ dependencies = [
"serde_json",
"smallstr",
"smallvec",
"thiserror 1.0.69",
"thiserror 2.0.12",
]
[[package]]

View File

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

View File

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

View File

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

View File

@@ -1,44 +1,44 @@
[versions]
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"
android-gradle-plugin = "8.13.2"
androidx-activity-compose = "1.13.0"
androidx-appcompat = "1.7.1"
androidx-browser = "1.10.0"
androidx-compose-bom = "2025.12.01"
androidx-coordinatorlayout = "1.3.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-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-material3 = "1.3.1"
androidx-navigation = "2.9.0"
apollo = "4.4.2"
apollo-kotlin-adapters = "0.0.6"
androidx-navigation = "2.9.8"
apollo = "4.4.3"
apollo-kotlin-adapters = "0.7.0"
# @keep
compileSdk = "36"
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"
firebase-bom = "33.16.0"
firebase-crashlytics = "3.0.7"
google-services = "4.4.4"
gradle-versions = "0.54.0"
hilt = "2.59.2"
hilt-ext = "1.3.0"
jna = "5.18.1"
junit = "4.13.2"
kotlin = "2.1.20"
kotlin = "2.3.21"
kotlinx-coroutines = "1.10.2"
kotlinx-datetime = "0.6.2"
kotlinx-serialization-json = "1.8.1"
ksp = "2.1.20-2.0.1"
kotlinx-datetime = "0.7.1-0.6.x-compat"
kotlinx-serialization-json = "1.11.0"
ksp = "2.3.7"
# @keep
minSdk = "23"
mozilla-rust-android = "0.9.6"
okhttp-bom = "5.0.0-alpha.14"
richtext = "1.0.0-alpha02"
okhttp-bom = "5.3.2"
richtext = "1.0.0-alpha04"
# @keep
targetSdk = "35"
timber = "5.0.1"
version-catalog-update = "1.0.0"
version-catalog-update = "1.1.0"
[libraries]
android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "android-gradle-plugin" }

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ end
target 'AFFiNE' do
capacitor_pods
# Add your Pods here
pod 'CryptoSwift', '~> 1.8.3'
pod 'CryptoSwift', '~> 1.10.0'
end
post_install do |installer|

View File

@@ -51,7 +51,7 @@
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"emojibase-data": "^16.0.3",
"foxact": "^0.2.49",
"foxact": "^0.3.0",
"jotai": "^2.10.3",
"lit": "^3.2.1",
"lodash-es": "^4.17.23",
@@ -60,7 +60,7 @@
"nanoid": "^5.1.6",
"next-themes": "^0.4.4",
"react": "^19.2.1",
"react-dom": "19.2.1",
"react-dom": "19.2.6",
"react-paginate": "^8.3.0",
"react-router-dom": "^6.30.3",
"react-transition-state": "^2.2.0",

View File

@@ -19,6 +19,7 @@
"@affine/reader": "workspace:*",
"@affine/templates": "workspace:*",
"@affine/track": "workspace:*",
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@blocksuite/affine": "workspace:*",
"@blocksuite/affine-block-root": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
@@ -58,7 +59,7 @@
"eventemitter2": "^6.4.9",
"file-type": "^21.0.0",
"filesize": "^10.1.6",
"foxact": "^0.2.49",
"foxact": "^0.3.0",
"fuse.js": "^7.0.0",
"graphemer": "^1.4.0",
"graphql": "^16.9.0",
@@ -67,7 +68,7 @@
"image-blob-reduce": "^4.1.0",
"is-svg": "^6.1.0",
"jotai": "^2.10.3",
"jotai-scope": "^0.7.2",
"jotai-scope": "^0.10.0",
"katex": "^0.16.27",
"lit": "^3.2.1",
"lodash-es": "^4.17.23",

View File

@@ -1,4 +1,3 @@
import { toast } from '@affine/component';
import type { TagMeta } from '@affine/core/components/page-list';
import type { CollectionMeta } from '@affine/core/modules/collection';
import track, { type EventArgs } from '@affine/track';
@@ -22,6 +21,7 @@ import { property, query, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import type { SearchMenuConfig } from '../ai-chat-add-context';
import { addFilesToChat } from './attachment-utils';
import type { ChatChip, DocDisplayConfig } from './type';
enum AddPopoverMode {
@@ -172,23 +172,10 @@ export class ChatPanelAddPopover extends SignalWatcher(
if (!files || files.length === 0) return;
this.abortController.abort();
const images = files.filter(file => file.type.startsWith('image/'));
if (images.length > 0) {
this.addImages(images);
}
const others = files.filter(file => !file.type.startsWith('image/'));
const addChipPromises = others.map(async file => {
if (file.size > 50 * 1024 * 1024) {
toast(`${file.name} is too large, please upload a file less than 50MB`);
return;
}
await this.addChip({
file,
state: 'processing',
});
await addFilesToChat(files, {
addImages: this.addImages,
addChip: this.addChip,
});
await Promise.all(addChipPromises);
this._track('file');
};

View File

@@ -0,0 +1,36 @@
import { toast } from '@affine/component';
import type { ChatChip } from './type';
const MAX_ATTACHMENT_SIZE = 50 * 1024 * 1024;
export interface AttachmentHandlers {
addImages: (images: File[]) => void;
addChip: (chip: ChatChip, silent?: boolean) => Promise<void>;
}
export async function addFilesToChat(
files: File[],
{ addImages, addChip }: AttachmentHandlers
): Promise<void> {
if (!files.length) return;
const images = files.filter(file => file.type.startsWith('image/'));
if (images.length > 0) {
addImages(images);
}
const others = files.filter(file => !file.type.startsWith('image/'));
await Promise.all(
others.map(async file => {
if (file.size > MAX_ATTACHMENT_SIZE) {
toast(`${file.name} is too large, please upload a file less than 50MB`);
return;
}
await addChip({
file,
state: 'processing',
});
})
);
}

View File

@@ -1,2 +1,3 @@
export * from './attachment-utils';
export * from './type';
export * from './utils';

View File

@@ -9,6 +9,9 @@ import type {
} from '@affine/core/modules/cloud';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { CopilotChatHistoryFragment } from '@affine/graphql';
import track, { type EventArgs } from '@affine/track';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import type { EditorHost } from '@blocksuite/affine/std';
@@ -26,6 +29,7 @@ import { reportResponse } from '../../utils/action-reporter';
import { readBlobAsURL } from '../../utils/image';
import { mergeStreamObjects } from '../../utils/stream-objects';
import type { SearchMenuConfig } from '../ai-chat-add-context';
import { addFilesToChat } from '../ai-chat-chips/attachment-utils';
import type { ChatChip, DocDisplayConfig } from '../ai-chat-chips/type';
import { isDocChip } from '../ai-chat-chips/utils';
import {
@@ -257,6 +261,31 @@ export class AIChatInput extends SignalWatcher(
user-select: none;
}
.chat-panel-input[data-drag-over='true'] {
--input-border-width: 1px;
--input-border-color: var(--affine-v2-layer-insideBorder-primaryBorder);
background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
}
.chat-panel-input-drop-overlay {
position: absolute;
inset: 0;
pointer-events: none;
border-radius: inherit;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 500;
color: ${unsafeCSSVarV2('icon/activated')};
background-color: color-mix(
in srgb,
var(--affine-v2-layer-background-primary) 92%,
transparent
);
z-index: 1;
}
.chat-panel-send {
display: flex;
justify-content: center;
@@ -326,6 +355,16 @@ export class AIChatInput extends SignalWatcher(
@state()
accessor focused = false;
@state()
accessor isDragOver = false;
@query('.chat-panel-input')
accessor chatPanelInput!: HTMLDivElement;
private _dragEnterCounter = 0;
private _internalDropCleanup: (() => void) | null = null;
@property({ attribute: false })
accessor chatContextValue!: AIChatInputContext;
@@ -434,6 +473,18 @@ export class AIChatInput extends SignalWatcher(
}
})
);
this.updateComplete
.then(() => {
if (this.isConnected && !this._internalDropCleanup) {
this._setupInternalDropTarget();
}
})
.catch(console.error);
window.addEventListener('dragleave', this._handleWindowDragLeave);
window.addEventListener('drop', this._resetDragState);
window.addEventListener('dragend', this._resetDragState);
}
protected override firstUpdated(changedProperties: PropertyValues): void {
@@ -449,6 +500,57 @@ export class AIChatInput extends SignalWatcher(
}
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this._internalDropCleanup?.();
this._internalDropCleanup = null;
window.removeEventListener('dragleave', this._handleWindowDragLeave);
window.removeEventListener('drop', this._resetDragState);
window.removeEventListener('dragend', this._resetDragState);
}
private _trackDragDrop(method: EventArgs['addEmbeddingDoc']['method']) {
const page = this.independentMode
? track.$.intelligence
: track.$.chatPanel;
page.chatPanelInput.addEmbeddingDoc({
control: 'dragDrop',
method,
});
}
private _setupInternalDropTarget() {
const el = this.chatPanelInput;
if (!el) return;
const dropTargetCleanup = dropTargetForElements({
element: el,
canDrop: ({ source }) => {
const entity = (source.data as { entity?: { type?: string } }).entity;
return entity?.type === 'doc';
},
onDragEnter: () => {
this.isDragOver = true;
},
onDragLeave: () => {
this.isDragOver = false;
},
onDrop: ({ source }) => {
this.isDragOver = false;
const entity = (
source.data as { entity?: { type?: string; id?: string } }
).entity;
if (entity?.type === 'doc' && entity.id) {
this.addChip({
docId: entity.id,
state: 'processing',
}).catch(console.error);
this._trackDragDrop('doc');
}
},
});
this._internalDropCleanup = combine(dropTargetCleanup);
}
protected override render() {
const { images, status } = this.chatContextValue;
const hasImages = images.length > 0;
@@ -458,11 +560,19 @@ export class AIChatInput extends SignalWatcher(
class="chat-panel-input"
data-independent-mode=${this.independentMode}
data-if-focused=${this.focused}
data-drag-over=${this.isDragOver}
style=${styleMap({
maxHeight: `${maxHeight}px !important`,
})}
@pointerdown=${this._handlePointerDown}
@dragenter=${this._handleDragEnter}
@dragover=${this._handleDragOver}
@dragleave=${this._handleDragLeave}
@drop=${this._handleDrop}
>
${this.isDragOver
? html`<div class="chat-panel-input-drop-overlay">Drop to attach</div>`
: nothing}
${hasImages
? html`
<image-preview-grid
@@ -611,6 +721,66 @@ export class AIChatInput extends SignalWatcher(
}
};
private _dragHasFiles(event: DragEvent) {
return Array.from(event.dataTransfer?.types ?? []).includes('Files');
}
private readonly _handleDragEnter = (event: DragEvent) => {
if (!this._dragHasFiles(event)) return;
event.preventDefault();
this._dragEnterCounter += 1;
this.isDragOver = true;
};
private readonly _handleDragOver = (event: DragEvent) => {
if (!this._dragHasFiles(event)) return;
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
}
};
private readonly _handleDragLeave = (event: DragEvent) => {
if (!this._dragHasFiles(event)) return;
this._dragEnterCounter = Math.max(0, this._dragEnterCounter - 1);
if (this._dragEnterCounter === 0) {
this.isDragOver = false;
}
};
private readonly _resetDragState = () => {
if (this._dragEnterCounter === 0 && !this.isDragOver) return;
this._dragEnterCounter = 0;
this.isDragOver = false;
};
// Covers the cases where the drag session ends without dragleave/drop firing
// on the input (Esc-cancel, release outside window, drop on another element).
private readonly _handleWindowDragLeave = (event: DragEvent) => {
if (event.relatedTarget === null) this._resetDragState();
};
private readonly _handleDrop = async (event: DragEvent) => {
if (!this._dragHasFiles(event)) return;
event.preventDefault();
event.stopPropagation();
this._dragEnterCounter = 0;
this.isDragOver = false;
const files = Array.from(event.dataTransfer?.files ?? []);
if (!files.length) return;
try {
await addFilesToChat(files, {
addImages: this.addImages,
addChip: this.addChip,
});
this._trackDragDrop('file');
} catch (error) {
console.error(error);
}
};
private readonly _handleAbort = () => {
this.chatContextValue.abortController?.abort();
this.updateContext({ status: 'success' });

View File

@@ -0,0 +1,212 @@
import type { CopilotChatHistoryFragment } from '@affine/graphql';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { ShadowlessElement } from '@blocksuite/affine/std';
import { CloseIcon } from '@blocksuite/icons/lit';
import { css, html, type PropertyValues } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
const DEFAULT_TAB_TITLE = 'New chat';
const TITLE_MAX_LENGTH = 28;
function truncate(text: string): string {
if (text.length <= TITLE_MAX_LENGTH) return text;
return `${text.slice(0, TITLE_MAX_LENGTH).trimEnd()}`;
}
function deriveTabTitle(session: CopilotChatHistoryFragment): string {
const explicit = session.title?.trim();
if (explicit) return truncate(explicit);
const firstUserMessage = session.messages?.find(m => m.role === 'user');
const raw = firstUserMessage?.content?.trim();
if (!raw) return DEFAULT_TAB_TITLE;
const newlineIdx = raw.indexOf('\n');
return truncate(newlineIdx === -1 ? raw : raw.slice(0, newlineIdx));
}
export class AIChatTabs extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor sessions: CopilotChatHistoryFragment[] = [];
@property({ attribute: false })
accessor activeSessionId: string | undefined;
@property({ attribute: false })
accessor onSelectTab!: (sessionId: string) => void;
@property({ attribute: false })
accessor onCloseTab!: (sessionId: string) => void;
static override styles = css`
ai-chat-tabs {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
height: 100%;
overflow: hidden;
}
.ai-chat-tabs {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
min-width: 0;
height: 100%;
}
.tabs-scroll {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
.tabs-scroll::-webkit-scrollbar {
display: none;
}
.tab {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
max-width: 180px;
height: 26px;
padding: 0 6px 0 10px;
border-radius: 6px;
cursor: pointer;
color: ${unsafeCSSVarV2('text/secondary')};
font-size: 12px;
font-weight: 500;
user-select: none;
transition: background-color 0.15s ease;
}
.tab:hover {
background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
color: ${unsafeCSSVarV2('text/primary')};
}
.tab[data-active='true'] {
background-color: ${unsafeCSSVarV2('layer/background/secondary')};
color: ${unsafeCSSVarV2('text/primary')};
}
.tab-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.tab-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border: none;
padding: 0;
border-radius: 3px;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.6;
}
.tab-close:hover {
opacity: 1;
background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
}
.tab-close svg {
width: 12px;
height: 12px;
}
`;
override render() {
if (!this.sessions.length) return html``;
return html`
<div class="ai-chat-tabs" data-testid="ai-chat-tabs">
<div class="tabs-scroll" @wheel=${this._handleWheel}>
${repeat(
this.sessions,
session => session.sessionId,
session => this._renderTab(session)
)}
</div>
</div>
`;
}
private readonly _handleWheel = (e: WheelEvent) => {
const el = e.currentTarget as HTMLElement;
if (el.scrollWidth <= el.clientWidth) return;
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
e.preventDefault();
el.scrollLeft += e.deltaY;
}
};
private _renderTab(session: CopilotChatHistoryFragment) {
const active = session.sessionId === this.activeSessionId;
const title = deriveTabTitle(session);
return html`
<div
class="tab"
data-active=${active}
data-session-id=${session.sessionId}
data-testid="ai-chat-tab"
title=${title}
@click=${() => this._handleSelect(session.sessionId)}
>
<span class="tab-title">${title}</span>
<button
class="tab-close"
data-testid="ai-chat-tab-close"
aria-label="Close tab"
@click=${(e: Event) => this._handleClose(e, session.sessionId)}
>
${CloseIcon()}
</button>
</div>
`;
}
private readonly _handleSelect = (sessionId: string) => {
if (sessionId === this.activeSessionId) return;
this.onSelectTab(sessionId);
};
private readonly _handleClose = (e: Event, sessionId: string) => {
e.stopPropagation();
this.onCloseTab(sessionId);
};
override updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (
(changedProps.has('activeSessionId') || changedProps.has('sessions')) &&
this.activeSessionId
) {
const activeTab = this.renderRoot.querySelector(
`[data-session-id="${this.activeSessionId}"]`
);
activeTab?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest',
});
}
}
}
declare global {
interface HTMLElementTagNameMap {
'ai-chat-tabs': AIChatTabs;
}
}

View File

@@ -5,7 +5,7 @@ import type { NotificationService } from '@blocksuite/affine/shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { ShadowlessElement } from '@blocksuite/affine/std';
import {
ArrowDownSmallIcon,
HistoryIcon,
PinedIcon,
PinIcon,
PlusIcon,
@@ -120,8 +120,9 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
<div
class="chat-toolbar-icon history-button"
@click=${this.toggleHistoryMenu}
data-testid="ai-panel-chat-history"
>
${ArrowDownSmallIcon()}
${HistoryIcon()}
<affine-tooltip>Chat History</affine-tooltip>
</div>
</div>

View File

@@ -1,3 +1,4 @@
export * from './ai-chat-tabs';
export * from './ai-chat-toolbar';
export * from './ai-session-history';
export * from './configure-ai-chat-toolbar';

View File

@@ -27,7 +27,11 @@ import { AIChatInput } from '../components/ai-chat-input';
import { AIChatEmbeddingStatusTooltip } from '../components/ai-chat-input/embedding-status-tooltip';
import { ChatInputPreference } from '../components/ai-chat-input/preference-popup';
import { AIChatMessages } from '../components/ai-chat-messages/ai-chat-messages';
import { AIChatToolbar, AISessionHistory } from '../components/ai-chat-toolbar';
import {
AIChatTabs,
AIChatToolbar,
AISessionHistory,
} from '../components/ai-chat-toolbar';
import { AIHistoryClear } from '../components/ai-history-clear';
import { AssistantAvatar } from '../components/ai-message-content/assistant-avatar';
import { ChatActionList } from '../components/chat-action-list';
@@ -53,6 +57,7 @@ const appElements = {
'action-text': ActionText,
'ai-loading': AILoading,
'ai-chat-content': AIChatContent,
'ai-chat-tabs': AIChatTabs,
'ai-chat-toolbar': AIChatToolbar,
'ai-session-history': AISessionHistory,
'ai-chat-messages': AIChatMessages,

View File

@@ -75,6 +75,7 @@ export const appEffectElementTags = [
'action-text',
'ai-loading',
'ai-chat-content',
'ai-chat-tabs',
'ai-chat-toolbar',
'ai-session-history',
'ai-chat-messages',

View File

@@ -15,6 +15,7 @@ import {
} from '@affine/core/modules/dialogs';
import { ExplorerIconService } from '@affine/core/modules/explorer-icon/services/explorer-icon';
import { OrganizeService } from '@affine/core/modules/organize';
import { TagService } from '@affine/core/modules/tag';
import { UrlService } from '@affine/core/modules/url';
import {
getAFFiNEWorkspaceSchema,
@@ -27,6 +28,7 @@ import track from '@affine/track';
import { openDirectory, openFilesWith } from '@blocksuite/affine/shared/utils';
import type { Workspace } from '@blocksuite/affine/store';
import {
BearTransformer,
DocxTransformer,
HtmlTransformer,
MarkdownTransformer,
@@ -188,11 +190,49 @@ function createFolderStructure(
return { folderId: rootFolderId, docLinks };
}
/**
* Creates the folder tree described by {@link folderHierarchy} via
* {@link OrganizeService} and links every document into its folder.
* Returns the root folder ID on success, or `undefined` if the
* hierarchy is empty or an error occurs.
*
* When {@link explorerIconService} is provided, document icons from the
* hierarchy (e.g. Notion page emojis) are applied. Callers that do not
* need icon support can omit it safely.
*/
function applyFolderHierarchy(
organizeService: OrganizeService,
folderHierarchy: FolderHierarchy,
explorerIconService?: ExplorerIconService
): string | undefined {
if (folderHierarchy.children.size === 0) return undefined;
try {
const { folderId, docLinks } = createFolderStructure(
organizeService,
folderHierarchy,
null,
explorerIconService
);
for (const { folderId, docId } of docLinks) {
const folder = organizeService.folderTree.folderNode$(folderId).value;
if (folder) {
const index = folder.indexAt('after');
folder.createLink('doc', docId, index);
}
}
return folderId || undefined;
} catch (error) {
logger.warn('Failed to create folder structure:', error);
return undefined;
}
}
type ImportType =
| 'markdown'
| 'markdownZip'
| 'notion'
| 'obsidian'
| 'bear'
| 'snapshot'
| 'html'
| 'docx'
@@ -218,7 +258,8 @@ type ImportConfig = {
files: File[],
handleImportAffineFile: () => Promise<WorkspaceMetadata | undefined>,
organizeService?: OrganizeService,
explorerIconService?: ExplorerIconService
explorerIconService?: ExplorerIconService,
tagService?: TagService
) => Promise<ImportResult>;
};
@@ -290,6 +331,19 @@ const importOptions = [
testId: 'editor-option-menu-import-obsidian',
type: 'obsidian' as ImportType,
},
{
key: 'bear',
label: 'com.affine.import.bear',
prefixIcon: (
<FileIcon color={cssVarV2('icon/primary')} width={20} height={20} />
),
suffixIcon: (
<HelpIcon color={cssVarV2('icon/primary')} width={20} height={20} />
),
suffixTooltip: 'com.affine.import.bear.tooltip',
testId: 'editor-option-menu-import-bear',
type: 'bear' as ImportType,
},
{
key: 'docx',
label: 'com.affine.import.docx',
@@ -365,21 +419,29 @@ const importConfigs: Record<ImportType, ImportConfig> = {
docCollection,
files,
_handleImportAffineFile,
_organizeService,
organizeService,
_explorerIconService
) => {
const file = files.length === 1 ? files[0] : null;
if (!file) {
throw new Error('Expected a single zip file for markdownZip import');
}
const docIds = await MarkdownTransformer.importMarkdownZip({
collection: docCollection,
schema: getAFFiNEWorkspaceSchema(),
imported: file,
extensions: getStoreManager().config.init().value.get('store'),
});
const { docIds, folderHierarchy } =
await MarkdownTransformer.importMarkdownZip({
collection: docCollection,
schema: getAFFiNEWorkspaceSchema(),
imported: file,
extensions: getStoreManager().config.init().value.get('store'),
});
const rootFolderId =
folderHierarchy && organizeService
? applyFolderHierarchy(organizeService, folderHierarchy)
: undefined;
return {
docIds,
rootFolderId,
};
},
},
@@ -431,37 +493,14 @@ const importConfigs: Record<ImportType, ImportConfig> = {
extensions: getStoreManager().config.init().value.get('store'),
});
let rootFolderId: string | undefined;
// Create folder structure if hierarchy exists and OrganizeService is available
if (
folderHierarchy &&
organizeService &&
folderHierarchy.children.size > 0
) {
try {
const { folderId, docLinks } = createFolderStructure(
organizeService,
folderHierarchy,
null,
explorerIconService
);
rootFolderId = folderId || undefined;
// Create links for all documents to their respective folders
for (const { folderId, docId } of docLinks) {
const folder =
organizeService.folderTree.folderNode$(folderId).value;
if (folder) {
const index = folder.indexAt('after');
folder.createLink('doc', docId, index);
}
}
} catch (error) {
logger.warn('Failed to create folder structure:', error);
// Continue with import even if folder creation fails
}
}
const rootFolderId =
folderHierarchy && organizeService
? applyFolderHierarchy(
organizeService,
folderHierarchy,
explorerIconService
)
: undefined;
return {
docIds: pageIds,
@@ -501,6 +540,114 @@ const importConfigs: Record<ImportType, ImportConfig> = {
return { docIds };
},
},
bear: {
fileOptions: { acceptType: 'Zip', multiple: false },
importFunction: async (
docCollection,
files,
_handleImportAffineFile,
organizeService,
_explorerIconService,
tagService
) => {
const file = files.length === 1 ? files[0] : null;
if (!file) {
throw new Error('Expected a single .bear2bk file for Bear import');
}
let docIds: string[];
let tags: Map<string, string[]>;
let folderHierarchy: FolderHierarchy;
try {
const result = await BearTransformer.importBearBackup({
collection: docCollection,
schema: getAFFiNEWorkspaceSchema(),
imported: file,
extensions: getStoreManager().config.init().value.get('store'),
});
docIds = result.docIds;
tags = result.tags;
folderHierarchy = result.folderHierarchy;
} catch (err) {
logger.error('Bear import failed:', err);
throw err instanceof Error
? err
: new Error(String(err) || 'Bear import failed');
}
// Create AFFiNE tags from Bear tags
if (tagService && tags.size > 0) {
try {
// Get existing tags for deduplication
const existingTags = tagService.tagList.tags$.value;
const existingTagMap = new Map<string, string>(); // lowercase name → tag id
for (const tag of existingTags) {
const name = tag.value$.value.toLowerCase();
existingTagMap.set(name, tag.id);
}
// Consolidate tags by root segment (e.g., "privat/bike" → "privat").
// Keyed by lowercase root for case-insensitive dedup, but the
// original capitalization of the first occurrence is preserved
// so new AFFiNE tags are created with the user's casing.
const rootTagDocMap = new Map<
string,
{ displayName: string; docs: Set<string> }
>();
for (const [tagName, tagDocIds] of tags) {
const originalRoot = tagName.split('/')[0];
const key = originalRoot.toLowerCase();
let entry = rootTagDocMap.get(key);
if (!entry) {
entry = { displayName: originalRoot, docs: new Set<string>() };
rootTagDocMap.set(key, entry);
}
for (const docId of tagDocIds) {
entry.docs.add(docId);
}
}
for (const [
rootTagKey,
{ displayName, docs: docIdSet },
] of rootTagDocMap) {
// Check if tag already exists (case-insensitive)
let tagId = existingTagMap.get(rootTagKey);
if (!tagId) {
const newTag = tagService.tagList.createTag(
displayName,
tagService.randomTagColor()
);
tagId = newTag.id;
existingTagMap.set(rootTagKey, tagId);
}
// Assign tag to each doc
for (const docId of docIdSet) {
const doc = docCollection.getDoc(docId);
const currentTags = doc?.meta?.tags ?? [];
if (!currentTags.includes(tagId)) {
docCollection.meta.setDocMeta(docId, {
tags: [...currentTags, tagId],
});
}
}
}
} catch (error) {
logger.warn('Failed to create Bear tags:', error);
}
}
const rootFolderId =
folderHierarchy && organizeService
? applyFolderHierarchy(organizeService, folderHierarchy)
: undefined;
return {
docIds,
rootFolderId,
};
},
},
docx: {
fileOptions: { acceptType: 'Docx', multiple: false },
importFunction: async (docCollection, file) => {
@@ -735,6 +882,7 @@ export const ImportDialog = ({
const docCollection = workspace.docCollection;
const organizeService = useService(OrganizeService);
const explorerIconService = useService(ExplorerIconService);
const tagService = useService(TagService);
const globalDialogService = useService(GlobalDialogService);
@@ -824,7 +972,8 @@ export const ImportDialog = ({
files,
handleImportAffineFile,
organizeService,
explorerIconService
explorerIconService,
tagService
);
setImportResult({
@@ -863,6 +1012,7 @@ export const ImportDialog = ({
explorerIconService,
handleImportAffineFile,
organizeService,
tagService,
t,
]
);

View File

@@ -1,5 +1,86 @@
import { WorkspaceLocalState } from '@affine/core/modules/workspace';
import type { I18nInstance } from '@affine/i18n';
import type { NotificationService } from '@blocksuite/affine/shared/services';
import { useService } from '@toeverything/infra';
import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
const AI_CHAT_OPEN_TABS_KEY = 'aiChatOpenTabs';
// Pass `null` for `loadSession` to defer hydration until a real loader is ready.
export function useAIChatOpenTabs<T extends { sessionId: string }>(
loadSession: ((sessionId: string) => Promise<T | null | undefined>) | null
): {
openTabs: T[];
setOpenTabs: Dispatch<SetStateAction<T[]>>;
} {
const workspaceLocalState = useService(WorkspaceLocalState);
const [openTabs, setOpenTabsState] = useState<T[]>([]);
// Ref so persist gate isn't subject to React state-batch ordering.
const hydratedRef = useRef(false);
useEffect(() => {
if (!loadSession) return;
hydratedRef.current = false;
setOpenTabsState([]);
const ids = workspaceLocalState.get<string[]>(AI_CHAT_OPEN_TABS_KEY) ?? [];
if (!ids.length) {
hydratedRef.current = true;
return;
}
let cancelled = false;
Promise.all(ids.map(id => loadSession(id).catch(() => null)))
.then(results => {
if (cancelled) return;
const valid = (results as (T | null | undefined)[]).filter(
(entry): entry is T => !!entry && !!entry.sessionId
);
if (valid.length) setOpenTabsState(valid);
hydratedRef.current = true;
})
.catch(error => {
console.error(error);
if (!cancelled) hydratedRef.current = true;
});
return () => {
cancelled = true;
};
}, [loadSession, workspaceLocalState]);
const setOpenTabs = useCallback<Dispatch<SetStateAction<T[]>>>(
updater => {
setOpenTabsState(prev => {
const next =
typeof updater === 'function'
? (updater as (p: T[]) => T[])(prev)
: updater;
if (hydratedRef.current) {
if (next.length) {
workspaceLocalState.set(
AI_CHAT_OPEN_TABS_KEY,
next.map(tab => tab.sessionId)
);
} else {
workspaceLocalState.del(AI_CHAT_OPEN_TABS_KEY);
}
}
return next;
});
},
[workspaceLocalState]
);
return { openTabs, setOpenTabs };
}
export type SessionDeleteCleanupFn = (
session: BlockSuitePresets.AIRecentSession

View File

@@ -1,8 +1,18 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const chatTabsContainer = style({
flex: 1,
minWidth: 0,
display: 'flex',
alignItems: 'center',
overflow: 'hidden',
});
export const chatRoot = style({
width: '100%',
height: '100%',
flex: 1,
minHeight: 0,
});
export const chatHeader = style({
@@ -10,4 +20,6 @@ export const chatHeader = style({
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
gap: 12,
borderBottom: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
});

View File

@@ -7,6 +7,7 @@ import {
import type { ChatStatus } from '@affine/core/blocksuite/ai/components/ai-chat-messages';
import type { AIChatToolbar } from '@affine/core/blocksuite/ai/components/ai-chat-toolbar';
import {
AIChatTabs,
configureAIChatToolbar,
getOrCreateAIChatToolbar,
} from '@affine/core/blocksuite/ai/components/ai-chat-toolbar';
@@ -49,7 +50,10 @@ import { useFramework, useService } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createSessionDeleteHandler } from '../chat-panel-utils';
import {
createSessionDeleteHandler,
useAIChatOpenTabs,
} from '../chat-panel-utils';
import * as styles from './index.css';
type CopilotSession = Awaited<ReturnType<CopilotClient['getSession']>>;
@@ -93,6 +97,7 @@ export const Component = () => {
const [isHeaderProvided, setIsHeaderProvided] = useState(false);
const [chatContent, setChatContent] = useState<AIChatContent | null>(null);
const [chatTool, setChatTool] = useState<AIChatToolbar | null>(null);
const [chatTabs, setChatTabs] = useState<AIChatTabs | null>(null);
const [currentSession, setCurrentSession] = useState<CopilotSession | null>(
null
);
@@ -102,12 +107,19 @@ export const Component = () => {
const hasRestoredPinnedSessionRef = useRef(false);
const chatContainerRef = useRef<HTMLDivElement>(null);
const chatToolContainerRef = useRef<HTMLDivElement>(null);
const chatTabsContainerRef = useRef<HTMLDivElement | null>(null);
const widthSignalRef = useRef<Signal<number>>(signal(0));
const client = useCopilotClient();
const workbench = useService(WorkbenchService).workbench;
const workspaceId = useService(WorkspaceService).workspace.id;
const loadSession = useCallback(
(sessionId: string) => client.getSession(workspaceId, sessionId),
[client, workspaceId]
);
const { openTabs, setOpenTabs } = useAIChatOpenTabs(loadSession);
useEffect(() => {
hasRestoredPinnedSessionRef.current = false;
}, [workspaceId]);
@@ -192,6 +204,11 @@ export const Component = () => {
setIsOpeningSession(true);
try {
const session = await client.getSession(workspaceId, sessionId);
if (!session) {
// Drop stale tab if session no longer exists.
setOpenTabs(prev => prev.filter(tab => tab.sessionId !== sessionId));
return;
}
setCurrentSession(session);
reMountChatContent();
chatTool?.closeHistoryMenu();
@@ -207,10 +224,31 @@ export const Component = () => {
currentSession?.sessionId,
isOpeningSession,
reMountChatContent,
setOpenTabs,
workspaceId,
]
);
const closeTab = useCallback(
(sessionId: string) => {
let fallback: NonNullable<CopilotSession> | undefined;
setOpenTabs(prev => {
const idx = prev.findIndex(tab => tab.sessionId === sessionId);
if (idx === -1) return prev;
const next = prev.filter(tab => tab.sessionId !== sessionId);
fallback = next[idx] ?? next[idx - 1] ?? next[0];
return next;
});
if (currentSession?.sessionId !== sessionId) return;
if (fallback) {
onOpenSession(fallback.sessionId).catch(console.error);
} else {
createFreshSession().catch(console.error);
}
},
[createFreshSession, currentSession?.sessionId, onOpenSession, setOpenTabs]
);
const onContextChange = useCallback((context: Partial<ChatContextValue>) => {
setStatus(context.status ?? 'idle');
}, []);
@@ -399,6 +437,40 @@ export const Component = () => {
return () => sub.unsubscribe();
}, [framework, mockStd]);
useEffect(() => {
if (!currentSession?.sessionId) return;
setOpenTabs(prev => {
const existing = prev.findIndex(
tab => tab.sessionId === currentSession.sessionId
);
if (existing !== -1) {
if (prev[existing] === currentSession) return prev;
const next = prev.slice();
next[existing] = currentSession;
return next;
}
return [...prev, currentSession];
});
}, [currentSession, setOpenTabs]);
useEffect(() => {
if (!chatTabsContainerRef.current) return;
let tabs = chatTabs;
if (!tabs) {
tabs = new AIChatTabs();
chatTabsContainerRef.current.append(tabs);
setChatTabs(tabs);
}
tabs.sessions = openTabs;
tabs.activeSessionId = currentSession?.sessionId;
tabs.onSelectTab = (sessionId: string) => {
onOpenSession(sessionId).catch(console.error);
};
tabs.onCloseTab = (sessionId: string) => {
closeTab(sessionId);
};
}, [chatTabs, closeTab, currentSession?.sessionId, onOpenSession, openTabs]);
// restore pinned session
useEffect(() => {
if (hasRestoredPinnedSessionRef.current || currentSession) return;
@@ -462,6 +534,10 @@ export const Component = () => {
}
}, []);
const onChatTabsContainerRef = useCallback((node: HTMLDivElement | null) => {
chatTabsContainerRef.current = node;
}, []);
// observe chat container width and provide to ai-chat-content
useEffect(() => {
if (!isBodyProvided || !chatContainerRef.current) return;
@@ -476,7 +552,10 @@ export const Component = () => {
<ViewIcon icon="ai" />
<ViewHeader>
<div className={styles.chatHeader}>
<div />
<div
className={styles.chatTabsContainer}
ref={onChatTabsContainerRef}
/>
<div ref={onChatToolContainerRef} />
</div>
</ViewHeader>

View File

@@ -20,11 +20,13 @@ export const header = style({
position: 'relative',
padding: '8px var(--h-padding, 16px)',
width: '100%',
height: '36px',
minHeight: '36px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
zIndex: 1,
borderBottom: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
});
export const title = style({
@@ -82,6 +84,14 @@ export const loadingIcon = style({
color: 'var(--affine-icon-secondary)',
});
export const tabsContainer = style({
flex: 1,
minWidth: 0,
display: 'flex',
alignItems: 'center',
overflow: 'hidden',
});
globalStyle(`${playground} svg`, {
width: '18px',
height: '18px',

View File

@@ -8,6 +8,7 @@ import {
import type { ChatStatus } from '@affine/core/blocksuite/ai/components/ai-chat-messages';
import type { AIChatToolbar } from '@affine/core/blocksuite/ai/components/ai-chat-toolbar';
import {
AIChatTabs,
configureAIChatToolbar,
getOrCreateAIChatToolbar,
} from '@affine/core/blocksuite/ai/components/ai-chat-toolbar';
@@ -45,7 +46,10 @@ import { useFramework, useService } from '@toeverything/infra';
import { html } from 'lit';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createSessionDeleteHandler } from '../../chat-panel-utils';
import {
createSessionDeleteHandler,
useAIChatOpenTabs,
} from '../../chat-panel-utils';
import * as styles from './chat.css';
import {
getChatContentKey,
@@ -93,11 +97,12 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
const [chatContent, setChatContent] = useState<AIChatContent | null>(null);
const [chatToolbar, setChatToolbar] = useState<AIChatToolbar | null>(null);
const [chatTabs, setChatTabs] = useState<AIChatTabs | null>(null);
const [isBodyProvided, setIsBodyProvided] = useState(false);
const [isHeaderProvided, setIsHeaderProvided] = useState(false);
const chatContainerRef = useRef<HTMLDivElement | null>(null);
const chatToolbarContainerRef = useRef<HTMLDivElement | null>(null);
const chatTabsContainerRef = useRef<HTMLDivElement | null>(null);
const contentKeyRef = useRef<string | null>(null);
const prevSessionIdRef = useRef<string | null>(null);
const prevSessionDocIdRef = useRef<string | null>(null);
@@ -107,6 +112,36 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
const doc = editor?.doc;
const host = editor?.host;
const workspaceId = doc?.workspace.id;
const [sessionServiceReady, setSessionServiceReady] = useState(
() => !!AIProvider.session
);
useEffect(() => {
if (sessionServiceReady) return;
if (AIProvider.session) {
setSessionServiceReady(true);
return;
}
const sub = AIProvider.slots.sessionReady.subscribe(ready => {
if (ready) setSessionServiceReady(true);
});
return () => sub.unsubscribe();
}, [sessionServiceReady]);
const loadSession = useMemo(() => {
if (!sessionServiceReady || !workspaceId) return null;
const sessionService = AIProvider.session;
if (!sessionService) return null;
return async (
sessionId: string
): Promise<CopilotChatHistoryFragment | null | undefined> =>
sessionService.getSession(workspaceId, sessionId);
}, [sessionServiceReady, workspaceId]);
const { openTabs, setOpenTabs } =
useAIChatOpenTabs<CopilotChatHistoryFragment>(loadSession);
const appSidebarConfig = useMemo<AppSidebarConfig>(() => {
return {
@@ -237,13 +272,18 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
sessionId
);
if (requestSeq !== sessionLoadSeqRef.current) return;
setSession(nextSession ?? null);
setHasPinned(!!nextSession?.pinned);
if (!nextSession) {
// Drop stale tab if session no longer exists.
setOpenTabs(prev => prev.filter(tab => tab.sessionId !== sessionId));
return;
}
setSession(nextSession);
setHasPinned(!!nextSession.pinned);
} catch (error) {
console.error(error);
}
},
[doc, session?.sessionId]
[doc, session?.sessionId, setOpenTabs]
);
const openDoc = useCallback(
@@ -291,6 +331,26 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
[newSession, notificationService, session?.sessionId, t]
);
const closeTab = useCallback(
(sessionId: string) => {
let fallback: CopilotChatHistoryFragment | undefined;
setOpenTabs(prev => {
const idx = prev.findIndex(tab => tab.sessionId === sessionId);
if (idx === -1) return prev;
const next = prev.filter(tab => tab.sessionId !== sessionId);
fallback = next[idx] ?? next[idx - 1] ?? next[0];
return next;
});
if (session?.sessionId !== sessionId) return;
if (fallback) {
openSession(fallback.sessionId).catch(console.error);
} else {
newSession().catch(console.error);
}
},
[newSession, openSession, session?.sessionId, setOpenTabs]
);
const togglePin = useCallback(async () => {
const pinned = !session?.pinned;
setHasPinned(true);
@@ -347,7 +407,27 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
chatToolbar.remove();
setChatToolbar(null);
}
}, [chatContent, chatToolbar, session]);
if (chatTabs) {
chatTabs.remove();
setChatTabs(null);
}
}, [chatContent, chatTabs, chatToolbar, session]);
useEffect(() => {
if (!session?.sessionId) return;
setOpenTabs(prev => {
const existing = prev.findIndex(
tab => tab.sessionId === session.sessionId
);
if (existing !== -1) {
if (prev[existing] === session) return prev;
const next = prev.slice();
next[existing] = session;
return next;
}
return [...prev, session];
});
}, [session, setOpenTabs]);
useEffect(() => {
let disposed = false;
@@ -553,6 +633,30 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
togglePin,
]);
useEffect(() => {
if (!chatTabsContainerRef.current || !doc) {
return;
}
if (session === undefined) {
return;
}
let tabs = chatTabs;
if (!tabs) {
tabs = new AIChatTabs();
chatTabsContainerRef.current.append(tabs);
setChatTabs(tabs);
}
tabs.sessions = openTabs;
tabs.activeSessionId = session?.sessionId;
tabs.onSelectTab = (sessionId: string) => {
openSession(sessionId).catch(console.error);
};
tabs.onCloseTab = (sessionId: string) => {
closeTab(sessionId);
};
}, [chatTabs, closeTab, doc, openSession, openTabs, session]);
useEffect(() => {
if (!editor?.host || !chatContent) {
return;
@@ -654,6 +758,10 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
chatToolbarContainerRef.current = node;
}, []);
const onChatTabsContainerRef = useCallback((node: HTMLDivElement | null) => {
chatTabsContainerRef.current = node;
}, []);
const isEmbedding =
embeddingProgress[1] > 0 && embeddingProgress[0] < embeddingProgress[1];
const [done, total] = embeddingProgress;
@@ -690,6 +798,10 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
<CenterPeekIcon />
</div>
) : null}
<div
className={styles.tabsContainer}
ref={onChatTabsContainerRef}
/>
<div ref={onChatToolContainerRef} />
</div>
<div className={styles.content} ref={onChatContainerRef} />

View File

@@ -3,7 +3,13 @@ import type { IndexerPreferOptions, IndexerSyncState } from '@affine/nbstore';
import type { ReferenceParams } from '@blocksuite/affine/model';
import { fromPromise, LiveData, Service } from '@toeverything/infra';
import { isEmpty, omit } from 'lodash-es';
import { map, type Observable, of, switchMap } from 'rxjs';
import {
distinctUntilChanged,
map,
type Observable,
of,
switchMap,
} from 'rxjs';
import { z } from 'zod';
import { normalizeSearchText } from '../../../utils/normalize-search-text';
@@ -234,6 +240,20 @@ export class DocsSearchService extends Service {
})
.filter(ref => !!ref);
});
}),
// Only propagate downstream when the actual set of linked docs
// changes (a link was added or removed). Without this guard,
// every re-index triggered by typing emits a new array (same
// docs, arbitrary search-engine order) and the navigation panel
// visibly reorders on every keystroke.
//
// Note: this compares docId sets, not order. A stable, meaningful
// sort order (e.g. document appearance order) requires block
// position data from the indexer and is tracked separately.
distinctUntilChanged((prev, curr) => {
if (prev.length !== curr.length) return false;
const currIds = new Set(curr.map(r => r.docId));
return prev.every(r => currIds.has(r.docId));
})
);
}

View File

@@ -35,6 +35,7 @@ const ToggleButton = ({
className={className}
data-show={show}
data-testid="right-sidebar-toggle"
tooltip="Open sidebar"
>
<RightSidebarIcon />
</IconButton>

View File

@@ -1,8 +1,30 @@
import { IconButton } from '@affine/component';
import { RightSidebarIcon } from '@blocksuite/icons/rc';
import * as styles from './sidebar-header.css';
const RightSidebarOpenIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="1em"
height="1em"
fill="none"
style={{ userSelect: 'none', flexShrink: 0 }}
{...props}
>
<path
fill="currentColor"
fillRule="evenodd"
d="M15.25 6h3.25a.5.5 0 0 1 .5.5v11a.5.5 0 0 1-.5.5h-3.25zm-1.5 0H5.5a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h8.25zM3.5 6.5a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2z"
clipRule="evenodd"
/>
<path
fill="#1E96EB"
d="M15.25 6h3.25a.5.5 0 0 1 .5.5v11a.5.5 0 0 1-.5.5h-3.25z"
/>
</svg>
);
export type HeaderProps = {
onToggle?: () => void;
children?: React.ReactNode;
@@ -26,8 +48,13 @@ function Container({
const ToggleButton = ({ onToggle }: { onToggle?: () => void }) => {
return (
<IconButton size="24" onClick={onToggle} data-testid="right-sidebar-close">
<RightSidebarIcon />
<IconButton
size="24"
onClick={onToggle}
data-testid="right-sidebar-close"
tooltip="Close sidebar"
>
<RightSidebarOpenIcon />
</IconButton>
);
};

View File

@@ -13,7 +13,7 @@
"hi": 1,
"it": 94,
"ja": 93,
"ko": 94,
"ko": 93,
"nb-NO": 46,
"pl": 94,
"pt-BR": 93,
@@ -21,6 +21,6 @@
"sv-SE": 93,
"uk": 93,
"ur": 2,
"zh-Hans": 98,
"zh-Hans": 97,
"zh-Hant": 93
}

View File

@@ -2462,6 +2462,14 @@ export function useAFFiNEI18N(): {
* `AFFiNE workspace data`
*/
["com.affine.import.affine-workspace-data"](): string;
/**
* `Bear (.bear2bk) (Experimental)`
*/
["com.affine.import.bear"](): string;
/**
* `Import your Bear note backup. Tags will be converted to AFFiNE tags and folders.`
*/
["com.affine.import.bear.tooltip"](): string;
/**
* `Docx`
*/
@@ -2495,7 +2503,7 @@ export function useAFFiNEI18N(): {
*/
["com.affine.import.modal.tip"](): string;
/**
* `Notion`
* `Notion (Experimental)`
*/
["com.affine.import.notion"](): string;
/**
@@ -2503,7 +2511,7 @@ export function useAFFiNEI18N(): {
*/
["com.affine.import.notion.tooltip"](): string;
/**
* `Obsidian Vault`
* `Obsidian Vault (Experimental)`
*/
["com.affine.import.obsidian"](): string;
/**

View File

@@ -614,6 +614,8 @@
"com.affine.import-clipper.dialog.errorLoad": "Failed to load content, please try again.",
"com.affine.import_file": "Support Markdown/Notion",
"com.affine.import.affine-workspace-data": "AFFiNE workspace data",
"com.affine.import.bear": "Bear (.bear2bk) (Experimental)",
"com.affine.import.bear.tooltip": "Import your Bear note backup. Tags will be converted to AFFiNE tags and folders.",
"com.affine.import.docx": "Docx",
"com.affine.import.docx.tooltip": "Import your .docx file.",
"com.affine.import.html-files": "HTML",
@@ -622,9 +624,9 @@
"com.affine.import.markdown-with-media-files": "Markdown with media files (.zip)",
"com.affine.import.markdown-with-media-files.tooltip": "Please upload a markdown zip file with attachments, experimental function, there may be data loss.",
"com.affine.import.modal.tip": "If you'd like to request support for additional file types, feel free to let us know on",
"com.affine.import.notion": "Notion",
"com.affine.import.notion": "Notion (Experimental)",
"com.affine.import.notion.tooltip": "Import your Notion data. Supported import formats: HTML with subpages.",
"com.affine.import.obsidian": "Obsidian Vault",
"com.affine.import.obsidian": "Obsidian Vault (Experimental)",
"com.affine.import.obsidian.tooltip": "Import an Obsidian vault. Select a folder to import all notes, images, and assets with wikilinks resolved.",
"com.affine.import.snapshot": "Snapshot",
"com.affine.import.snapshot.tooltip": "Import your AFFiNE workspace and page snapshot file.",

View File

@@ -25,7 +25,7 @@
]
},
"devDependencies": {
"@napi-rs/cli": "3.5.0",
"@napi-rs/cli": "3.6.2",
"@napi-rs/whisper": "^0.0.4",
"@types/node": "^22.0.0",
"ava": "^7.0.0",

View File

@@ -721,7 +721,7 @@ export type EventArgs = {
dragStart: { type: string };
addEmbeddingDoc: {
type?: 'page' | 'edgeless';
control: 'addButton' | 'atMenu';
control: 'addButton' | 'atMenu' | 'dragDrop';
method: 'doc' | 'cur-doc' | 'file' | 'tags' | 'collections' | 'suggestion';
};
openAttachmentInFullscreen: AttachmentEventArgs;

View File

@@ -7,7 +7,7 @@
},
"devDependencies": {
"@affine-test/kit": "workspace:*",
"@playwright/test": "=1.58.2"
"@playwright/test": "=1.59.1"
},
"version": "0.26.3"
}

View File

@@ -7,7 +7,7 @@
},
"devDependencies": {
"@affine-test/kit": "workspace:*",
"@playwright/test": "=1.58.2"
"@playwright/test": "=1.59.1"
},
"version": "0.26.3"
}

View File

@@ -7,7 +7,7 @@
},
"devDependencies": {
"@affine-test/kit": "workspace:*",
"@playwright/test": "=1.58.2"
"@playwright/test": "=1.59.1"
},
"version": "0.26.3"
}

View File

@@ -8,10 +8,10 @@
"devDependencies": {
"@affine-test/kit": "workspace:*",
"@affine/electron-api": "workspace:*",
"@playwright/test": "=1.58.2",
"@playwright/test": "=1.59.1",
"@types/fs-extra": "^11.0.4",
"fs-extra": "^11.2.0",
"playwright": "=1.58.2"
"playwright": "=1.59.1"
},
"version": "0.26.3"
}

View File

@@ -7,7 +7,7 @@
},
"devDependencies": {
"@affine-test/kit": "workspace:*",
"@playwright/test": "=1.58.2"
"@playwright/test": "=1.59.1"
},
"version": "0.26.3"
}

View File

@@ -7,7 +7,7 @@
},
"devDependencies": {
"@affine-test/kit": "workspace:*",
"@playwright/test": "=1.58.2"
"@playwright/test": "=1.59.1"
},
"version": "0.26.3"
}

View File

@@ -9,7 +9,7 @@
"@affine-test/kit": "workspace:*",
"@blocksuite/affine": "workspace:*",
"@blocksuite/integration-test": "workspace:*",
"@playwright/test": "=1.58.2",
"@playwright/test": "=1.59.1",
"@toeverything/theme": "^1.1.23",
"json-stable-stringify": "^1.2.1"
},

View File

@@ -14,7 +14,7 @@
"@affine-tools/utils": "workspace:*",
"@blocksuite/affine": "workspace:*",
"@node-rs/argon2": "^2.0.2",
"@playwright/test": "=1.58.2",
"@playwright/test": "=1.59.1",
"@toeverything/infra": "workspace:*",
"express": "^5.1.0",
"http-proxy-middleware": "^3.0.5"

7784
yarn.lock

File diff suppressed because it is too large Load Diff