diff --git a/.github/workflows/android_ci.yaml.bak b/.github/workflows/android_ci.yaml.bak
index 4e159e845b..81e132cbf8 100644
--- a/.github/workflows/android_ci.yaml.bak
+++ b/.github/workflows/android_ci.yaml.bak
@@ -18,7 +18,7 @@ on:
env:
CARGO_TERM_COLOR: always
- FLUTTER_VERSION: "3.22.3"
+ FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
CARGO_MAKE_VERSION: "0.37.18"
CLOUD_VERSION: 0.6.54-amd64
diff --git a/.github/workflows/flutter_ci.yaml b/.github/workflows/flutter_ci.yaml
index 42daeca881..1fc1b0e052 100644
--- a/.github/workflows/flutter_ci.yaml
+++ b/.github/workflows/flutter_ci.yaml
@@ -25,7 +25,7 @@ on:
env:
CARGO_TERM_COLOR: always
- FLUTTER_VERSION: "3.22.2"
+ FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
CARGO_MAKE_VERSION: "0.37.18"
CLOUD_VERSION: 0.6.54-amd64
@@ -40,7 +40,7 @@ jobs:
strategy:
fail-fast: true
matrix:
- os: [ ubuntu-latest ]
+ os: [ubuntu-latest]
include:
- os: ubuntu-latest
flutter_profile: development-linux-x86_64
@@ -74,7 +74,7 @@ jobs:
strategy:
fail-fast: true
matrix:
- os: [ windows-latest ]
+ os: [windows-latest]
include:
- os: windows-latest
flutter_profile: development-windows-x86
@@ -101,7 +101,7 @@ jobs:
strategy:
fail-fast: true
matrix:
- os: [ macos-latest ]
+ os: [macos-latest]
include:
- os: macos-latest
flutter_profile: development-mac-x86_64
@@ -123,12 +123,12 @@ jobs:
flutter_profile: ${{ matrix.flutter_profile }}
unit_test:
- needs: [ prepare-linux ]
+ needs: [prepare-linux]
if: github.event.pull_request.draft != true
strategy:
fail-fast: false
matrix:
- os: [ ubuntu-latest ]
+ os: [ubuntu-latest]
include:
- os: ubuntu-latest
flutter_profile: development-linux-x86_64
@@ -217,11 +217,11 @@ jobs:
shell: bash
cloud_integration_test:
- needs: [ prepare-linux ]
+ needs: [prepare-linux]
strategy:
fail-fast: false
matrix:
- os: [ ubuntu-latest ]
+ os: [ubuntu-latest]
include:
- os: ubuntu-latest
flutter_profile: development-linux-x86_64
@@ -340,13 +340,13 @@ jobs:
shell: bash
integration_test:
- needs: [ prepare-linux ]
+ needs: [prepare-linux]
if: github.event.pull_request.draft != true
strategy:
fail-fast: false
matrix:
- os: [ ubuntu-latest ]
- test_number: [ 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
+ os: [ubuntu-latest]
+ test_number: [1, 2, 3, 4, 5, 6, 7, 8, 9]
include:
- os: ubuntu-latest
target: "x86_64-unknown-linux-gnu"
diff --git a/.github/workflows/ios_ci.yaml b/.github/workflows/ios_ci.yaml
index d9bee0242a..e13863f4a7 100644
--- a/.github/workflows/ios_ci.yaml
+++ b/.github/workflows/ios_ci.yaml
@@ -18,7 +18,7 @@ on:
- "!frontend/appflowy_web_app/**"
env:
- FLUTTER_VERSION: "3.22.3"
+ FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
concurrency:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 91e6b99565..a4582ffa74 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -6,7 +6,7 @@ on:
- "*"
env:
- FLUTTER_VERSION: "3.22.0"
+ FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
jobs:
@@ -232,10 +232,10 @@ jobs:
matrix:
job:
- {
- targets: "aarch64-apple-darwin,x86_64-apple-darwin",
- os: macos-latest,
- extra-build-args: "",
- }
+ targets: "aarch64-apple-darwin,x86_64-apple-darwin",
+ os: macos-latest,
+ extra-build-args: "",
+ }
steps:
- name: Checkout source code
uses: actions/checkout@v4
@@ -336,12 +336,12 @@ jobs:
matrix:
job:
- {
- arch: x86_64,
- target: x86_64-unknown-linux-gnu,
- os: ubuntu-20.04,
- extra-build-args: "",
- flutter_profile: production-linux-x86_64,
- }
+ arch: x86_64,
+ target: x86_64-unknown-linux-gnu,
+ os: ubuntu-22.04,
+ extra-build-args: "",
+ flutter_profile: production-linux-x86_64,
+ }
steps:
- name: Checkout source code
uses: actions/checkout@v4
diff --git a/.github/workflows/rust_coverage.yml b/.github/workflows/rust_coverage.yml
index 916f19d9fc..53a5f66748 100644
--- a/.github/workflows/rust_coverage.yml
+++ b/.github/workflows/rust_coverage.yml
@@ -10,7 +10,7 @@ on:
env:
CARGO_TERM_COLOR: always
- FLUTTER_VERSION: "3.22.0"
+ FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
jobs:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6ce72e8997..a5e7e268a5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,83 @@
# Release Notes
+## Version 0.8.9 - 16/04/2025
+### Desktop
+#### New Features
+- Supported pasting a link as a mention, providing a more condensed visualization of linked content
+- Supported converting between link formats (e.g. transforming a mention into a bookmark)
+- Improved the link editing experience with enhanced UX
+- Added OTP (One-Time Password) support for sign-in authentication
+- Added latest AI models: GPT-4.1, GPT-4.1-mini, and Claude 3.7 Sonnet
+#### Bug Fixes
+- Fixed an issue where properties were not displaying in the row detail page
+- Fixed a bug where Undo didn't work in the row detail page
+- Fixed an issue where blocks didn't grow when the grid got bigger
+- Fixed several bugs related to AI writers
+### Mobile
+#### New Features
+- Added sign-in with OTP (One-Time Password)
+#### Bug Fixes
+- Fixed an issue where the slash menu sometimes failed to display
+- Updated the mention page block to handle page selection with more context.
+
+## Version 0.8.8 - 01/04/2025
+### New Features
+- Added support for selecting AI models in AI writer
+- Revamped link menu in toolbar
+- Added support for using ":" to add emojis in documents
+- Passed the history of past AI prompts and responses to AI writer
+### Bug Fixes
+- Improved AI writer scrolling user experience
+- Fixed issue where checklist items would disappear during reordering
+- Fixed numbered lists generated by AI to maintain the same index as the input
+
+## Version 0.8.7 - 18/03/2025
+### New Features
+- Made local AI free and integrated with Ollama
+- Supported nested lists within callout and quote blocks
+- Revamped the document's floating toolbar and added Turn Into
+- Enabled custom icons in callout blocks
+### Bug Fixes
+- Fixed occasional incorrect positioning of the slash menu
+- Improved AI Chat and AI Writers with various bug fixes
+- Adjusted the columns block to match the width of the editor
+- Fixed a potential segfault caused by infinite recursion in the trash view
+- Resolved an issue where the first added cover might be invisible
+- Fixed adding cover images via Unsplash
+
+## Version 0.8.6 - 06/03/2025
+### Bug Fixes
+- Fix the incorrect title positioning when adjusting the document width setting
+- Enhance the user experience of the icon color picker for smoother interactions
+- Add missing icons to the database to ensure completeness and consistency
+- Resolve the issue with links not functioning correctly on Linux systems
+- Improve the outline feature to work seamlessly within columns
+- Center the bulleted list icon within columns for better visual alignment
+- Enable dragging blocks under tables in the second column to enhance flexibility
+- Disable the AI writer feature within tables to prevent conflicts and improve usability
+- Automatically enable the header row when converting content from Markdown to ensure proper formatting
+- Use the "Undo" function to revert the auto-formatting
+
+## Version 0.8.5 - 04/03/2025
+### New Features
+- Columns in Documents: Arrange content side by side using drag-and-drop or the slash menu
+- AI Writers: New AI assistants in documents with response formatting options (list, table, text with images, image-only), follow-up questions, contextual memory, and more
+- Compact Mode for Databases: Enable compact mode for grid and kanban views (full-page and inline) to increase information density, displaying more data per screen
+### Bug Fixes
+- Fixed an issue where callout blocks couldn’t be deleted when appearing as the first line in a document
+- Fixed a bug preventing the relation field in databases from opening
+- Fixed an issue where links in documents were unclickable on Linux
+
+## Version 0.8.4 - 18/02/2025
+### New Features
+- Switch AI mode on mobile
+- Support locking page
+- Support uploading svg file as icon
+- Support the slash, at, and plus menus on mobile
+### Bug Fixes
+- Gallery not rendering in row page
+- Save image should not copy the image (mobile)
+- Support exporting more content to markdown
+
## Version 0.8.2 - 23/01/2025
### New Features
- Customized database view icons
diff --git a/README.md b/README.md
index b9606b8844..565908e756 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
- AppFlowy.IO
+ AppFlowy
⭐️ The Open Source Alternative To Notion ⭐️
@@ -18,18 +18,18 @@ AppFlowy is the AI workspace where you achieve more without losing control of yo
- Website •
+ Website •
Forum •
Discord •
Reddit •
Twitter
-
-
-
-
-
+
+
+
+
+
@@ -48,7 +48,7 @@ AppFlowy is the AI workspace where you achieve more without losing control of yo
- [App Store](https://apps.apple.com/app/appflowy/id6457261352): iPhone
- [Play Store](https://play.google.com/store/apps/details?id=io.appflowy.appflowy): Android 10 or above; ARMv7 is
not supported
-- [Self-hosting AppFlowy](https://docs.appflowy.io/docs/guides/appflowy/self-hosting-appflowy)
+- [Self-hosting AppFlowy](https://appflowy.com/docs/self-host-appflowy-overview)
- [Source](https://docs.appflowy.io/docs/documentation/appflowy/from-source)
## Built With
@@ -78,7 +78,7 @@ report [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labe
## **Releases**
-Please see the [changelog](https://www.appflowy.io/whatsnew) for more details about a given release.
+Please see the [changelog](https://appflowy.com/what-is-new) for more details about a given release.
## Contributing
@@ -89,9 +89,7 @@ for details.
If your Pull Request is accepted as it fixes a bug, adds functionality, or makes AppFlowy's codebase significantly
easier to use or understand, **Congratulations!** If your administrative and managerial work behind the scenes sustains
-the community, **Congratulations!** You are now an official contributor to AppFlowy. Get in touch with
-us ([link](https://tally.so/r/mKP5z3)) to receive the very special Contributor T-shirt!
-Proudly wear your T-shirt and show it to us by tagging [@appflowy](https://twitter.com/appflowy) on Twitter.
+the community, **Congratulations!** You are now an official contributor to AppFlowy.
## Translations 🌎🗺
@@ -152,8 +150,8 @@ more information.
## Acknowledgments
-Special thanks to these amazing projects which help power AppFlowy.IO:
+Special thanks to these amazing projects which help power AppFlowy:
- [cargo-make](https://github.com/sagiegurari/cargo-make)
- [contrib.rocks](https://contrib.rocks)
-- [flutter_chat_ui](https://pub.dev/packages/flutter_chat_ui)
\ No newline at end of file
+- [flutter_chat_ui](https://pub.dev/packages/flutter_chat_ui)
diff --git a/codemagic.yaml b/codemagic.yaml
index b8934d8be8..9ba2a1a562 100644
--- a/codemagic.yaml
+++ b/codemagic.yaml
@@ -4,7 +4,7 @@ workflows:
instance_type: mac_mini_m2
max_build_duration: 30
environment:
- flutter: 3.22.3
+ flutter: 3.27.4
xcode: latest
cocoapods: default
diff --git a/frontend/Makefile.toml b/frontend/Makefile.toml
index 95cfe761a1..41fdffb1af 100644
--- a/frontend/Makefile.toml
+++ b/frontend/Makefile.toml
@@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
CARGO_MAKE_CRATE_NAME = "dart-ffi"
LIB_NAME = "dart_ffi"
-APPFLOWY_VERSION = "0.8.2"
+APPFLOWY_VERSION = "0.8.9"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
diff --git a/frontend/appflowy_flutter/analysis_options.yaml b/frontend/appflowy_flutter/analysis_options.yaml
index 8da401ef26..4579b2d8c5 100644
--- a/frontend/appflowy_flutter/analysis_options.yaml
+++ b/frontend/appflowy_flutter/analysis_options.yaml
@@ -4,6 +4,7 @@ analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
+ - "packages/**/*.dart"
linter:
rules:
diff --git a/frontend/appflowy_flutter/android/app/src/main/AndroidManifest.xml b/frontend/appflowy_flutter/android/app/src/main/AndroidManifest.xml
index 74d5c5e494..f746eeb610 100644
--- a/frontend/appflowy_flutter/android/app/src/main/AndroidManifest.xml
+++ b/frontend/appflowy_flutter/android/app/src/main/AndroidManifest.xml
@@ -43,6 +43,8 @@
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java
-->
+
diff --git a/frontend/appflowy_flutter/assets/fonts/.gitkeep b/frontend/appflowy_flutter/assets/fonts/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frontend/appflowy_flutter/assets/fonts/FlowyIconData.ttf b/frontend/appflowy_flutter/assets/fonts/FlowyIconData.ttf
deleted file mode 100644
index 8f03a5c8f9..0000000000
Binary files a/frontend/appflowy_flutter/assets/fonts/FlowyIconData.ttf and /dev/null differ
diff --git a/frontend/appflowy_flutter/assets/test/images/sample.svg b/frontend/appflowy_flutter/assets/test/images/sample.svg
new file mode 100644
index 0000000000..7dcd6907d8
--- /dev/null
+++ b/frontend/appflowy_flutter/assets/test/images/sample.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/frontend/appflowy_flutter/assets/translations/mr-IN.json b/frontend/appflowy_flutter/assets/translations/mr-IN.json
new file mode 100644
index 0000000000..f86a1e0081
--- /dev/null
+++ b/frontend/appflowy_flutter/assets/translations/mr-IN.json
@@ -0,0 +1,3210 @@
+{
+ "appName": "AppFlowy",
+ "defaultUsername": "मी",
+ "welcomeText": "@:appName मध्ये आ पले स्वागत आहे.",
+ "welcomeTo": "मध्ये आ पले स्वागत आ हे",
+ "githubStarText": "GitHub वर स्टार करा",
+ "subscribeNewsletterText": "वृत्तपत्राची सदस्यता घ्या",
+ "letsGoButtonText": "क्विक स्टार्ट",
+ "title": "Title",
+ "youCanAlso": "तुम्ही देखील",
+ "and": "आ णि",
+ "failedToOpenUrl": "URL उघडण्यात अयशस्वी: {}",
+ "blockActions": {
+ "addBelowTooltip": "खाली जोडण्यासाठी क्लिक करा",
+ "addAboveCmd": "Alt+click",
+ "addAboveMacCmd": "Option+click",
+ "addAboveTooltip": "वर जोडण्यासाठी",
+ "dragTooltip": "Drag to move",
+ "openMenuTooltip": "मेनू उघडण्यासाठी क्लिक करा"
+ },
+ "signUp": {
+ "buttonText": "साइन अप",
+ "title": "साइन अप to @:appName",
+ "getStartedText": "सुरुवात करा",
+ "emptyPasswordError": "पासवर्ड रिकामा असू शकत नाही",
+ "repeatPasswordEmptyError": "Repeat पासवर्ड रिकामा असू शकत नाही",
+ "unmatchedPasswordError": "पुन्हा लिहिलेला पासवर्ड मूळ पासवर्डशी जुळत नाही",
+ "alreadyHaveAnAccount": "आधीच खाते आहे?",
+ "emailHint": "Email",
+ "passwordHint": "Password",
+ "repeatPasswordHint": "पासवर्ड पुन्हा लिहा",
+ "signUpWith": "यामध्ये साइन अप करा:"
+ },
+ "signIn": {
+ "loginTitle": "@:appName मध्ये लॉगिन करा",
+ "loginButtonText": "लॉगिन",
+ "loginStartWithAnonymous": "अनामिक सत्रासह पुढे जा",
+ "continueAnonymousUser": "अनामिक सत्रासह पुढे जा",
+ "anonymous": "अनामिक",
+ "buttonText": "साइन इन",
+ "signingInText": "साइन इन होत आहे...",
+ "forgotPassword": "पासवर्ड विसरलात?",
+ "emailHint": "ईमेल",
+ "passwordHint": "पासवर्ड",
+ "dontHaveAnAccount": "तुमचं खाते नाही?",
+ "createAccount": "खाते तयार करा",
+ "repeatPasswordEmptyError": "पुन्हा पासवर्ड रिकामा असू शकत नाही",
+ "unmatchedPasswordError": "पुन्हा लिहिलेला पासवर्ड मूळ पासवर्डशी जुळत नाही",
+ "syncPromptMessage": "डेटा सिंक होण्यास थोडा वेळ लागू शकतो. कृपया हे पृष्ठ बंद करू नका",
+ "or": "किंवा",
+ "signInWithGoogle": "Google सह पुढे जा",
+ "signInWithGithub": "GitHub सह पुढे जा",
+ "signInWithDiscord": "Discord सह पुढे जा",
+ "signInWithApple": "Apple सह पुढे जा",
+ "continueAnotherWay": "इतर पर्यायांनी पुढे जा",
+ "signUpWithGoogle": "Google सह साइन अप करा",
+ "signUpWithGithub": "GitHub सह साइन अप करा",
+ "signUpWithDiscord": "Discord सह साइन अप करा",
+ "signInWith": "यासह पुढे जा:",
+ "signInWithEmail": "ईमेलसह पुढे जा",
+ "signInWithMagicLink": "पुढे जा",
+ "signUpWithMagicLink": "Magic Link सह साइन अप करा",
+ "pleaseInputYourEmail": "कृपया तुमचा ईमेल पत्ता टाका",
+ "settings": "सेटिंग्ज",
+ "magicLinkSent": "Magic Link पाठवण्यात आली आहे!",
+ "invalidEmail": "कृपया वैध ईमेल पत्ता टाका",
+ "alreadyHaveAnAccount": "आधीच खाते आहे?",
+ "logIn": "लॉगिन",
+ "generalError": "काहीतरी चुकलं. कृपया नंतर प्रयत्न करा",
+ "limitRateError": "सुरक्षेच्या कारणास्तव, तुम्ही दर ६० सेकंदांतून एकदाच Magic Link मागवू शकता",
+ "magicLinkSentDescription": "तुमच्या ईमेलवर Magic Link पाठवण्यात आली आहे. लॉगिन पूर्ण करण्यासाठी लिंकवर क्लिक करा. ही लिंक ५ मिनिटांत कालबाह्य होईल."
+ },
+ "workspace": {
+ "chooseWorkspace": "तुमचे workspace निवडा",
+ "defaultName": "माझे Workspace",
+ "create": "नवीन workspace तयार करा",
+ "new": "नवीन workspace",
+ "importFromNotion": "Notion मधून आयात करा",
+ "learnMore": "अधिक जाणून घ्या",
+ "reset": "workspace रीसेट करा",
+ "renameWorkspace": "workspace चे नाव बदला",
+ "workspaceNameCannotBeEmpty": "workspace चे नाव रिकामे असू शकत नाही",
+ "resetWorkspacePrompt": "workspace रीसेट केल्यास त्यातील सर्व पृष्ठे आणि डेटा हटवले जातील. तुम्हाला workspace रीसेट करायचे आहे का? पर्यायी म्हणून तुम्ही सपोर्ट टीमशी संपर्क करू शकता.",
+ "hint": "workspace",
+ "notFoundError": "workspace सापडले नाही",
+ "failedToLoad": "काहीतरी चूक झाली! workspace लोड होण्यात अयशस्वी. कृपया @:appName चे कोणतेही उघडे instance बंद करा आणि पुन्हा प्रयत्न करा.",
+ "errorActions": {
+ "reportIssue": "समस्या नोंदवा",
+ "reportIssueOnGithub": "Github वर समस्या नोंदवा",
+ "exportLogFiles": "लॉग फाइल्स निर्यात करा",
+ "reachOut": "Discord वर संपर्क करा"
+ },
+ "menuTitle": "कार्यक्षेत्रे",
+ "deleteWorkspaceHintText": "तुम्हाला हे कार्यक्षेत्र हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही आणि तुम्ही प्रकाशित केलेली कोणतीही पृष्ठे अप्रकाशित होतील.",
+ "createSuccess": "कार्यक्षेत्र यशस्वीरित्या तयार झाले",
+ "createFailed": "कार्यक्षेत्र तयार करण्यात अयशस्वी",
+ "createLimitExceeded": "तुम्ही तुमच्या खात्यासाठी परवानगी दिलेल्या कार्यक्षेत्र मर्यादेपर्यंत पोहोचलात. अधिक कार्यक्षेत्रासाठी GitHub वर विनंती करा.",
+ "deleteSuccess": "कार्यक्षेत्र यशस्वीरित्या हटवले गेले",
+ "deleteFailed": "कार्यक्षेत्र हटवण्यात अयशस्वी",
+ "openSuccess": "कार्यक्षेत्र यशस्वीरित्या उघडले",
+ "openFailed": "कार्यक्षेत्र उघडण्यात अयशस्वी",
+ "renameSuccess": "कार्यक्षेत्राचे नाव यशस्वीरित्या बदलले",
+ "renameFailed": "कार्यक्षेत्राचे नाव बदलण्यात अयशस्वी",
+ "updateIconSuccess": "कार्यक्षेत्राचे चिन्ह यशस्वीरित्या अद्यतनित केले",
+ "updateIconFailed": "कार्यक्षेत्राचे चिन्ह अद्यतनित करण्यात अयशस्वी",
+ "cannotDeleteTheOnlyWorkspace": "फक्त एकच कार्यक्षेत्र असल्यास ते हटवता येत नाही",
+ "fetchWorkspacesFailed": "कार्यक्षेत्रे मिळवण्यात अयशस्वी",
+ "leaveCurrentWorkspace": "कार्यक्षेत्र सोडा",
+ "leaveCurrentWorkspacePrompt": "तुम्हाला हे कार्यक्षेत्र सोडायचे आहे का?"
+ },
+ "shareAction": {
+ "buttonText": "शेअर करा",
+ "workInProgress": "लवकरच येत आहे",
+ "markdown": "Markdown",
+ "html": "HTML",
+ "clipboard": "क्लिपबोर्डवर कॉपी करा",
+ "csv": "CSV",
+ "copyLink": "लिंक कॉपी करा",
+ "publishToTheWeb": "वेबवर प्रकाशित करा",
+ "publishToTheWebHint": "AppFlowy सह वेबसाइट तयार करा",
+ "publish": "प्रकाशित करा",
+ "unPublish": "अप्रकाशित करा",
+ "visitSite": "साइटला भेट द्या",
+ "exportAsTab": "या स्वरूपात निर्यात करा",
+ "publishTab": "प्रकाशित करा",
+ "shareTab": "शेअर करा",
+ "publishOnAppFlowy": "AppFlowy वर प्रकाशित करा",
+ "shareTabTitle": "सहकार्य करण्यासाठी आमंत्रित करा",
+ "shareTabDescription": "कोणासही सहज सहकार्य करण्यासाठी",
+ "copyLinkSuccess": "लिंक क्लिपबोर्डवर कॉपी केली",
+ "copyShareLink": "शेअर लिंक कॉपी करा",
+ "copyLinkFailed": "लिंक क्लिपबोर्डवर कॉपी करण्यात अयशस्वी",
+ "copyLinkToBlockSuccess": "ब्लॉकची लिंक क्लिपबोर्डवर कॉपी केली",
+ "copyLinkToBlockFailed": "ब्लॉकची लिंक क्लिपबोर्डवर कॉपी करण्यात अयशस्वी",
+ "manageAllSites": "सर्व साइट्स व्यवस्थापित करा",
+ "updatePathName": "पथाचे नाव अपडेट करा"
+ },
+ "moreAction": {
+ "small": "लहान",
+ "medium": "मध्यम",
+ "large": "मोठा",
+ "fontSize": "फॉन्ट आकार",
+ "import": "Import",
+ "moreOptions": "अधिक पर्याय",
+ "wordCount": "शब्द संख्या: {}",
+ "charCount": "अक्षर संख्या: {}",
+ "createdAt": "निर्मिती: {}",
+ "deleteView": "हटवा",
+ "duplicateView": "प्रत बनवा",
+ "wordCountLabel": "शब्द संख्या: ",
+ "charCountLabel": "अक्षर संख्या: ",
+ "createdAtLabel": "निर्मिती: ",
+ "syncedAtLabel": "सिंक केले: ",
+ "saveAsNewPage": "संदेश पृष्ठात जोडा",
+ "saveAsNewPageDisabled": "उपलब्ध संदेश नाहीत"
+ },
+ "importPanel": {
+ "textAndMarkdown": "मजकूर आणि Markdown",
+ "documentFromV010": "v0.1.0 पासून दस्तऐवज",
+ "databaseFromV010": "v0.1.0 पासून डेटाबेस",
+ "notionZip": "Notion निर्यात केलेली Zip फाईल",
+ "csv": "CSV",
+ "database": "डेटाबेस"
+ },
+ "emojiIconPicker": {
+ "iconUploader": {
+ "placeholderLeft": "फाईल ड्रॅग आणि ड्रॉप करा, क्लिक करा ",
+ "placeholderUpload": "अपलोड",
+ "placeholderRight": ", किंवा इमेज लिंक पेस्ट करा.",
+ "dropToUpload": "अपलोडसाठी फाईल ड्रॉप करा",
+ "change": "बदला"
+ }
+ },
+ "disclosureAction": {
+ "rename": "नाव बदला",
+ "delete": "हटवा",
+ "duplicate": "प्रत बनवा",
+ "unfavorite": "आवडतीतून काढा",
+ "favorite": "आवडतीत जोडा",
+ "openNewTab": "नवीन टॅबमध्ये उघडा",
+ "moveTo": "या ठिकाणी हलवा",
+ "addToFavorites": "आवडतीत जोडा",
+ "copyLink": "लिंक कॉपी करा",
+ "changeIcon": "आयकॉन बदला",
+ "collapseAllPages": "सर्व उपपृष्ठे संकुचित करा",
+ "movePageTo": "पृष्ठ हलवा",
+ "move": "हलवा",
+ "lockPage": "पृष्ठ लॉक करा"
+ },
+ "blankPageTitle": "रिक्त पृष्ठ",
+ "newPageText": "नवीन पृष्ठ",
+ "newDocumentText": "नवीन दस्तऐवज",
+ "newGridText": "नवीन ग्रिड",
+ "newCalendarText": "नवीन कॅलेंडर",
+ "newBoardText": "नवीन बोर्ड",
+ "chat": {
+ "newChat": "AI गप्पा",
+ "inputMessageHint": "@:appName AI ला विचार करा",
+ "inputLocalAIMessageHint": "@:appName लोकल AI ला विचार करा",
+ "unsupportedCloudPrompt": "ही सुविधा फक्त @:appName Cloud वापरताना उपलब्ध आहे",
+ "relatedQuestion": "सूचवलेले",
+ "serverUnavailable": "कनेक्शन गमावले. कृपया तुमचे इंटरनेट तपासा आणि पुन्हा प्रयत्न करा",
+ "aiServerUnavailable": "AI सेवा सध्या अनुपलब्ध आहे. कृपया नंतर पुन्हा प्रयत्न करा.",
+ "retry": "पुन्हा प्रयत्न करा",
+ "clickToRetry": "पुन्हा प्रयत्न करण्यासाठी क्लिक करा",
+ "regenerateAnswer": "उत्तर पुन्हा तयार करा",
+ "question1": "Kanban वापरून कामे कशी व्यवस्थापित करायची",
+ "question2": "GTD पद्धत समजावून सांगा",
+ "question3": "Rust का वापरावा",
+ "question4": "माझ्या स्वयंपाकघरात असलेल्या वस्तूंनी रेसिपी",
+ "question5": "माझ्या पृष्ठासाठी एक चित्र तयार करा",
+ "question6": "या आठवड्याची माझी कामांची यादी तयार करा",
+ "aiMistakePrompt": "AI चुकू शकतो. महत्त्वाची माहिती तपासा.",
+ "chatWithFilePrompt": "तुम्हाला फाइलसोबत गप्पा मारायच्या आहेत का?",
+ "indexFileSuccess": "फाईल यशस्वीरित्या अनुक्रमित केली गेली",
+ "inputActionNoPages": "काहीही पृष्ठे सापडली नाहीत",
+ "referenceSource": {
+ "zero": "0 स्रोत सापडले",
+ "one": "{count} स्रोत सापडला",
+ "other": "{count} स्रोत सापडले"
+ }
+ },
+ "clickToMention": "पृष्ठाचा उल्लेख करा",
+ "uploadFile": "PDFs, मजकूर किंवा markdown फाइल्स जोडा",
+ "questionDetail": "नमस्कार {}! मी तुम्हाला कशी मदत करू शकतो?",
+ "indexingFile": "{} अनुक्रमित करत आहे",
+ "generatingResponse": "उत्तर तयार होत आहे",
+ "selectSources": "स्रोत निवडा",
+ "currentPage": "सध्याचे पृष्ठ",
+ "sourcesLimitReached": "तुम्ही फक्त ३ मुख्य दस्तऐवज आणि त्यांची उपदस्तऐवज निवडू शकता",
+ "sourceUnsupported": "सध्या डेटाबेससह चॅटिंगसाठी आम्ही समर्थन करत नाही",
+ "regenerate": "पुन्हा प्रयत्न करा",
+ "addToPageButton": "संदेश पृष्ठावर जोडा",
+ "addToPageTitle": "या पृष्ठात संदेश जोडा...",
+ "addToNewPage": "नवीन पृष्ठ तयार करा",
+ "addToNewPageName": "\"{}\" मधून काढलेले संदेश",
+ "addToNewPageSuccessToast": "संदेश जोडण्यात आला",
+ "openPagePreviewFailedToast": "पृष्ठ उघडण्यात अयशस्वी",
+ "changeFormat": {
+ "actionButton": "फॉरमॅट बदला",
+ "confirmButton": "या फॉरमॅटसह पुन्हा तयार करा",
+ "textOnly": "मजकूर",
+ "imageOnly": "फक्त प्रतिमा",
+ "textAndImage": "मजकूर आणि प्रतिमा",
+ "text": "परिच्छेद",
+ "bullet": "बुलेट यादी",
+ "number": "क्रमांकित यादी",
+ "table": "सारणी",
+ "blankDescription": "उत्तराचे फॉरमॅट ठरवा",
+ "defaultDescription": "स्वयंचलित उत्तर फॉरमॅट",
+ "textWithImageDescription": "@:chat.changeFormat.text प्रतिमेसह",
+ "numberWithImageDescription": "@:chat.changeFormat.number प्रतिमेसह",
+ "bulletWithImageDescription": "@:chat.changeFormat.bullet प्रतिमेसह",
+ " tableWithImageDescription": "@:chat.changeFormat.table प्रतिमेसह"
+ },
+ "switchModel": {
+ "label": "मॉडेल बदला",
+ "localModel": "स्थानिक मॉडेल",
+ "cloudModel": "क्लाऊड मॉडेल",
+ "autoModel": "स्वयंचलित"
+ },
+ "selectBanner": {
+ "saveButton": "… मध्ये जोडा",
+ "selectMessages": "संदेश निवडा",
+ "nSelected": "{} निवडले गेले",
+ "allSelected": "सर्व निवडले गेले"
+ },
+ "stopTooltip": "उत्पन्न करणे थांबवा",
+ "trash": {
+ "text": "कचरा",
+ "restoreAll": "सर्व पुनर्संचयित करा",
+ "restore": "पुनर्संचयित करा",
+ "deleteAll": "सर्व हटवा",
+ "pageHeader": {
+ "fileName": "फाईलचे नाव",
+ "lastModified": "शेवटचा बदल",
+ "created": "निर्मिती"
+ }
+ },
+ "confirmDeleteAll": {
+ "title": "कचरापेटीतील सर्व पृष्ठे",
+ "caption": "तुम्हाला कचरापेटीतील सर्व काही हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही."
+ },
+ "confirmRestoreAll": {
+ "title": "कचरापेटीतील सर्व पृष्ठे पुनर्संचयित करा",
+ "caption": "ही कृती पूर्ववत केली जाऊ शकत नाही."
+ },
+ "restorePage": {
+ "title": "पुनर्संचयित करा: {}",
+ "caption": "तुम्हाला हे पृष्ठ पुनर्संचयित करायचे आहे का?"
+ },
+ "mobile": {
+ "actions": "कचरा क्रिया",
+ "empty": "कचरापेटीत कोणतीही पृष्ठे किंवा जागा नाहीत",
+ "emptyDescription": "जे आवश्यक नाही ते कचरापेटीत हलवा.",
+ "isDeleted": "हटवले गेले आहे",
+ "isRestored": "पुनर्संचयित केले गेले आहे"
+ },
+ "confirmDeleteTitle": "तुम्हाला हे पृष्ठ कायमचे हटवायचे आहे का?",
+ "deletePagePrompt": {
+ "text": "हे पृष्ठ कचरापेटीत आहे",
+ "restore": "पृष्ठ पुनर्संचयित करा",
+ "deletePermanent": "कायमचे हटवा",
+ "deletePermanentDescription": "तुम्हाला हे पृष्ठ कायमचे हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही."
+ },
+ "dialogCreatePageNameHint": "पृष्ठाचे नाव",
+ "questionBubble": {
+ "shortcuts": "शॉर्टकट्स",
+ "whatsNew": "नवीन काय आहे?",
+ "help": "मदत आणि समर्थन",
+ "markdown": "Markdown",
+ "debug": {
+ "name": "डीबग माहिती",
+ "success": "डीबग माहिती क्लिपबोर्डवर कॉपी केली!",
+ "fail": "डीबग माहिती कॉपी करता आली नाही"
+ },
+ "feedback": "अभिप्राय"
+ },
+ "menuAppHeader": {
+ "moreButtonToolTip": "हटवा, नाव बदला आणि अधिक...",
+ "addPageTooltip": "तत्काळ एक पृष्ठ जोडा",
+ "defaultNewPageName": "शीर्षक नसलेले",
+ "renameDialog": "नाव बदला",
+ "pageNameSuffix": "प्रत"
+ },
+ "noPagesInside": "अंदर कोणतीही पृष्ठे नाहीत",
+ "toolbar": {
+ "undo": "पूर्ववत करा",
+ "redo": "पुन्हा करा",
+ "bold": "ठळक",
+ "italic": "तिरकस",
+ "underline": "अधोरेखित",
+ "strike": "मागे ओढलेले",
+ "numList": "क्रमांकित यादी",
+ "bulletList": "बुलेट यादी",
+ "checkList": "चेक यादी",
+ "inlineCode": "इनलाइन कोड",
+ "quote": "उद्धरण ब्लॉक",
+ "header": "शीर्षक",
+ "highlight": "हायलाइट",
+ "color": "रंग",
+ "addLink": "लिंक जोडा"
+ },
+ "tooltip": {
+ "lightMode": "लाइट मोडमध्ये स्विच करा",
+ "darkMode": "डार्क मोडमध्ये स्विच करा",
+ "openAsPage": "पृष्ठ म्हणून उघडा",
+ "addNewRow": "नवीन पंक्ती जोडा",
+ "openMenu": "मेनू उघडण्यासाठी क्लिक करा",
+ "dragRow": "पंक्तीचे स्थान बदलण्यासाठी ड्रॅग करा",
+ "viewDataBase": "डेटाबेस पहा",
+ "referencePage": "हे {name} संदर्भित आहे",
+ "addBlockBelow": "खाली एक ब्लॉक जोडा",
+ "aiGenerate": "निर्मिती करा"
+ },
+ "sideBar": {
+ "closeSidebar": "साइडबार बंद करा",
+ "openSidebar": "साइडबार उघडा",
+ "expandSidebar": "पूर्ण पृष्ठावर विस्तारित करा",
+ "personal": "वैयक्तिक",
+ "private": "खाजगी",
+ "workspace": "कार्यक्षेत्र",
+ "favorites": "आवडती",
+ "clickToHidePrivate": "खाजगी जागा लपवण्यासाठी क्लिक करा\nयेथे तयार केलेली पाने फक्त तुम्हाला दिसतील",
+ "clickToHideWorkspace": "कार्यक्षेत्र लपवण्यासाठी क्लिक करा\nयेथे तयार केलेली पाने सर्व सदस्यांना दिसतील",
+ "clickToHidePersonal": "वैयक्तिक जागा लपवण्यासाठी क्लिक करा",
+ "clickToHideFavorites": "आवडती जागा लपवण्यासाठी क्लिक करा",
+ "addAPage": "नवीन पृष्ठ जोडा",
+ "addAPageToPrivate": "खाजगी जागेत पृष्ठ जोडा",
+ "addAPageToWorkspace": "कार्यक्षेत्रात पृष्ठ जोडा",
+ "recent": "अलीकडील",
+ "today": "आज",
+ "thisWeek": "या आठवड्यात",
+ "others": "पूर्वीच्या आवडती",
+ "earlier": "पूर्वीचे",
+ "justNow": "आत्ताच",
+ "minutesAgo": "{count} मिनिटांपूर्वी",
+ "lastViewed": "शेवटी पाहिलेले",
+ "favoriteAt": "आवडते म्हणून चिन्हांकित",
+ "emptyRecent": "अलीकडील पृष्ठे नाहीत",
+ "emptyRecentDescription": "तुम्ही पाहिलेली पृष्ठे येथे सहज पुन्हा मिळवण्यासाठी दिसतील.",
+ "emptyFavorite": "आवडती पृष्ठे नाहीत",
+ "emptyFavoriteDescription": "पृष्ठांना आवडते म्हणून चिन्हांकित करा—ते येथे झपाट्याने प्रवेशासाठी दिसतील!",
+ "removePageFromRecent": "हे पृष्ठ अलीकडील यादीतून काढायचे?",
+ "removeSuccess": "यशस्वीरित्या काढले गेले",
+ "favoriteSpace": "आवडती",
+ "RecentSpace": "अलीकडील",
+ "Spaces": "जागा",
+ "upgradeToPro": "Pro मध्ये अपग्रेड करा",
+ "upgradeToAIMax": "अमर्यादित AI अनलॉक करा",
+ "storageLimitDialogTitle": "तुमचा मोफत स्टोरेज संपला आहे. अमर्यादित स्टोरेजसाठी अपग्रेड करा",
+ "storageLimitDialogTitleIOS": "तुमचा मोफत स्टोरेज संपला आहे.",
+ "aiResponseLimitTitle": "तुमचे मोफत AI प्रतिसाद संपले आहेत. कृपया Pro प्लॅनला अपग्रेड करा किंवा AI add-on खरेदी करा",
+ "aiResponseLimitDialogTitle": "AI प्रतिसाद मर्यादा गाठली आहे",
+ "aiResponseLimit": "तुमचे मोफत AI प्रतिसाद संपले आहेत.\n\nसेटिंग्ज -> प्लॅन -> AI Max किंवा Pro प्लॅन क्लिक करा",
+ "askOwnerToUpgradeToPro": "तुमच्या कार्यक्षेत्राचे मोफत स्टोरेज संपत चालले आहे. कृपया कार्यक्षेत्र मालकाला Pro प्लॅनमध्ये अपग्रेड करण्यास सांगा",
+ "askOwnerToUpgradeToProIOS": "तुमच्या कार्यक्षेत्राचे मोफत स्टोरेज संपत चालले आहे.",
+ "askOwnerToUpgradeToAIMax": "तुमच्या कार्यक्षेत्राचे मोफत AI प्रतिसाद संपले आहेत. कृपया कार्यक्षेत्र मालकाला प्लॅन अपग्रेड किंवा AI add-ons खरेदी करण्यास सांगा",
+ "askOwnerToUpgradeToAIMaxIOS": "तुमच्या कार्यक्षेत्राचे मोफत AI प्रतिसाद संपले आहेत.",
+ "purchaseAIMax": "तुमच्या कार्यक्षेत्राचे AI प्रतिमा प्रतिसाद संपले आहेत. कृपया कार्यक्षेत्र मालकाला AI Max खरेदी करण्यास सांगा",
+ "aiImageResponseLimit": "तुमचे AI प्रतिमा प्रतिसाद संपले आहेत.\n\nसेटिंग्ज -> प्लॅन -> AI Max क्लिक करा",
+ "purchaseStorageSpace": "स्टोरेज स्पेस खरेदी करा",
+ "singleFileProPlanLimitationDescription": "तुम्ही मोफत प्लॅनमध्ये परवानगी दिलेल्या फाइल अपलोड आकाराची मर्यादा ओलांडली आहे. कृपया Pro प्लॅनमध्ये अपग्रेड करा",
+ "purchaseAIResponse": "AI प्रतिसाद खरेदी करा",
+ "askOwnerToUpgradeToLocalAI": "कार्यक्षेत्र मालकाला ऑन-डिव्हाइस AI सक्षम करण्यास सांगा",
+ "upgradeToAILocal": "अत्याधिक गोपनीयतेसाठी तुमच्या डिव्हाइसवर लोकल मॉडेल चालवा",
+ "upgradeToAILocalDesc": "PDFs सोबत गप्पा मारा, तुमचे लेखन सुधारा आणि लोकल AI वापरून टेबल्स आपोआप भरा"
+},
+ "notifications": {
+ "export": {
+ "markdown": "टीप Markdown मध्ये निर्यात केली",
+ "path": "Documents/flowy"
+ }
+ },
+ "contactsPage": {
+ "title": "संपर्क",
+ "whatsHappening": "या आठवड्यात काय घडत आहे?",
+ "addContact": "संपर्क जोडा",
+ "editContact": "संपर्क संपादित करा"
+ },
+ "button": {
+ "ok": "ठीक आहे",
+ "confirm": "खात्री करा",
+ "done": "पूर्ण",
+ "cancel": "रद्द करा",
+ "signIn": "साइन इन",
+ "signOut": "साइन आउट",
+ "complete": "पूर्ण करा",
+ "save": "जतन करा",
+ "generate": "निर्माण करा",
+ "esc": "ESC",
+ "keep": "ठेवा",
+ "tryAgain": "पुन्हा प्रयत्न करा",
+ "discard": "टाका",
+ "replace": "बदला",
+ "insertBelow": "खाली घाला",
+ "insertAbove": "वर घाला",
+ "upload": "अपलोड करा",
+ "edit": "संपादित करा",
+ "delete": "हटवा",
+ "copy": "कॉपी करा",
+ "duplicate": "प्रत बनवा",
+ "putback": "परत ठेवा",
+ "update": "अद्यतनित करा",
+ "share": "शेअर करा",
+ "removeFromFavorites": "आवडतीतून काढा",
+ "removeFromRecent": "अलीकडील यादीतून काढा",
+ "addToFavorites": "आवडतीत जोडा",
+ "favoriteSuccessfully": "आवडतीत यशस्वीरित्या जोडले",
+ "unfavoriteSuccessfully": "आवडतीतून यशस्वीरित्या काढले",
+ "duplicateSuccessfully": "प्रत यशस्वीरित्या तयार झाली",
+ "rename": "नाव बदला",
+ "helpCenter": "मदत केंद्र",
+ "add": "जोड़ा",
+ "yes": "होय",
+ "no": "नाही",
+ "clear": "साफ करा",
+ "remove": "काढा",
+ "dontRemove": "काढू नका",
+ "copyLink": "लिंक कॉपी करा",
+ "align": "जुळवा",
+ "login": "लॉगिन",
+ "logout": "लॉगआउट",
+ "deleteAccount": "खाते हटवा",
+ "back": "मागे",
+ "signInGoogle": "Google सह पुढे जा",
+ "signInGithub": "GitHub सह पुढे जा",
+ "signInDiscord": "Discord सह पुढे जा",
+ "more": "अधिक",
+ "create": "तयार करा",
+ "close": "बंद करा",
+ "next": "पुढे",
+ "previous": "मागील",
+ "submit": "सबमिट करा",
+ "download": "डाउनलोड करा",
+ "backToHome": "मुख्यपृष्ठावर परत जा",
+ "viewing": "पाहत आहात",
+ "editing": "संपादन करत आहात",
+ "gotIt": "समजले",
+ "retry": "पुन्हा प्रयत्न करा",
+ "uploadFailed": "अपलोड अयशस्वी.",
+ "copyLinkOriginal": "मूळ दुव्याची कॉपी करा"
+ },
+ "label": {
+ "welcome": "स्वागत आहे!",
+ "firstName": "पहिले नाव",
+ "middleName": "मधले नाव",
+ "lastName": "आडनाव",
+ "stepX": "पायरी {X}"
+ },
+ "oAuth": {
+ "err": {
+ "failedTitle": "तुमच्या खात्याशी कनेक्ट होता आले नाही.",
+ "failedMsg": "कृपया खात्री करा की तुम्ही ब्राउझरमध्ये साइन-इन प्रक्रिया पूर्ण केली आहे."
+ },
+ "google": {
+ "title": "GOOGLE साइन-इन",
+ "instruction1": "तुमचे Google Contacts आयात करण्यासाठी, तुम्हाला तुमच्या वेब ब्राउझरचा वापर करून या अॅप्लिकेशनला अधिकृत करणे आवश्यक आहे.",
+ "instruction2": "ही कोड आयकॉनवर क्लिक करून किंवा मजकूर निवडून क्लिपबोर्डवर कॉपी करा:",
+ "instruction3": "तुमच्या वेब ब्राउझरमध्ये खालील दुव्यावर जा आणि वरील कोड टाका:",
+ "instruction4": "साइनअप पूर्ण झाल्यावर खालील बटण क्लिक करा:"
+ }
+ },
+ "settings": {
+ "title": "सेटिंग्ज",
+ "popupMenuItem": {
+ "settings": "सेटिंग्ज",
+ "members": "सदस्य",
+ "trash": "कचरा",
+ "helpAndSupport": "मदत आणि समर्थन"
+ },
+ "sites": {
+ "title": "साइट्स",
+ "namespaceTitle": "नेमस्पेस",
+ "namespaceDescription": "तुमचा नेमस्पेस आणि मुख्यपृष्ठ व्यवस्थापित करा",
+ "namespaceHeader": "नेमस्पेस",
+ "homepageHeader": "मुख्यपृष्ठ",
+ "updateNamespace": "नेमस्पेस अद्यतनित करा",
+ "removeHomepage": "मुख्यपृष्ठ हटवा",
+ "selectHomePage": "एक पृष्ठ निवडा",
+ "clearHomePage": "या नेमस्पेससाठी मुख्यपृष्ठ साफ करा",
+ "customUrl": "स्वतःची URL",
+ "namespace": {
+ "description": "हे बदल सर्व प्रकाशित पृष्ठांवर लागू होतील जे या नेमस्पेसवर चालू आहेत",
+ "tooltip": "कोणताही अनुचित नेमस्पेस आम्ही काढून टाकण्याचा अधिकार राखून ठेवतो",
+ "updateExistingNamespace": "विद्यमान नेमस्पेस अद्यतनित करा",
+ "upgradeToPro": "मुख्यपृष्ठ सेट करण्यासाठी Pro प्लॅनमध्ये अपग्रेड करा",
+ "redirectToPayment": "पेमेंट पृष्ठावर वळवत आहे...",
+ "onlyWorkspaceOwnerCanSetHomePage": "फक्त कार्यक्षेत्र मालकच मुख्यपृष्ठ सेट करू शकतो",
+ "pleaseAskOwnerToSetHomePage": "कृपया कार्यक्षेत्र मालकाला Pro प्लॅनमध्ये अपग्रेड करण्यास सांगा"
+ },
+ "publishedPage": {
+ "title": "सर्व प्रकाशित पृष्ठे",
+ "description": "तुमची प्रकाशित पृष्ठे व्यवस्थापित करा",
+ "page": "पृष्ठ",
+ "pathName": "पथाचे नाव",
+ "date": "प्रकाशन तारीख",
+ "emptyHinText": "या कार्यक्षेत्रात तुमच्याकडे कोणतीही प्रकाशित पृष्ठे नाहीत",
+ "noPublishedPages": "प्रकाशित पृष्ठे नाहीत",
+ "settings": "प्रकाशन सेटिंग्ज",
+ "clickToOpenPageInApp": "पृष्ठ अॅपमध्ये उघडा",
+ "clickToOpenPageInBrowser": "पृष्ठ ब्राउझरमध्ये उघडा"
+ }
+ }
+ },
+ "error": {
+ "failedToGeneratePaymentLink": "Pro प्लॅनसाठी पेमेंट लिंक तयार करण्यात अयशस्वी",
+ "failedToUpdateNamespace": "नेमस्पेस अद्यतनित करण्यात अयशस्वी",
+ "proPlanLimitation": "नेमस्पेस अद्यतनित करण्यासाठी तुम्हाला Pro प्लॅनमध्ये अपग्रेड करणे आवश्यक आहे",
+ "namespaceAlreadyInUse": "नेमस्पेस आधीच वापरात आहे, कृपया दुसरे प्रयत्न करा",
+ "invalidNamespace": "अवैध नेमस्पेस, कृपया दुसरे प्रयत्न करा",
+ "namespaceLengthAtLeast2Characters": "नेमस्पेस किमान २ अक्षरे लांब असावे",
+ "onlyWorkspaceOwnerCanUpdateNamespace": "फक्त कार्यक्षेत्र मालकच नेमस्पेस अद्यतनित करू शकतो",
+ "onlyWorkspaceOwnerCanRemoveHomepage": "फक्त कार्यक्षेत्र मालकच मुख्यपृष्ठ हटवू शकतो",
+ "setHomepageFailed": "मुख्यपृष्ठ सेट करण्यात अयशस्वी",
+ "namespaceTooLong": "नेमस्पेस खूप लांब आहे, कृपया दुसरे प्रयत्न करा",
+ "namespaceTooShort": "नेमस्पेस खूप लहान आहे, कृपया दुसरे प्रयत्न करा",
+ "namespaceIsReserved": "हा नेमस्पेस राखीव आहे, कृपया दुसरे प्रयत्न करा",
+ "updatePathNameFailed": "पथाचे नाव अद्यतनित करण्यात अयशस्वी",
+ "removeHomePageFailed": "मुख्यपृष्ठ हटवण्यात अयशस्वी",
+ "publishNameContainsInvalidCharacters": "पथाच्या नावामध्ये अवैध अक्षरे आहेत, कृपया दुसरे प्रयत्न करा",
+ "publishNameTooShort": "पथाचे नाव खूप लहान आहे, कृपया दुसरे प्रयत्न करा",
+ "publishNameTooLong": "पथाचे नाव खूप लांब आहे, कृपया दुसरे प्रयत्न करा",
+ "publishNameAlreadyInUse": "हे पथाचे नाव आधीच वापरले गेले आहे, कृपया दुसरे प्रयत्न करा",
+ "namespaceContainsInvalidCharacters": "नेमस्पेसमध्ये अवैध अक्षरे आहेत, कृपया दुसरे प्रयत्न करा",
+ "publishPermissionDenied": "फक्त कार्यक्षेत्र मालक किंवा पृष्ठ प्रकाशकच प्रकाशन सेटिंग्ज व्यवस्थापित करू शकतो",
+ "publishNameCannotBeEmpty": "पथाचे नाव रिकामे असू शकत नाही, कृपया दुसरे प्रयत्न करा"
+ },
+ "success": {
+ "namespaceUpdated": "नेमस्पेस यशस्वीरित्या अद्यतनित केला",
+ "setHomepageSuccess": "मुख्यपृष्ठ यशस्वीरित्या सेट केले",
+ "updatePathNameSuccess": "पथाचे नाव यशस्वीरित्या अद्यतनित केले",
+ "removeHomePageSuccess": "मुख्यपृष्ठ यशस्वीरित्या हटवले"
+ },
+ "accountPage": {
+ "menuLabel": "खाते आणि अॅप",
+ "title": "माझे खाते",
+ "general": {
+ "title": "खात्याचे नाव आणि प्रोफाइल प्रतिमा",
+ "changeProfilePicture": "प्रोफाइल प्रतिमा बदला"
+ },
+ "email": {
+ "title": "ईमेल",
+ "actions": {
+ "change": "ईमेल बदला"
+ }
+ },
+ "login": {
+ "title": "खाते लॉगिन",
+ "loginLabel": "लॉगिन",
+ "logoutLabel": "लॉगआउट"
+ },
+ "isUpToDate": "@:appName अद्ययावत आहे!",
+ "officialVersion": "आवृत्ती {version} (अधिकृत बिल्ड)"
+},
+ "workspacePage": {
+ "menuLabel": "कार्यक्षेत्र",
+ "title": "कार्यक्षेत्र",
+ "description": "तुमचे कार्यक्षेत्र स्वरूप, थीम, फॉन्ट, मजकूर रचना, दिनांक/वेळ फॉरमॅट आणि भाषा सानुकूलित करा.",
+ "workspaceName": {
+ "title": "कार्यक्षेत्राचे नाव"
+ },
+ "workspaceIcon": {
+ "title": "कार्यक्षेत्राचे चिन्ह",
+ "description": "तुमच्या कार्यक्षेत्रासाठी प्रतिमा अपलोड करा किंवा इमोजी वापरा. हे चिन्ह साइडबार आणि सूचना मध्ये दिसेल."
+ },
+ "appearance": {
+ "title": "दृश्यरूप",
+ "description": "कार्यक्षेत्राचे दृश्यरूप, थीम, फॉन्ट, मजकूर रचना, दिनांक, वेळ आणि भाषा सानुकूलित करा.",
+ "options": {
+ "system": "स्वयंचलित",
+ "light": "लाइट",
+ "dark": "डार्क"
+ }
+ }
+ },
+ "resetCursorColor": {
+ "title": "दस्तऐवज कर्सरचा रंग रीसेट करा",
+ "description": "तुम्हाला कर्सरचा रंग रीसेट करायचा आहे का?"
+ },
+ "resetSelectionColor": {
+ "title": "दस्तऐवज निवडीचा रंग रीसेट करा",
+ "description": "तुम्हाला निवडीचा रंग रीसेट करायचा आहे का?"
+ },
+ "resetWidth": {
+ "resetSuccess": "दस्तऐवजाची रुंदी यशस्वीरित्या रीसेट केली"
+ },
+ "theme": {
+ "title": "थीम",
+ "description": "पूर्व-निर्धारित थीम निवडा किंवा तुमची स्वतःची थीम अपलोड करा.",
+ "uploadCustomThemeTooltip": "स्वतःची थीम अपलोड करा"
+ },
+ "workspaceFont": {
+ "title": "कार्यक्षेत्र फॉन्ट",
+ "noFontHint": "कोणताही फॉन्ट सापडला नाही, कृपया दुसरा शब्द वापरून पहा."
+ },
+ "textDirection": {
+ "title": "मजकूर दिशा",
+ "leftToRight": "डावीकडून उजवीकडे",
+ "rightToLeft": "उजवीकडून डावीकडे",
+ "auto": "स्वयंचलित",
+ "enableRTLItems": "RTL टूलबार घटक सक्षम करा"
+ },
+ "layoutDirection": {
+ "title": "लेआउट दिशा",
+ "leftToRight": "डावीकडून उजवीकडे",
+ "rightToLeft": "उजवीकडून डावीकडे"
+ },
+ "dateTime": {
+ "title": "दिनांक आणि वेळ",
+ "example": "{} वाजता {} ({})",
+ "24HourTime": "२४-तास वेळ",
+ "dateFormat": {
+ "label": "दिनांक फॉरमॅट",
+ "local": "स्थानिक",
+ "us": "US",
+ "iso": "ISO",
+ "friendly": "सुलभ",
+ "dmy": "D/M/Y"
+ }
+ },
+ "language": {
+ "title": "भाषा"
+ },
+ "deleteWorkspacePrompt": {
+ "title": "कार्यक्षेत्र हटवा",
+ "content": "तुम्हाला हे कार्यक्षेत्र हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही, आणि तुम्ही प्रकाशित केलेली कोणतीही पृष्ठे अप्रकाशित होतील."
+ },
+ "leaveWorkspacePrompt": {
+ "title": "कार्यक्षेत्र सोडा",
+ "content": "तुम्हाला हे कार्यक्षेत्र सोडायचे आहे का? यानंतर तुम्हाला यामधील सर्व पृष्ठे आणि डेटावर प्रवेश मिळणार नाही.",
+ "success": "तुम्ही कार्यक्षेत्र यशस्वीरित्या सोडले.",
+ "fail": "कार्यक्षेत्र सोडण्यात अयशस्वी."
+ },
+ "manageWorkspace": {
+ "title": "कार्यक्षेत्र व्यवस्थापित करा",
+ "leaveWorkspace": "कार्यक्षेत्र सोडा",
+ "deleteWorkspace": "कार्यक्षेत्र हटवा"
+ },
+ "manageDataPage": {
+ "menuLabel": "डेटा व्यवस्थापित करा",
+ "title": "डेटा व्यवस्थापन",
+ "description": "@:appName मध्ये स्थानिक डेटा संचयन व्यवस्थापित करा किंवा तुमचा विद्यमान डेटा आयात करा.",
+ "dataStorage": {
+ "title": "फाइल संचयन स्थान",
+ "tooltip": "जिथे तुमच्या फाइल्स संग्रहित आहेत ते स्थान",
+ "actions": {
+ "change": "मार्ग बदला",
+ "open": "फोल्डर उघडा",
+ "openTooltip": "सध्याच्या डेटा फोल्डरचे स्थान उघडा",
+ "copy": "मार्ग कॉपी करा",
+ "copiedHint": "मार्ग कॉपी केला!",
+ "resetTooltip": "मूलभूत स्थानावर रीसेट करा"
+ },
+ "resetDialog": {
+ "title": "तुम्हाला खात्री आहे का?",
+ "description": "पथ मूलभूत डेटा स्थानावर रीसेट केल्याने तुमचा डेटा हटवला जाणार नाही. तुम्हाला सध्याचा डेटा पुन्हा आयात करायचा असल्यास, कृपया आधी त्याचा पथ कॉपी करा."
+ }
+ },
+ "importData": {
+ "title": "डेटा आयात करा",
+ "tooltip": "@:appName बॅकअप/डेटा फोल्डरमधून डेटा आयात करा",
+ "description": "बाह्य @:appName डेटा फोल्डरमधून डेटा कॉपी करा",
+ "action": "फाइल निवडा"
+ },
+ "encryption": {
+ "title": "एनक्रिप्शन",
+ "tooltip": "तुमचा डेटा कसा संग्रहित आणि एनक्रिप्ट केला जातो ते व्यवस्थापित करा",
+ "descriptionNoEncryption": "एनक्रिप्शन चालू केल्याने सर्व डेटा एनक्रिप्ट केला जाईल. ही क्रिया पूर्ववत केली जाऊ शकत नाही.",
+ "descriptionEncrypted": "तुमचा डेटा एनक्रिप्टेड आहे.",
+ "action": "डेटा एनक्रिप्ट करा",
+ "dialog": {
+ "title": "संपूर्ण डेटा एनक्रिप्ट करायचा?",
+ "description": "तुमचा संपूर्ण डेटा एनक्रिप्ट केल्याने तो सुरक्षित राहील. ही क्रिया पूर्ववत केली जाऊ शकत नाही. तुम्हाला पुढे जायचे आहे का?"
+ }
+ },
+ "cache": {
+ "title": "कॅशे साफ करा",
+ "description": "प्रतिमा न दिसणे, पृष्ठे हरवणे, फॉन्ट लोड न होणे अशा समस्यांचे निराकरण करण्यासाठी मदत करा. याचा तुमच्या डेटावर परिणाम होणार नाही.",
+ "dialog": {
+ "title": "कॅशे साफ करा",
+ "description": "प्रतिमा न दिसणे, पृष्ठे हरवणे, फॉन्ट लोड न होणे अशा समस्यांचे निराकरण करण्यासाठी मदत करा. याचा तुमच्या डेटावर परिणाम होणार नाही.",
+ "successHint": "कॅशे साफ झाली!"
+ }
+ },
+ "data": {
+ "fixYourData": "तुमचा डेटा सुधारा",
+ "fixButton": "सुधारा",
+ "fixYourDataDescription": "तुमच्या डेटामध्ये काही अडचण येत असल्यास, तुम्ही येथे ती सुधारू शकता."
+ }
+ },
+ "shortcutsPage": {
+ "menuLabel": "शॉर्टकट्स",
+ "title": "शॉर्टकट्स",
+ "editBindingHint": "नवीन बाइंडिंग टाका",
+ "searchHint": "शोधा",
+ "actions": {
+ "resetDefault": "मूलभूत रीसेट करा"
+ },
+ "errorPage": {
+ "message": "शॉर्टकट्स लोड करण्यात अयशस्वी: {}",
+ "howToFix": "कृपया पुन्हा प्रयत्न करा. समस्या कायम राहिल्यास GitHub वर संपर्क साधा."
+ },
+ "resetDialog": {
+ "title": "शॉर्टकट्स रीसेट करा",
+ "description": "हे सर्व कीबाइंडिंग्जना मूळ स्थितीत रीसेट करेल. ही क्रिया पूर्ववत करता येणार नाही. तुम्हाला खात्री आहे का?",
+ "buttonLabel": "रीसेट करा"
+ },
+ "conflictDialog": {
+ "title": "{} आधीच वापरले जात आहे",
+ "descriptionPrefix": "हे कीबाइंडिंग सध्या ",
+ "descriptionSuffix": " यामध्ये वापरले जात आहे. तुम्ही हे कीबाइंडिंग बदलल्यास, ते {} मधून काढले जाईल.",
+ "confirmLabel": "पुढे जा"
+ },
+ "editTooltip": "कीबाइंडिंग संपादित करण्यासाठी क्लिक करा",
+ "keybindings": {
+ "toggleToDoList": "टू-डू सूची चालू/बंद करा",
+ "insertNewParagraphInCodeblock": "नवीन परिच्छेद टाका",
+ "pasteInCodeblock": "कोडब्लॉकमध्ये पेस्ट करा",
+ "selectAllCodeblock": "सर्व निवडा",
+ "indentLineCodeblock": "ओळीच्या सुरुवातीला दोन स्पेस टाका",
+ "outdentLineCodeblock": "ओळीच्या सुरुवातीची दोन स्पेस काढा",
+ "twoSpacesCursorCodeblock": "कर्सरवर दोन स्पेस टाका",
+ "copy": "निवड कॉपी करा",
+ "paste": "मजकुरात पेस्ट करा",
+ "cut": "निवड कट करा",
+ "alignLeft": "मजकूर डावीकडे संरेखित करा",
+ "alignCenter": "मजकूर मधोमध संरेखित करा",
+ "alignRight": "मजकूर उजवीकडे संरेखित करा",
+ "insertInlineMathEquation": "इनलाइन गणितीय सूत्र टाका",
+ "undo": "पूर्ववत करा",
+ "redo": "पुन्हा करा",
+ "convertToParagraph": "ब्लॉक परिच्छेदात रूपांतरित करा",
+ "backspace": "हटवा",
+ "deleteLeftWord": "डावीकडील शब्द हटवा",
+ "deleteLeftSentence": "डावीकडील वाक्य हटवा",
+ "delete": "उजवीकडील अक्षर हटवा",
+ "deleteMacOS": "डावीकडील अक्षर हटवा",
+ "deleteRightWord": "उजवीकडील शब्द हटवा",
+ "moveCursorLeft": "कर्सर डावीकडे हलवा",
+ "moveCursorBeginning": "कर्सर सुरुवातीला हलवा",
+ "moveCursorLeftWord": "कर्सर एक शब्द डावीकडे हलवा",
+ "moveCursorLeftSelect": "निवडा आणि कर्सर डावीकडे हलवा",
+ "moveCursorBeginSelect": "निवडा आणि कर्सर सुरुवातीला हलवा",
+ "moveCursorLeftWordSelect": "निवडा आणि कर्सर एक शब्द डावीकडे हलवा",
+ "moveCursorRight": "कर्सर उजवीकडे हलवा",
+ "moveCursorEnd": "कर्सर शेवटी हलवा",
+ "moveCursorRightWord": "कर्सर एक शब्द उजवीकडे हलवा",
+ "moveCursorRightSelect": "निवडा आणि कर्सर उजवीकडे हलवा",
+ "moveCursorEndSelect": "निवडा आणि कर्सर शेवटी हलवा",
+ "moveCursorRightWordSelect": "निवडा आणि कर्सर एक शब्द उजवीकडे हलवा",
+ "moveCursorUp": "कर्सर वर हलवा",
+ "moveCursorTopSelect": "निवडा आणि कर्सर वर हलवा",
+ "moveCursorTop": "कर्सर वर हलवा",
+ "moveCursorUpSelect": "निवडा आणि कर्सर वर हलवा",
+ "moveCursorBottomSelect": "निवडा आणि कर्सर खाली हलवा",
+ "moveCursorBottom": "कर्सर खाली हलवा",
+ "moveCursorDown": "कर्सर खाली हलवा",
+ "moveCursorDownSelect": "निवडा आणि कर्सर खाली हलवा",
+ "home": "वर स्क्रोल करा",
+ "end": "खाली स्क्रोल करा",
+ "toggleBold": "बोल्ड चालू/बंद करा",
+ "toggleItalic": "इटालिक चालू/बंद करा",
+ "toggleUnderline": "अधोरेखित चालू/बंद करा",
+ "toggleStrikethrough": "स्ट्राईकथ्रू चालू/बंद करा",
+ "toggleCode": "इनलाइन कोड चालू/बंद करा",
+ "toggleHighlight": "हायलाईट चालू/बंद करा",
+ "showLinkMenu": "लिंक मेनू दाखवा",
+ "openInlineLink": "इनलाइन लिंक उघडा",
+ "openLinks": "सर्व निवडलेले लिंक उघडा",
+ "indent": "इंडेंट",
+ "outdent": "आउटडेंट",
+ "exit": "संपादनातून बाहेर पडा",
+ "pageUp": "एक पृष्ठ वर स्क्रोल करा",
+ "pageDown": "एक पृष्ठ खाली स्क्रोल करा",
+ "selectAll": "सर्व निवडा",
+ "pasteWithoutFormatting": "फॉरमॅटिंगशिवाय पेस्ट करा",
+ "showEmojiPicker": "इमोजी निवडकर्ता दाखवा",
+ "enterInTableCell": "टेबलमध्ये नवीन ओळ जोडा",
+ "leftInTableCell": "टेबलमध्ये डावीकडील सेलमध्ये जा",
+ "rightInTableCell": "टेबलमध्ये उजवीकडील सेलमध्ये जा",
+ "upInTableCell": "टेबलमध्ये वरील सेलमध्ये जा",
+ "downInTableCell": "टेबलमध्ये खालील सेलमध्ये जा",
+ "tabInTableCell": "टेबलमध्ये पुढील सेलमध्ये जा",
+ "shiftTabInTableCell": "टेबलमध्ये मागील सेलमध्ये जा",
+ "backSpaceInTableCell": "सेलच्या सुरुवातीला थांबा"
+ },
+ "commands": {
+ "codeBlockNewParagraph": "कोडब्लॉकच्या शेजारी नवीन परिच्छेद टाका",
+ "codeBlockIndentLines": "कोडब्लॉकमध्ये ओळीच्या सुरुवातीला दोन स्पेस टाका",
+ "codeBlockOutdentLines": "कोडब्लॉकमध्ये ओळीच्या सुरुवातीची दोन स्पेस काढा",
+ "codeBlockAddTwoSpaces": "कोडब्लॉकमध्ये कर्सरच्या ठिकाणी दोन स्पेस टाका",
+ "codeBlockSelectAll": "कोडब्लॉकमधील सर्व मजकूर निवडा",
+ "codeBlockPasteText": "कोडब्लॉकमध्ये मजकूर पेस्ट करा",
+ "textAlignLeft": "मजकूर डावीकडे संरेखित करा",
+ "textAlignCenter": "मजकूर मधोमध संरेखित करा",
+ "textAlignRight": "मजकूर उजवीकडे संरेखित करा"
+ },
+ "couldNotLoadErrorMsg": "शॉर्टकट लोड करता आले नाहीत. पुन्हा प्रयत्न करा",
+ "couldNotSaveErrorMsg": "शॉर्टकट सेव्ह करता आले नाहीत. पुन्हा प्रयत्न करा"
+},
+ "aiPage": {
+ "title": "AI सेटिंग्ज",
+ "menuLabel": "AI सेटिंग्ज",
+ "keys": {
+ "enableAISearchTitle": "AI शोध",
+ "aiSettingsDescription": "AppFlowy AI चालवण्यासाठी तुमचा पसंतीचा मॉडेल निवडा. आता GPT-4o, GPT-o3-mini, DeepSeek R1, Claude 3.5 Sonnet आणि Ollama मधील मॉडेल्सचा समावेश आहे.",
+ "loginToEnableAIFeature": "AI वैशिष्ट्ये फक्त @:appName Cloud मध्ये लॉगिन केल्यानंतरच सक्षम होतात. तुमच्याकडे @:appName खाते नसेल तर 'माय अकाउंट' मध्ये जाऊन साइन अप करा.",
+ "llmModel": "भाषा मॉडेल",
+ "llmModelType": "भाषा मॉडेल प्रकार",
+ "downloadLLMPrompt": "{} डाउनलोड करा",
+ "downloadAppFlowyOfflineAI": "AI ऑफलाइन पॅकेज डाउनलोड केल्याने तुमच्या डिव्हाइसवर AI चालवता येईल. तुम्हाला पुढे जायचे आहे का?",
+ "downloadLLMPromptDetail": "{} स्थानिक मॉडेल डाउनलोड करण्यासाठी सुमारे {} संचयन लागेल. तुम्हाला पुढे जायचे आहे का?",
+ "downloadBigFilePrompt": "डाउनलोड पूर्ण होण्यासाठी सुमारे १० मिनिटे लागू शकतात",
+ "downloadAIModelButton": "डाउनलोड करा",
+ "downloadingModel": "डाउनलोड करत आहे",
+ "localAILoaded": "स्थानिक AI मॉडेल यशस्वीरित्या जोडले गेले आणि वापरण्यास सज्ज आहे",
+ "localAIStart": "स्थानिक AI सुरू होत आहे. जर हळू वाटत असेल, तर ते बंद करून पुन्हा सुरू करून पहा",
+ "localAILoading": "स्थानिक AI Chat मॉडेल लोड होत आहे...",
+ "localAIStopped": "स्थानिक AI थांबले आहे",
+ "localAIRunning": "स्थानिक AI चालू आहे",
+ "localAINotReadyRetryLater": "स्थानिक AI सुरू होत आहे, कृपया नंतर पुन्हा प्रयत्न करा",
+ "localAIDisabled": "तुम्ही स्थानिक AI वापरत आहात, पण ते अकार्यक्षम आहे. कृपया सेटिंग्जमध्ये जाऊन ते सक्षम करा किंवा दुसरे मॉडेल वापरून पहा",
+ "localAIInitializing": "स्थानिक AI लोड होत आहे. तुमच्या डिव्हाइसवर अवलंबून याला काही मिनिटे लागू शकतात",
+ "localAINotReadyTextFieldPrompt": "स्थानिक AI लोड होत असताना संपादन करता येणार नाही",
+ "failToLoadLocalAI": "स्थानिक AI सुरू करण्यात अयशस्वी.",
+ "restartLocalAI": "पुन्हा सुरू करा",
+ "disableLocalAITitle": "स्थानिक AI अकार्यक्षम करा",
+ "disableLocalAIDescription": "तुम्हाला स्थानिक AI अकार्यक्षम करायचा आहे का?",
+ "localAIToggleTitle": "AppFlowy स्थानिक AI (LAI)",
+ "localAIToggleSubTitle": "AppFlowy मध्ये अत्याधुनिक स्थानिक AI मॉडेल्स वापरून गोपनीयता आणि सुरक्षा सुनिश्चित करा",
+ "offlineAIInstruction1": "हे अनुसरा",
+ "offlineAIInstruction2": "सूचना",
+ "offlineAIInstruction3": "ऑफलाइन AI सक्षम करण्यासाठी.",
+ "offlineAIDownload1": "जर तुम्ही AppFlowy AI डाउनलोड केले नसेल, तर कृपया",
+ "offlineAIDownload2": "डाउनलोड",
+ "offlineAIDownload3": "करा",
+ "activeOfflineAI": "सक्रिय",
+ "downloadOfflineAI": "डाउनलोड करा",
+ "openModelDirectory": "फोल्डर उघडा",
+ "laiNotReady": "स्थानिक AI अॅप योग्य प्रकारे इन्स्टॉल झालेले नाही.",
+ "ollamaNotReady": "Ollama सर्व्हर तयार नाही.",
+ "pleaseFollowThese": "कृपया हे अनुसरा",
+ "instructions": "सूचना",
+ "installOllamaLai": "Ollama आणि AppFlowy स्थानिक AI सेटअप करण्यासाठी.",
+ "modelsMissing": "आवश्यक मॉडेल्स सापडत नाहीत.",
+ "downloadModel": "त्यांना डाउनलोड करण्यासाठी."
+ }
+},
+ "planPage": {
+ "menuLabel": "योजना",
+ "title": "दर योजना",
+ "planUsage": {
+ "title": "योजनेचा वापर सारांश",
+ "storageLabel": "स्टोरेज",
+ "storageUsage": "{} पैकी {} GB",
+ "unlimitedStorageLabel": "अमर्यादित स्टोरेज",
+ "collaboratorsLabel": "सदस्य",
+ "collaboratorsUsage": "{} पैकी {}",
+ "aiResponseLabel": "AI प्रतिसाद",
+ "aiResponseUsage": "{} पैकी {}",
+ "unlimitedAILabel": "अमर्यादित AI प्रतिसाद",
+ "proBadge": "प्रो",
+ "aiMaxBadge": "AI Max",
+ "aiOnDeviceBadge": "मॅकसाठी ऑन-डिव्हाइस AI",
+ "memberProToggle": "अधिक सदस्य आणि अमर्यादित AI",
+ "aiMaxToggle": "अमर्यादित AI आणि प्रगत मॉडेल्सचा प्रवेश",
+ "aiOnDeviceToggle": "जास्त गोपनीयतेसाठी स्थानिक AI",
+ "aiCredit": {
+ "title": "@:appName AI क्रेडिट जोडा",
+ "price": "{}",
+ "priceDescription": "1,000 क्रेडिट्ससाठी",
+ "purchase": "AI खरेदी करा",
+ "info": "प्रत्येक कार्यक्षेत्रासाठी 1,000 AI क्रेडिट्स जोडा आणि आपल्या कामाच्या प्रवाहात सानुकूलनीय AI सहजपणे एकत्र करा — अधिक स्मार्ट आणि जलद निकालांसाठी:",
+ "infoItemOne": "प्रत्येक डेटाबेससाठी 10,000 प्रतिसाद",
+ "infoItemTwo": "प्रत्येक कार्यक्षेत्रासाठी 1,000 प्रतिसाद"
+ },
+ "currentPlan": {
+ "bannerLabel": "सद्य योजना",
+ "freeTitle": "फ्री",
+ "proTitle": "प्रो",
+ "teamTitle": "टीम",
+ "freeInfo": "2 सदस्यांपर्यंत वैयक्तिक वापरासाठी उत्तम",
+ "proInfo": "10 सदस्यांपर्यंत लहान आणि मध्यम टीमसाठी योग्य",
+ "teamInfo": "सर्व उत्पादनक्षम आणि सुव्यवस्थित टीमसाठी योग्य",
+ "upgrade": "योजना बदला",
+ "canceledInfo": "तुमची योजना रद्द करण्यात आली आहे, {} रोजी तुम्हाला फ्री योजनेवर डाऊनग्रेड केले जाईल."
+ },
+ "addons": {
+ "title": "ऍड-ऑन्स",
+ "addLabel": "जोडा",
+ "activeLabel": "जोडले गेले",
+ "aiMax": {
+ "title": "AI Max",
+ "description": "प्रगत AI मॉडेल्ससह अमर्यादित AI प्रतिसाद आणि दरमहा 50 AI प्रतिमा",
+ "price": "{}",
+ "priceInfo": "प्रति वापरकर्ता प्रति महिना (वार्षिक बिलिंग)"
+ },
+ "aiOnDevice": {
+ "title": "मॅकसाठी ऑन-डिव्हाइस AI",
+ "description": "तुमच्या डिव्हाइसवर Mistral 7B, LLAMA 3 आणि अधिक स्थानिक मॉडेल्स चालवा",
+ "price": "{}",
+ "priceInfo": "प्रति वापरकर्ता प्रति महिना (वार्षिक बिलिंग)",
+ "recommend": "M1 किंवा नवीनतम शिफारस केली जाते"
+ }
+ },
+ "deal": {
+ "bannerLabel": "नववर्षाचे विशेष ऑफर!",
+ "title": "तुमची टीम वाढवा!",
+ "info": "Pro आणि Team योजनांवर 10% सूट मिळवा! @:appName AI सह शक्तिशाली नवीन वैशिष्ट्यांसह तुमचे कार्यक्षेत्र अधिक कार्यक्षम बनवा.",
+ "viewPlans": "योजना पहा"
+ }
+ }
+},
+ "billingPage": {
+ "menuLabel": "बिलिंग",
+ "title": "बिलिंग",
+ "plan": {
+ "title": "योजना",
+ "freeLabel": "फ्री",
+ "proLabel": "प्रो",
+ "planButtonLabel": "योजना बदला",
+ "billingPeriod": "बिलिंग कालावधी",
+ "periodButtonLabel": "कालावधी संपादित करा"
+ },
+ "paymentDetails": {
+ "title": "पेमेंट तपशील",
+ "methodLabel": "पेमेंट पद्धत",
+ "methodButtonLabel": "पद्धत संपादित करा"
+ },
+ "addons": {
+ "title": "ऍड-ऑन्स",
+ "addLabel": "जोडा",
+ "removeLabel": "काढा",
+ "renewLabel": "नवीन करा",
+ "aiMax": {
+ "label": "AI Max",
+ "description": "अमर्यादित AI आणि प्रगत मॉडेल्स अनलॉक करा",
+ "activeDescription": "पुढील बिलिंग तारीख {} आहे",
+ "canceledDescription": "AI Max {} पर्यंत उपलब्ध असेल"
+ },
+ "aiOnDevice": {
+ "label": "मॅकसाठी ऑन-डिव्हाइस AI",
+ "description": "तुमच्या डिव्हाइसवर अमर्यादित ऑन-डिव्हाइस AI अनलॉक करा",
+ "activeDescription": "पुढील बिलिंग तारीख {} आहे",
+ "canceledDescription": "मॅकसाठी ऑन-डिव्हाइस AI {} पर्यंत उपलब्ध असेल"
+ },
+ "removeDialog": {
+ "title": "{} काढा",
+ "description": "तुम्हाला {plan} काढायचे आहे का? तुम्हाला तत्काळ {plan} चे सर्व फिचर्स आणि फायदे वापरण्याचा अधिकार गमावावा लागेल."
+ }
+ },
+ "currentPeriodBadge": "सद्य कालावधी",
+ "changePeriod": "कालावधी बदला",
+ "planPeriod": "{} कालावधी",
+ "monthlyInterval": "मासिक",
+ "monthlyPriceInfo": "प्रति सदस्य, मासिक बिलिंग",
+ "annualInterval": "वार्षिक",
+ "annualPriceInfo": "प्रति सदस्य, वार्षिक बिलिंग"
+},
+ "comparePlanDialog": {
+ "title": "योजना तुलना आणि निवड",
+ "planFeatures": "योजनेची\nवैशिष्ट्ये",
+ "current": "सध्याची",
+ "actions": {
+ "upgrade": "अपग्रेड करा",
+ "downgrade": "डाऊनग्रेड करा",
+ "current": "सध्याची"
+ },
+ "freePlan": {
+ "title": "फ्री",
+ "description": "२ सदस्यांपर्यंत व्यक्तींसाठी सर्व काही व्यवस्थित करण्यासाठी",
+ "price": "{}",
+ "priceInfo": "सदैव फ्री"
+ },
+ "proPlan": {
+ "title": "प्रो",
+ "description": "लहान टीम्ससाठी प्रोजेक्ट्स व ज्ञान व्यवस्थापनासाठी",
+ "price": "{}",
+ "priceInfo": "प्रति सदस्य प्रति महिना\nवार्षिक बिलिंग\n\n{} मासिक बिलिंगसाठी"
+ },
+ "planLabels": {
+ "itemOne": "वर्कस्पेसेस",
+ "itemTwo": "सदस्य",
+ "itemThree": "स्टोरेज",
+ "itemFour": "रिअल-टाइम सहकार्य",
+ "itemFive": "मोबाईल अॅप",
+ "itemSix": "AI प्रतिसाद",
+ "itemSeven": "AI प्रतिमा",
+ "itemFileUpload": "फाइल अपलोड",
+ "customNamespace": "सानुकूल नेमस्पेस",
+ "tooltipSix": "‘Lifetime’ म्हणजे या प्रतिसादांची मर्यादा कधीही रीसेट केली जात नाही",
+ "intelligentSearch": "स्मार्ट शोध",
+ "tooltipSeven": "तुमच्या कार्यक्षेत्रासाठी URL चा भाग सानुकूलित करू देते",
+ "customNamespaceTooltip": "सानुकूल प्रकाशित साइट URL"
+ },
+ "freeLabels": {
+ "itemOne": "प्रत्येक वर्कस्पेसवर शुल्क",
+ "itemTwo": "२ पर्यंत",
+ "itemThree": "५ GB",
+ "itemFour": "होय",
+ "itemFive": "होय",
+ "itemSix": "१० कायमस्वरूपी",
+ "itemSeven": "२ कायमस्वरूपी",
+ "itemFileUpload": "७ MB पर्यंत",
+ "intelligentSearch": "स्मार्ट शोध"
+ },
+ "proLabels": {
+ "itemOne": "प्रत्येक वर्कस्पेसवर शुल्क",
+ "itemTwo": "१० पर्यंत",
+ "itemThree": "अमर्यादित",
+ "itemFour": "होय",
+ "itemFive": "होय",
+ "itemSix": "अमर्यादित",
+ "itemSeven": "दर महिन्याला १० प्रतिमा",
+ "itemFileUpload": "अमर्यादित",
+ "intelligentSearch": "स्मार्ट शोध"
+ },
+ "paymentSuccess": {
+ "title": "तुम्ही आता {} योजनेवर आहात!",
+ "description": "तुमचे पेमेंट यशस्वीरित्या पूर्ण झाले असून तुमची योजना @:appName {} मध्ये अपग्रेड झाली आहे. तुम्ही 'योजना' पृष्ठावर तपशील पाहू शकता."
+ },
+ "downgradeDialog": {
+ "title": "तुम्हाला योजना डाऊनग्रेड करायची आहे का?",
+ "description": "तुमची योजना डाऊनग्रेड केल्यास तुम्ही फ्री योजनेवर परत जाल. काही सदस्यांना या कार्यक्षेत्राचा प्रवेश मिळणार नाही आणि तुम्हाला स्टोरेज मर्यादेप्रमाणे जागा रिकामी करावी लागू शकते.",
+ "downgradeLabel": "योजना डाऊनग्रेड करा"
+ }
+},
+ "cancelSurveyDialog": {
+ "title": "तुम्ही जात आहात याचे दुःख आहे",
+ "description": "तुम्ही जात आहात याचे आम्हाला खरोखरच दुःख वाटते. @:appName सुधारण्यासाठी तुमचा अभिप्राय आम्हाला महत्त्वाचा वाटतो. कृपया काही क्षण घेऊन काही प्रश्नांची उत्तरे द्या.",
+ "commonOther": "इतर",
+ "otherHint": "तुमचे उत्तर येथे लिहा",
+ "questionOne": {
+ "question": "तुम्ही तुमची @:appName Pro सदस्यता का रद्द केली?",
+ "answerOne": "खर्च खूप जास्त आहे",
+ "answerTwo": "वैशिष्ट्ये अपेक्षांनुसार नव्हती",
+ "answerThree": "यापेक्षा चांगला पर्याय सापडला",
+ "answerFour": "वापर फारसा केला नाही, त्यामुळे खर्च योग्य वाटला नाही",
+ "answerFive": "सेवा समस्या किंवा तांत्रिक अडचणी"
+ },
+ "questionTwo": {
+ "question": "भविष्यात @:appName Pro सदस्यता पुन्हा घेण्याची शक्यता किती आहे?",
+ "answerOne": "खूप शक्यता आहे",
+ "answerTwo": "काहीशी शक्यता आहे",
+ "answerThree": "निश्चित नाही",
+ "answerFour": "अल्प शक्यता",
+ "answerFive": "एकदम कमी शक्यता"
+ },
+ "questionThree": {
+ "question": "तुमच्या सदस्यत्वादरम्यान कोणते Pro वैशिष्ट्य सर्वात उपयुक्त वाटले?",
+ "answerOne": "अनेक वापरकर्त्यांशी सहकार्य",
+ "answerTwo": "लांब कालावधीची आवृत्ती इतिहास",
+ "answerThree": "अमर्यादित AI प्रतिसाद",
+ "answerFour": "स्थानिक AI मॉडेल्सचा प्रवेश"
+ },
+ "questionFour": {
+ "question": "@:appName वापरण्याचा तुमचा एकूण अनुभव कसा होता?",
+ "answerOne": "खूप छान",
+ "answerTwo": "चांगला",
+ "answerThree": "सरासरी",
+ "answerFour": "सरासरीपेक्षा कमी",
+ "answerFive": "असंतोषजनक"
+ }
+},
+ "common": {
+ "uploadingFile": "फाईल अपलोड होत आहे. कृपया अॅप बंद करू नका",
+ "uploadNotionSuccess": "तुमची Notion zip फाईल यशस्वीरित्या अपलोड झाली आहे. आयात पूर्ण झाल्यानंतर तुम्हाला पुष्टीकरण ईमेल मिळेल",
+ "reset": "रीसेट करा"
+},
+ "menu": {
+ "appearance": "दृश्यरूप",
+ "language": "भाषा",
+ "user": "वापरकर्ता",
+ "files": "फाईल्स",
+ "notifications": "सूचना",
+ "open": "सेटिंग्ज उघडा",
+ "logout": "लॉगआउट",
+ "logoutPrompt": "तुम्हाला नक्की लॉगआउट करायचे आहे का?",
+ "selfEncryptionLogoutPrompt": "तुम्हाला खात्रीने लॉगआउट करायचे आहे का? कृपया खात्री करा की तुम्ही एनक्रिप्शन गुप्तकी कॉपी केली आहे",
+ "syncSetting": "सिंक्रोनायझेशन सेटिंग",
+ "cloudSettings": "क्लाऊड सेटिंग्ज",
+ "enableSync": "सिंक्रोनायझेशन सक्षम करा",
+ "enableSyncLog": "सिंक लॉगिंग सक्षम करा",
+ "enableSyncLogWarning": "सिंक समस्यांचे निदान करण्यात मदतीसाठी धन्यवाद. हे तुमचे डॉक्युमेंट संपादन स्थानिक फाईलमध्ये लॉग करेल. कृपया सक्षम केल्यानंतर अॅप बंद करून पुन्हा उघडा",
+ "enableEncrypt": "डेटा एन्क्रिप्ट करा",
+ "cloudURL": "बेस URL",
+ "webURL": "वेब URL",
+ "invalidCloudURLScheme": "अवैध स्कीम",
+ "cloudServerType": "क्लाऊड सर्व्हर",
+ "cloudServerTypeTip": "कृपया लक्षात घ्या की क्लाऊड सर्व्हर बदलल्यास तुम्ही सध्या लॉगिन केलेले खाते लॉगआउट होऊ शकते",
+ "cloudLocal": "स्थानिक",
+ "cloudAppFlowy": "@:appName Cloud",
+ "cloudAppFlowySelfHost": "@:appName क्लाऊड सेल्फ-होस्टेड",
+ "appFlowyCloudUrlCanNotBeEmpty": "क्लाऊड URL रिकामा असू शकत नाही",
+ "clickToCopy": "क्लिपबोर्डवर कॉपी करा",
+ "selfHostStart": "जर तुमच्याकडे सर्व्हर नसेल, तर कृपया हे पाहा",
+ "selfHostContent": "दस्तऐवज",
+ "selfHostEnd": "तुमचा स्वतःचा सर्व्हर कसा होस्ट करावा यासाठी मार्गदर्शनासाठी",
+ "pleaseInputValidURL": "कृपया वैध URL टाका",
+ "changeUrl": "सेल्फ-होस्टेड URL {} मध्ये बदला",
+ "cloudURLHint": "तुमच्या सर्व्हरचा बेस URL टाका",
+ "webURLHint": "तुमच्या वेब सर्व्हरचा बेस URL टाका",
+ "cloudWSURL": "वेबसॉकेट URL",
+ "cloudWSURLHint": "तुमच्या सर्व्हरचा वेबसॉकेट पत्ता टाका",
+ "restartApp": "अॅप रीस्टार्ट करा",
+ "restartAppTip": "बदल प्रभावी होण्यासाठी अॅप रीस्टार्ट करा. कृपया लक्षात घ्या की यामुळे सध्याचे खाते लॉगआउट होऊ शकते.",
+ "changeServerTip": "सर्व्हर बदलल्यानंतर, बदल लागू करण्यासाठी तुम्हाला 'रीस्टार्ट' बटणावर क्लिक करणे आवश्यक आहे",
+ "enableEncryptPrompt": "तुमचा डेटा सुरक्षित करण्यासाठी एन्क्रिप्शन सक्रिय करा. ही गुप्तकी सुरक्षित ठेवा; एकदा सक्षम केल्यावर ती बंद करता येणार नाही. जर हरवली तर तुमचा डेटा पुन्हा मिळवता येणार नाही. कॉपी करण्यासाठी क्लिक करा",
+ "inputEncryptPrompt": "कृपया खालीलसाठी तुमची एनक्रिप्शन गुप्तकी टाका:",
+ "clickToCopySecret": "गुप्तकी कॉपी करण्यासाठी क्लिक करा",
+ "configServerSetting": "तुमच्या सर्व्हर सेटिंग्ज कॉन्फिगर करा",
+ "configServerGuide": "`Quick Start` निवडल्यानंतर, `Settings` → \"Cloud Settings\" मध्ये जा आणि तुमचा सेल्फ-होस्टेड सर्व्हर कॉन्फिगर करा.",
+ "inputTextFieldHint": "तुमची गुप्तकी",
+ "historicalUserList": "वापरकर्ता लॉगिन इतिहास",
+ "historicalUserListTooltip": "ही यादी तुमची अनामिक खाती दर्शवते. तपशील पाहण्यासाठी खात्यावर क्लिक करा. अनामिक खाती 'सुरुवात करा' बटणावर क्लिक करून तयार केली जातात",
+ "openHistoricalUser": "अनामिक खाते उघडण्यासाठी क्लिक करा",
+ "customPathPrompt": "@:appName डेटा फोल्डर Google Drive सारख्या क्लाऊड-सिंक फोल्डरमध्ये साठवणे धोकादायक ठरू शकते. फोल्डरमधील डेटाबेस अनेक ठिकाणांवरून एकाच वेळी ऍक्सेस/संपादित केल्यास डेटा सिंक समस्या किंवा भ्रष्ट होण्याची शक्यता असते.",
+ "importAppFlowyData": "बाह्य @:appName फोल्डरमधून डेटा आयात करा",
+ "importingAppFlowyDataTip": "डेटा आयात सुरू आहे. कृपया अॅप बंद करू नका",
+ "importAppFlowyDataDescription": "बाह्य @:appName फोल्डरमधून डेटा कॉपी करून सध्याच्या AppFlowy डेटा फोल्डरमध्ये आयात करा",
+ "importSuccess": "@:appName डेटा फोल्डर यशस्वीरित्या आयात झाला",
+ "importFailed": "@:appName डेटा फोल्डर आयात करण्यात अयशस्वी",
+ "importGuide": "अधिक माहितीसाठी, कृपया संदर्भित दस्तऐवज पहा"
+},
+ "notifications": {
+ "enableNotifications": {
+ "label": "सूचना सक्षम करा",
+ "hint": "स्थानिक सूचना दिसू नयेत यासाठी बंद करा."
+ },
+ "showNotificationsIcon": {
+ "label": "सूचना चिन्ह दाखवा",
+ "hint": "साइडबारमध्ये सूचना चिन्ह लपवण्यासाठी बंद करा."
+ },
+ "archiveNotifications": {
+ "allSuccess": "सर्व सूचना यशस्वीरित्या संग्रहित केल्या",
+ "success": "सूचना यशस्वीरित्या संग्रहित केली"
+ },
+ "markAsReadNotifications": {
+ "allSuccess": "सर्व वाचलेल्या म्हणून चिन्हांकित केल्या",
+ "success": "वाचलेले म्हणून चिन्हांकित केले"
+ },
+ "action": {
+ "markAsRead": "वाचलेले म्हणून चिन्हांकित करा",
+ "multipleChoice": "अधिक निवडा",
+ "archive": "संग्रहित करा"
+ },
+ "settings": {
+ "settings": "सेटिंग्ज",
+ "markAllAsRead": "सर्व वाचलेले म्हणून चिन्हांकित करा",
+ "archiveAll": "सर्व संग्रहित करा"
+ },
+ "emptyInbox": {
+ "title": "इनबॉक्स झिरो!",
+ "description": "इथे सूचना मिळवण्यासाठी रिमाइंडर सेट करा."
+ },
+ "emptyUnread": {
+ "title": "कोणतीही न वाचलेली सूचना नाही",
+ "description": "तुम्ही सर्व वाचले आहे!"
+ },
+ "emptyArchived": {
+ "title": "कोणतीही संग्रहित सूचना नाही",
+ "description": "संग्रहित सूचना इथे दिसतील."
+ },
+ "tabs": {
+ "inbox": "इनबॉक्स",
+ "unread": "न वाचलेले",
+ "archived": "संग्रहित"
+ },
+ "refreshSuccess": "सूचना यशस्वीरित्या रीफ्रेश केल्या",
+ "titles": {
+ "notifications": "सूचना",
+ "reminder": "रिमाइंडर"
+ }
+},
+ "appearance": {
+ "resetSetting": "रीसेट",
+ "fontFamily": {
+ "label": "फॉन्ट फॅमिली",
+ "search": "शोध",
+ "defaultFont": "सिस्टम"
+ },
+ "themeMode": {
+ "label": "थीम मोड",
+ "light": "लाइट मोड",
+ "dark": "डार्क मोड",
+ "system": "सिस्टमशी जुळवा"
+ },
+ "fontScaleFactor": "फॉन्ट स्केल घटक",
+ "displaySize": "डिस्प्ले आकार",
+ "documentSettings": {
+ "cursorColor": "डॉक्युमेंट कर्सरचा रंग",
+ "selectionColor": "डॉक्युमेंट निवडीचा रंग",
+ "width": "डॉक्युमेंटची रुंदी",
+ "changeWidth": "बदला",
+ "pickColor": "रंग निवडा",
+ "colorShade": "रंगाची छटा",
+ "opacity": "अपारदर्शकता",
+ "hexEmptyError": "Hex रंग रिकामा असू शकत नाही",
+ "hexLengthError": "Hex व्हॅल्यू 6 अंकांची असावी",
+ "hexInvalidError": "अवैध Hex व्हॅल्यू",
+ "opacityEmptyError": "अपारदर्शकता रिकामी असू शकत नाही",
+ "opacityRangeError": "अपारदर्शकता 1 ते 100 दरम्यान असावी",
+ "app": "अॅप",
+ "flowy": "Flowy",
+ "apply": "लागू करा"
+ },
+ "layoutDirection": {
+ "label": "लेआउट दिशा",
+ "hint": "तुमच्या स्क्रीनवरील कंटेंटचा प्रवाह नियंत्रित करा — डावीकडून उजवीकडे किंवा उजवीकडून डावीकडे.",
+ "ltr": "LTR",
+ "rtl": "RTL"
+ },
+ "textDirection": {
+ "label": "मूलभूत मजकूर दिशा",
+ "hint": "मजकूर डावीकडून सुरू व्हावा की उजवीकडून याचे पूर्वनिर्धारित निर्धारण करा.",
+ "ltr": "LTR",
+ "rtl": "RTL",
+ "auto": "स्वयं",
+ "fallback": "लेआउट दिशेशी जुळवा"
+ },
+ "themeUpload": {
+ "button": "अपलोड",
+ "uploadTheme": "थीम अपलोड करा",
+ "description": "खालील बटण वापरून तुमची स्वतःची @:appName थीम अपलोड करा.",
+ "loading": "कृपया प्रतीक्षा करा. आम्ही तुमची थीम पडताळत आहोत आणि अपलोड करत आहोत...",
+ "uploadSuccess": "तुमची थीम यशस्वीरित्या अपलोड झाली आहे",
+ "deletionFailure": "थीम हटवण्यात अयशस्वी. कृपया ती मॅन्युअली हटवून पाहा.",
+ "filePickerDialogTitle": ".flowy_plugin फाईल निवडा",
+ "urlUploadFailure": "URL उघडण्यात अयशस्वी: {}"
+ },
+ "theme": "थीम",
+ "builtInsLabel": "अंतर्गत थीम्स",
+ "pluginsLabel": "प्लगइन्स",
+ "dateFormat": {
+ "label": "दिनांक फॉरमॅट",
+ "local": "स्थानिक",
+ "us": "US",
+ "iso": "ISO",
+ "friendly": "अनौपचारिक",
+ "dmy": "D/M/Y"
+ },
+ "timeFormat": {
+ "label": "वेळ फॉरमॅट",
+ "twelveHour": "१२ तास",
+ "twentyFourHour": "२४ तास"
+ },
+ "showNamingDialogWhenCreatingPage": "पृष्ठ तयार करताना नाव विचारणारा डायलॉग दाखवा",
+ "enableRTLToolbarItems": "RTL टूलबार आयटम्स सक्षम करा",
+ "members": {
+ "title": "सदस्य सेटिंग्ज",
+ "inviteMembers": "सदस्यांना आमंत्रण द्या",
+ "inviteHint": "ईमेलद्वारे आमंत्रण द्या",
+ "sendInvite": "आमंत्रण पाठवा",
+ "copyInviteLink": "आमंत्रण दुवा कॉपी करा",
+ "label": "सदस्य",
+ "user": "वापरकर्ता",
+ "role": "भूमिका",
+ "removeFromWorkspace": "वर्कस्पेसमधून काढा",
+ "removeFromWorkspaceSuccess": "वर्कस्पेसमधून यशस्वीरित्या काढले",
+ "removeFromWorkspaceFailed": "वर्कस्पेसमधून काढण्यात अयशस्वी",
+ "owner": "मालक",
+ "guest": "अतिथी",
+ "member": "सदस्य",
+ "memberHintText": "सदस्य पृष्ठे वाचू व संपादित करू शकतो",
+ "guestHintText": "अतिथी पृष्ठे वाचू शकतो, प्रतिक्रिया देऊ शकतो, टिप्पणी करू शकतो, आणि परवानगी असल्यास काही पृष्ठे संपादित करू शकतो.",
+ "emailInvalidError": "अवैध ईमेल, कृपया तपासा व पुन्हा प्रयत्न करा",
+ "emailSent": "ईमेल पाठवला गेला आहे, कृपया इनबॉक्स तपासा",
+ "members": "सदस्य",
+ "membersCount": {
+ "zero": "{} सदस्य",
+ "one": "{} सदस्य",
+ "other": "{} सदस्य"
+ },
+ "inviteFailedDialogTitle": "आमंत्रण पाठवण्यात अयशस्वी",
+ "inviteFailedMemberLimit": "सदस्य मर्यादा गाठली आहे, अधिक सदस्यांसाठी कृपया अपग्रेड करा.",
+ "inviteFailedMemberLimitMobile": "तुमच्या वर्कस्पेसने सदस्य मर्यादा गाठली आहे.",
+ "memberLimitExceeded": "सदस्य मर्यादा गाठली आहे, अधिक सदस्य आमंत्रित करण्यासाठी कृपया ",
+ "memberLimitExceededUpgrade": "अपग्रेड करा",
+ "memberLimitExceededPro": "सदस्य मर्यादा गाठली आहे, अधिक सदस्य आवश्यक असल्यास संपर्क करा",
+ "memberLimitExceededProContact": "support@appflowy.io",
+ "failedToAddMember": "सदस्य जोडण्यात अयशस्वी",
+ "addMemberSuccess": "सदस्य यशस्वीरित्या जोडला गेला",
+ "removeMember": "सदस्य काढा",
+ "areYouSureToRemoveMember": "तुम्हाला हा सदस्य काढायचा आहे का?",
+ "inviteMemberSuccess": "आमंत्रण यशस्वीरित्या पाठवले गेले",
+ "failedToInviteMember": "सदस्य आमंत्रित करण्यात अयशस्वी",
+ "workspaceMembersError": "अरे! काहीतरी चूक झाली आहे",
+ "workspaceMembersErrorDescription": "आम्ही सध्या सदस्यांची यादी लोड करू शकत नाही. कृपया नंतर पुन्हा प्रयत्न करा"
+ }
+},
+ "files": {
+ "copy": "कॉपी करा",
+ "defaultLocation": "फाईल्स आणि डेटाचा संचय स्थान",
+ "exportData": "तुमचा डेटा निर्यात करा",
+ "doubleTapToCopy": "पथ कॉपी करण्यासाठी दोनदा टॅप करा",
+ "restoreLocation": "@:appName चे मूळ स्थान पुनर्संचयित करा",
+ "customizeLocation": "इतर फोल्डर उघडा",
+ "restartApp": "बदल लागू करण्यासाठी कृपया अॅप रीस्टार्ट करा.",
+ "exportDatabase": "डेटाबेस निर्यात करा",
+ "selectFiles": "निर्यात करण्यासाठी फाईल्स निवडा",
+ "selectAll": "सर्व निवडा",
+ "deselectAll": "सर्व निवड रद्द करा",
+ "createNewFolder": "नवीन फोल्डर तयार करा",
+ "createNewFolderDesc": "तुमचा डेटा कुठे साठवायचा हे सांगा",
+ "defineWhereYourDataIsStored": "तुमचा डेटा कुठे साठवला जातो हे ठरवा",
+ "open": "उघडा",
+ "openFolder": "आधीक फोल्डर उघडा",
+ "openFolderDesc": "तुमच्या विद्यमान @:appName फोल्डरमध्ये वाचन व लेखन करा",
+ "folderHintText": "फोल्डरचे नाव",
+ "location": "नवीन फोल्डर तयार करत आहे",
+ "locationDesc": "तुमच्या @:appName डेटासाठी नाव निवडा",
+ "browser": "ब्राउझ करा",
+ "create": "तयार करा",
+ "set": "सेट करा",
+ "folderPath": "फोल्डर साठवण्याचा मार्ग",
+ "locationCannotBeEmpty": "मार्ग रिकामा असू शकत नाही",
+ "pathCopiedSnackbar": "फाईल संचय मार्ग क्लिपबोर्डवर कॉपी केला!",
+ "changeLocationTooltips": "डेटा डिरेक्टरी बदला",
+ "change": "बदला",
+ "openLocationTooltips": "इतर डेटा डिरेक्टरी उघडा",
+ "openCurrentDataFolder": "सध्याची डेटा डिरेक्टरी उघडा",
+ "recoverLocationTooltips": "@:appName च्या मूळ डेटा डिरेक्टरीवर रीसेट करा",
+ "exportFileSuccess": "फाईल यशस्वीरित्या निर्यात झाली!",
+ "exportFileFail": "फाईल निर्यात करण्यात अयशस्वी!",
+ "export": "निर्यात करा",
+ "clearCache": "कॅशे साफ करा",
+ "clearCacheDesc": "प्रतिमा लोड होत नाहीत किंवा फॉन्ट अयोग्यरित्या दिसत असल्यास, कॅशे साफ करून पाहा. ही क्रिया तुमचा वापरकर्ता डेटा हटवणार नाही.",
+ "areYouSureToClearCache": "तुम्हाला नक्की कॅशे साफ करायची आहे का?",
+ "clearCacheSuccess": "कॅशे यशस्वीरित्या साफ झाली!"
+},
+ "user": {
+ "name": "नाव",
+ "email": "ईमेल",
+ "tooltipSelectIcon": "चिन्ह निवडा",
+ "selectAnIcon": "चिन्ह निवडा",
+ "pleaseInputYourOpenAIKey": "कृपया तुमची AI की टाका",
+ "clickToLogout": "सध्याचा वापरकर्ता लॉगआउट करण्यासाठी क्लिक करा"
+},
+ "mobile": {
+ "personalInfo": "वैयक्तिक माहिती",
+ "username": "वापरकर्तानाव",
+ "usernameEmptyError": "वापरकर्तानाव रिकामे असू शकत नाही",
+ "about": "विषयी",
+ "pushNotifications": "पुश सूचना",
+ "support": "सपोर्ट",
+ "joinDiscord": "Discord मध्ये सहभागी व्हा",
+ "privacyPolicy": "गोपनीयता धोरण",
+ "userAgreement": "वापरकर्ता करार",
+ "termsAndConditions": "अटी व शर्ती",
+ "userprofileError": "वापरकर्ता प्रोफाइल लोड करण्यात अयशस्वी",
+ "userprofileErrorDescription": "कृपया लॉगआउट करून पुन्हा लॉगिन करा आणि त्रुटी अजूनही येते का ते पहा.",
+ "selectLayout": "लेआउट निवडा",
+ "selectStartingDay": "सप्ताहाचा प्रारंभ दिवस निवडा",
+ "version": "आवृत्ती"
+},
+ "grid": {
+ "deleteView": "तुम्हाला हे दृश्य हटवायचे आहे का?",
+ "createView": "नवीन",
+ "title": {
+ "placeholder": "नाव नाही"
+ },
+ "settings": {
+ "filter": "फिल्टर",
+ "sort": "क्रमवारी",
+ "sortBy": "यावरून क्रमवारी लावा",
+ "properties": "गुणधर्म",
+ "reorderPropertiesTooltip": "गुणधर्मांचे स्थान बदला",
+ "group": "समूह",
+ "addFilter": "फिल्टर जोडा",
+ "deleteFilter": "फिल्टर हटवा",
+ "filterBy": "यावरून फिल्टर करा",
+ "typeAValue": "मूल्य लिहा...",
+ "layout": "लेआउट",
+ "compactMode": "कॉम्पॅक्ट मोड",
+ "databaseLayout": "लेआउट",
+ "viewList": {
+ "zero": "० दृश्ये",
+ "one": "{count} दृश्य",
+ "other": "{count} दृश्ये"
+ },
+ "editView": "दृश्य संपादित करा",
+ "boardSettings": "बोर्ड सेटिंग",
+ "calendarSettings": "कॅलेंडर सेटिंग",
+ "createView": "नवीन दृश्य",
+ "duplicateView": "दृश्याची प्रत बनवा",
+ "deleteView": "दृश्य हटवा",
+ "numberOfVisibleFields": "{} दर्शविले"
+ },
+ "filter": {
+ "empty": "कोणतेही सक्रिय फिल्टर नाहीत",
+ "addFilter": "फिल्टर जोडा",
+ "cannotFindCreatableField": "फिल्टर करण्यासाठी योग्य फील्ड सापडले नाही",
+ "conditon": "अट",
+ "where": "जिथे"
+ },
+ "textFilter": {
+ "contains": "अंतर्भूत आहे",
+ "doesNotContain": "अंतर्भूत नाही",
+ "endsWith": "याने समाप्त होते",
+ "startWith": "याने सुरू होते",
+ "is": "आहे",
+ "isNot": "नाही",
+ "isEmpty": "रिकामे आहे",
+ "isNotEmpty": "रिकामे नाही",
+ "choicechipPrefix": {
+ "isNot": "नाही",
+ "startWith": "याने सुरू होते",
+ "endWith": "याने समाप्त होते",
+ "isEmpty": "रिकामे आहे",
+ "isNotEmpty": "रिकामे नाही"
+ }
+ },
+ "checkboxFilter": {
+ "isChecked": "निवडलेले आहे",
+ "isUnchecked": "निवडलेले नाही",
+ "choicechipPrefix": {
+ "is": "आहे"
+ }
+ },
+ "checklistFilter": {
+ "isComplete": "पूर्ण झाले आहे",
+ "isIncomplted": "अपूर्ण आहे"
+ },
+ "selectOptionFilter": {
+ "is": "आहे",
+ "isNot": "नाही",
+ "contains": "अंतर्भूत आहे",
+ "doesNotContain": "अंतर्भूत नाही",
+ "isEmpty": "रिकामे आहे",
+ "isNotEmpty": "रिकामे नाही"
+},
+"dateFilter": {
+ "is": "या दिवशी आहे",
+ "before": "पूर्वी आहे",
+ "after": "नंतर आहे",
+ "onOrBefore": "या दिवशी किंवा त्याआधी आहे",
+ "onOrAfter": "या दिवशी किंवा त्यानंतर आहे",
+ "between": "दरम्यान आहे",
+ "empty": "रिकामे आहे",
+ "notEmpty": "रिकामे नाही",
+ "startDate": "सुरुवातीची तारीख",
+ "endDate": "शेवटची तारीख",
+ "choicechipPrefix": {
+ "before": "पूर्वी",
+ "after": "नंतर",
+ "between": "दरम्यान",
+ "onOrBefore": "या दिवशी किंवा त्याआधी",
+ "onOrAfter": "या दिवशी किंवा त्यानंतर",
+ "isEmpty": "रिकामे आहे",
+ "isNotEmpty": "रिकामे नाही"
+ }
+},
+"numberFilter": {
+ "equal": "बरोबर आहे",
+ "notEqual": "बरोबर नाही",
+ "lessThan": "पेक्षा कमी आहे",
+ "greaterThan": "पेक्षा जास्त आहे",
+ "lessThanOrEqualTo": "किंवा कमी आहे",
+ "greaterThanOrEqualTo": "किंवा जास्त आहे",
+ "isEmpty": "रिकामे आहे",
+ "isNotEmpty": "रिकामे नाही"
+},
+"field": {
+ "label": "गुणधर्म",
+ "hide": "गुणधर्म लपवा",
+ "show": "गुणधर्म दर्शवा",
+ "insertLeft": "डावीकडे जोडा",
+ "insertRight": "उजवीकडे जोडा",
+ "duplicate": "प्रत बनवा",
+ "delete": "हटवा",
+ "wrapCellContent": "पाठ लपेटा",
+ "clear": "सेल्स रिकामे करा",
+ "switchPrimaryFieldTooltip": "प्राथमिक फील्डचा प्रकार बदलू शकत नाही",
+ "textFieldName": "मजकूर",
+ "checkboxFieldName": "चेकबॉक्स",
+ "dateFieldName": "तारीख",
+ "updatedAtFieldName": "शेवटचे अपडेट",
+ "createdAtFieldName": "तयार झाले",
+ "numberFieldName": "संख्या",
+ "singleSelectFieldName": "सिंगल सिलेक्ट",
+ "multiSelectFieldName": "मल्टीसिलेक्ट",
+ "urlFieldName": "URL",
+ "checklistFieldName": "चेकलिस्ट",
+ "relationFieldName": "संबंध",
+ "summaryFieldName": "AI सारांश",
+ "timeFieldName": "वेळ",
+ "mediaFieldName": "फाईल्स आणि मीडिया",
+ "translateFieldName": "AI भाषांतर",
+ "translateTo": "मध्ये भाषांतर करा",
+ "numberFormat": "संख्या स्वरूप",
+ "dateFormat": "तारीख स्वरूप",
+ "includeTime": "वेळ जोडा",
+ "isRange": "शेवटची तारीख",
+ "dateFormatFriendly": "महिना दिवस, वर्ष",
+ "dateFormatISO": "वर्ष-महिना-दिनांक",
+ "dateFormatLocal": "महिना/दिवस/वर्ष",
+ "dateFormatUS": "वर्ष/महिना/दिवस",
+ "dateFormatDayMonthYear": "दिवस/महिना/वर्ष",
+ "timeFormat": "वेळ स्वरूप",
+ "invalidTimeFormat": "अवैध स्वरूप",
+ "timeFormatTwelveHour": "१२ तास",
+ "timeFormatTwentyFourHour": "२४ तास",
+ "clearDate": "तारीख हटवा",
+ "dateTime": "तारीख व वेळ",
+ "startDateTime": "सुरुवातीची तारीख व वेळ",
+ "endDateTime": "शेवटची तारीख व वेळ",
+ "failedToLoadDate": "तारीख मूल्य लोड करण्यात अयशस्वी",
+ "selectTime": "वेळ निवडा",
+ "selectDate": "तारीख निवडा",
+ "visibility": "दृश्यता",
+ "propertyType": "गुणधर्माचा प्रकार",
+ "addSelectOption": "पर्याय जोडा",
+ "typeANewOption": "नवीन पर्याय लिहा",
+ "optionTitle": "पर्याय",
+ "addOption": "पर्याय जोडा",
+ "editProperty": "गुणधर्म संपादित करा",
+ "newProperty": "नवीन गुणधर्म",
+ "openRowDocument": "पृष्ठ म्हणून उघडा",
+ "deleteFieldPromptMessage": "तुम्हाला खात्री आहे का? हा गुणधर्म आणि त्याचा डेटा हटवला जाईल",
+ "clearFieldPromptMessage": "तुम्हाला खात्री आहे का? या कॉलममधील सर्व सेल्स रिकामे होतील",
+ "newColumn": "नवीन कॉलम",
+ "format": "स्वरूप",
+ "reminderOnDateTooltip": "या सेलमध्ये अनुस्मारक शेड्यूल केले आहे",
+ "optionAlreadyExist": "पर्याय आधीच अस्तित्वात आहे"
+},
+ "rowPage": {
+ "newField": "नवीन फील्ड जोडा",
+ "fieldDragElementTooltip": "मेनू उघडण्यासाठी क्लिक करा",
+ "showHiddenFields": {
+ "one": "{count} लपलेले फील्ड दाखवा",
+ "many": "{count} लपलेली फील्ड दाखवा",
+ "other": "{count} लपलेली फील्ड दाखवा"
+ },
+ "hideHiddenFields": {
+ "one": "{count} लपलेले फील्ड लपवा",
+ "many": "{count} लपलेली फील्ड लपवा",
+ "other": "{count} लपलेली फील्ड लपवा"
+ },
+ "openAsFullPage": "पूर्ण पृष्ठ म्हणून उघडा",
+ "moreRowActions": "अधिक पंक्ती क्रिया"
+},
+"sort": {
+ "ascending": "चढत्या क्रमाने",
+ "descending": "उतरत्या क्रमाने",
+ "by": "द्वारे",
+ "empty": "सक्रिय सॉर्ट्स नाहीत",
+ "cannotFindCreatableField": "सॉर्टसाठी योग्य फील्ड सापडले नाही",
+ "deleteAllSorts": "सर्व सॉर्ट्स हटवा",
+ "addSort": "सॉर्ट जोडा",
+ "sortsActive": "सॉर्टिंग करत असताना {intention} करू शकत नाही",
+ "removeSorting": "या दृश्यातील सर्व सॉर्ट्स हटवायचे आहेत का?",
+ "fieldInUse": "या फील्डवर आधीच सॉर्टिंग चालू आहे"
+},
+"row": {
+ "label": "पंक्ती",
+ "duplicate": "प्रत बनवा",
+ "delete": "हटवा",
+ "titlePlaceholder": "शीर्षक नाही",
+ "textPlaceholder": "रिक्त",
+ "copyProperty": "गुणधर्म क्लिपबोर्डवर कॉपी केला",
+ "count": "संख्या",
+ "newRow": "नवीन पंक्ती",
+ "loadMore": "अधिक लोड करा",
+ "action": "क्रिया",
+ "add": "खाली जोडा वर क्लिक करा",
+ "drag": "हलवण्यासाठी ड्रॅग करा",
+ "deleteRowPrompt": "तुम्हाला खात्री आहे का की ही पंक्ती हटवायची आहे? ही क्रिया पूर्ववत करता येणार नाही.",
+ "deleteCardPrompt": "तुम्हाला खात्री आहे का की हे कार्ड हटवायचे आहे? ही क्रिया पूर्ववत करता येणार नाही.",
+ "dragAndClick": "हलवण्यासाठी ड्रॅग करा, मेनू उघडण्यासाठी क्लिक करा",
+ "insertRecordAbove": "वर रेकॉर्ड जोडा",
+ "insertRecordBelow": "खाली रेकॉर्ड जोडा",
+ "noContent": "माहिती नाही",
+ "reorderRowDescription": "पंक्तीचे पुन्हा क्रमांकन",
+ "createRowAboveDescription": "वर पंक्ती तयार करा",
+ "createRowBelowDescription": "खाली पंक्ती जोडा"
+},
+"selectOption": {
+ "create": "तयार करा",
+ "purpleColor": "जांभळा",
+ "pinkColor": "गुलाबी",
+ "lightPinkColor": "फिकट गुलाबी",
+ "orangeColor": "नारंगी",
+ "yellowColor": "पिवळा",
+ "limeColor": "लिंबू",
+ "greenColor": "हिरवा",
+ "aquaColor": "आक्वा",
+ "blueColor": "निळा",
+ "deleteTag": "टॅग हटवा",
+ "colorPanelTitle": "रंग",
+ "panelTitle": "पर्याय निवडा किंवा नवीन तयार करा",
+ "searchOption": "पर्याय शोधा",
+ "searchOrCreateOption": "पर्याय शोधा किंवा तयार करा",
+ "createNew": "नवीन तयार करा",
+ "orSelectOne": "किंवा पर्याय निवडा",
+ "typeANewOption": "नवीन पर्याय टाइप करा",
+ "tagName": "टॅग नाव"
+},
+"checklist": {
+ "taskHint": "कार्याचे वर्णन",
+ "addNew": "नवीन कार्य जोडा",
+ "submitNewTask": "तयार करा",
+ "hideComplete": "पूर्ण कार्ये लपवा",
+ "showComplete": "सर्व कार्ये दाखवा"
+},
+"url": {
+ "launch": "बाह्य ब्राउझरमध्ये लिंक उघडा",
+ "copy": "लिंक क्लिपबोर्डवर कॉपी करा",
+ "textFieldHint": "URL टाका",
+ "copiedNotification": "क्लिपबोर्डवर कॉपी केले!"
+},
+"relation": {
+ "relatedDatabasePlaceLabel": "संबंधित डेटाबेस",
+ "relatedDatabasePlaceholder": "काही नाही",
+ "inRelatedDatabase": "या मध्ये",
+ "rowSearchTextFieldPlaceholder": "शोध",
+ "noDatabaseSelected": "कोणताही डेटाबेस निवडलेला नाही, कृपया खालील यादीतून एक निवडा:",
+ "emptySearchResult": "कोणतीही नोंद सापडली नाही",
+ "linkedRowListLabel": "{count} लिंक केलेल्या पंक्ती",
+ "unlinkedRowListLabel": "आणखी एक पंक्ती लिंक करा"
+},
+"menuName": "ग्रिड",
+"referencedGridPrefix": "दृश्य",
+"calculate": "गणना करा",
+"calculationTypeLabel": {
+ "none": "काही नाही",
+ "average": "सरासरी",
+ "max": "कमाल",
+ "median": "मध्यम",
+ "min": "किमान",
+ "sum": "बेरीज",
+ "count": "मोजणी",
+ "countEmpty": "रिकाम्यांची मोजणी",
+ "countEmptyShort": "रिक्त",
+ "countNonEmpty": "रिक्त नसलेल्यांची मोजणी",
+ "countNonEmptyShort": "भरलेले"
+},
+"media": {
+ "rename": "पुन्हा नाव द्या",
+ "download": "डाउनलोड करा",
+ "expand": "मोठे करा",
+ "delete": "हटवा",
+ "moreFilesHint": "+{}",
+ "addFileOrImage": "फाईल किंवा लिंक जोडा",
+ "attachmentsHint": "{}",
+ "addFileMobile": "फाईल जोडा",
+ "extraCount": "+{}",
+ "deleteFileDescription": "तुम्हाला खात्री आहे का की ही फाईल हटवायची आहे? ही क्रिया पूर्ववत करता येणार नाही.",
+ "showFileNames": "फाईलचे नाव दाखवा",
+ "downloadSuccess": "फाईल डाउनलोड झाली",
+ "downloadFailedToken": "फाईल डाउनलोड अयशस्वी, वापरकर्ता टोकन अनुपलब्ध",
+ "setAsCover": "कव्हर म्हणून सेट करा",
+ "openInBrowser": "ब्राउझरमध्ये उघडा",
+ "embedLink": "फाईल लिंक एम्बेड करा"
+ }
+},
+ "document": {
+ "menuName": "दस्तऐवज",
+ "date": {
+ "timeHintTextInTwelveHour": "01:00 PM",
+ "timeHintTextInTwentyFourHour": "13:00"
+ },
+ "creating": "तयार करत आहे...",
+ "slashMenu": {
+ "board": {
+ "selectABoardToLinkTo": "लिंक करण्यासाठी बोर्ड निवडा",
+ "createANewBoard": "नवीन बोर्ड तयार करा"
+ },
+ "grid": {
+ "selectAGridToLinkTo": "लिंक करण्यासाठी ग्रिड निवडा",
+ "createANewGrid": "नवीन ग्रिड तयार करा"
+ },
+ "calendar": {
+ "selectACalendarToLinkTo": "लिंक करण्यासाठी दिनदर्शिका निवडा",
+ "createANewCalendar": "नवीन दिनदर्शिका तयार करा"
+ },
+ "document": {
+ "selectADocumentToLinkTo": "लिंक करण्यासाठी दस्तऐवज निवडा"
+ },
+ "name": {
+ "textStyle": "मजकुराची शैली",
+ "list": "यादी",
+ "toggle": "टॉगल",
+ "fileAndMedia": "फाईल व मीडिया",
+ "simpleTable": "सोपे टेबल",
+ "visuals": "दृश्य घटक",
+ "document": "दस्तऐवज",
+ "advanced": "प्रगत",
+ "text": "मजकूर",
+ "heading1": "शीर्षक 1",
+ "heading2": "शीर्षक 2",
+ "heading3": "शीर्षक 3",
+ "image": "प्रतिमा",
+ "bulletedList": "बुलेट यादी",
+ "numberedList": "क्रमांकित यादी",
+ "todoList": "करण्याची यादी",
+ "doc": "दस्तऐवज",
+ "linkedDoc": "पृष्ठाशी लिंक करा",
+ "grid": "ग्रिड",
+ "linkedGrid": "लिंक केलेला ग्रिड",
+ "kanban": "कानबन",
+ "linkedKanban": "लिंक केलेला कानबन",
+ "calendar": "दिनदर्शिका",
+ "linkedCalendar": "लिंक केलेली दिनदर्शिका",
+ "quote": "उद्धरण",
+ "divider": "विभाजक",
+ "table": "टेबल",
+ "callout": "महत्त्वाचा मजकूर",
+ "outline": "रूपरेषा",
+ "mathEquation": "गणिती समीकरण",
+ "code": "कोड",
+ "toggleList": "टॉगल यादी",
+ "toggleHeading1": "टॉगल शीर्षक 1",
+ "toggleHeading2": "टॉगल शीर्षक 2",
+ "toggleHeading3": "टॉगल शीर्षक 3",
+ "emoji": "इमोजी",
+ "aiWriter": "AI ला काहीही विचारा",
+ "dateOrReminder": "दिनांक किंवा स्मरणपत्र",
+ "photoGallery": "फोटो गॅलरी",
+ "file": "फाईल",
+ "twoColumns": "२ स्तंभ",
+ "threeColumns": "३ स्तंभ",
+ "fourColumns": "४ स्तंभ"
+ },
+ "subPage": {
+ "name": "दस्तऐवज",
+ "keyword1": "उपपृष्ठ",
+ "keyword2": "पृष्ठ",
+ "keyword3": "चाइल्ड पृष्ठ",
+ "keyword4": "पृष्ठ जोडा",
+ "keyword5": "एम्बेड पृष्ठ",
+ "keyword6": "नवीन पृष्ठ",
+ "keyword7": "पृष्ठ तयार करा",
+ "keyword8": "दस्तऐवज"
+ }
+ },
+ "selectionMenu": {
+ "outline": "रूपरेषा",
+ "codeBlock": "कोड ब्लॉक"
+ },
+ "plugins": {
+ "referencedBoard": "संदर्भित बोर्ड",
+ "referencedGrid": "संदर्भित ग्रिड",
+ "referencedCalendar": "संदर्भित दिनदर्शिका",
+ "referencedDocument": "संदर्भित दस्तऐवज",
+ "aiWriter": {
+ "userQuestion": "AI ला काहीही विचारा",
+ "continueWriting": "लेखन सुरू ठेवा",
+ "fixSpelling": "स्पेलिंग व व्याकरण सुधारणा",
+ "improveWriting": "लेखन सुधारित करा",
+ "summarize": "सारांश द्या",
+ "explain": "स्पष्टीकरण द्या",
+ "makeShorter": "लहान करा",
+ "makeLonger": "मोठे करा"
+ },
+ "autoGeneratorMenuItemName": "AI लेखक",
+"autoGeneratorTitleName": "AI: काहीही लिहिण्यासाठी AI ला विचारा...",
+"autoGeneratorLearnMore": "अधिक जाणून घ्या",
+"autoGeneratorGenerate": "उत्पन्न करा",
+"autoGeneratorHintText": "AI ला विचारा...",
+"autoGeneratorCantGetOpenAIKey": "AI की मिळवू शकलो नाही",
+"autoGeneratorRewrite": "पुन्हा लिहा",
+"smartEdit": "AI ला विचारा",
+"aI": "AI",
+"smartEditFixSpelling": "स्पेलिंग आणि व्याकरण सुधारा",
+"warning": "⚠️ AI उत्तरं चुकीची किंवा दिशाभूल करणारी असू शकतात.",
+"smartEditSummarize": "सारांश द्या",
+"smartEditImproveWriting": "लेखन सुधारित करा",
+"smartEditMakeLonger": "लांब करा",
+"smartEditCouldNotFetchResult": "AI कडून उत्तर मिळवता आले नाही",
+"smartEditCouldNotFetchKey": "AI की मिळवता आली नाही",
+"smartEditDisabled": "सेटिंग्जमध्ये AI कनेक्ट करा",
+"appflowyAIEditDisabled": "AI वैशिष्ट्ये सक्षम करण्यासाठी साइन इन करा",
+"discardResponse": "AI उत्तर फेकून द्यायचं आहे का?",
+"createInlineMathEquation": "समीकरण तयार करा",
+"fonts": "फॉन्ट्स",
+"insertDate": "तारीख जोडा",
+"emoji": "इमोजी",
+"toggleList": "टॉगल यादी",
+"emptyToggleHeading": "रिकामे टॉगल h{}. मजकूर जोडण्यासाठी क्लिक करा.",
+"emptyToggleList": "रिकामी टॉगल यादी. मजकूर जोडण्यासाठी क्लिक करा.",
+"emptyToggleHeadingWeb": "रिकामे टॉगल h{level}. मजकूर जोडण्यासाठी क्लिक करा",
+"quoteList": "उद्धरण यादी",
+"numberedList": "क्रमांकित यादी",
+"bulletedList": "बुलेट यादी",
+"todoList": "करण्याची यादी",
+"callout": "ठळक मजकूर",
+"simpleTable": {
+ "moreActions": {
+ "color": "रंग",
+ "align": "पंक्तिबद्ध करा",
+ "delete": "हटा",
+ "duplicate": "डुप्लिकेट करा",
+ "insertLeft": "डावीकडे घाला",
+ "insertRight": "उजवीकडे घाला",
+ "insertAbove": "वर घाला",
+ "insertBelow": "खाली घाला",
+ "headerColumn": "हेडर स्तंभ",
+ "headerRow": "हेडर ओळ",
+ "clearContents": "सामग्री साफ करा",
+ "setToPageWidth": "पृष्ठाच्या रुंदीप्रमाणे सेट करा",
+ "distributeColumnsWidth": "स्तंभ समान करा",
+ "duplicateRow": "ओळ डुप्लिकेट करा",
+ "duplicateColumn": "स्तंभ डुप्लिकेट करा",
+ "textColor": "मजकूराचा रंग",
+ "cellBackgroundColor": "सेलचा पार्श्वभूमी रंग",
+ "duplicateTable": "टेबल डुप्लिकेट करा"
+ },
+ "clickToAddNewRow": "नवीन ओळ जोडण्यासाठी क्लिक करा",
+ "clickToAddNewColumn": "नवीन स्तंभ जोडण्यासाठी क्लिक करा",
+ "clickToAddNewRowAndColumn": "नवीन ओळ आणि स्तंभ जोडण्यासाठी क्लिक करा",
+ "headerName": {
+ "table": "टेबल",
+ "alignText": "मजकूर पंक्तिबद्ध करा"
+ }
+},
+"cover": {
+ "changeCover": "कव्हर बदला",
+ "colors": "रंग",
+ "images": "प्रतिमा",
+ "clearAll": "सर्व साफ करा",
+ "abstract": "ऍबस्ट्रॅक्ट",
+ "addCover": "कव्हर जोडा",
+ "addLocalImage": "स्थानिक प्रतिमा जोडा",
+ "invalidImageUrl": "अवैध प्रतिमा URL",
+ "failedToAddImageToGallery": "प्रतिमा गॅलरीत जोडता आली नाही",
+ "enterImageUrl": "प्रतिमा URL लिहा",
+ "add": "जोडा",
+ "back": "मागे",
+ "saveToGallery": "गॅलरीत जतन करा",
+ "removeIcon": "आयकॉन काढा",
+ "removeCover": "कव्हर काढा",
+ "pasteImageUrl": "प्रतिमा URL पेस्ट करा",
+ "or": "किंवा",
+ "pickFromFiles": "फाईल्समधून निवडा",
+ "couldNotFetchImage": "प्रतिमा मिळवता आली नाही",
+ "imageSavingFailed": "प्रतिमा जतन करणे अयशस्वी",
+ "addIcon": "आयकॉन जोडा",
+ "changeIcon": "आयकॉन बदला",
+ "coverRemoveAlert": "हे हटवल्यानंतर कव्हरमधून काढले जाईल.",
+ "alertDialogConfirmation": "तुम्हाला खात्री आहे का? तुम्हाला पुढे जायचे आहे?"
+},
+"mathEquation": {
+ "name": "गणिती समीकरण",
+ "addMathEquation": "TeX समीकरण जोडा",
+ "editMathEquation": "गणिती समीकरण संपादित करा"
+},
+"optionAction": {
+ "click": "क्लिक",
+ "toOpenMenu": "मेनू उघडण्यासाठी",
+ "drag": "ओढा",
+ "toMove": "हलवण्यासाठी",
+ "delete": "हटा",
+ "duplicate": "डुप्लिकेट करा",
+ "turnInto": "मध्ये बदला",
+ "moveUp": "वर हलवा",
+ "moveDown": "खाली हलवा",
+ "color": "रंग",
+ "align": "पंक्तिबद्ध करा",
+ "left": "डावीकडे",
+ "center": "मध्यभागी",
+ "right": "उजवीकडे",
+ "defaultColor": "डिफॉल्ट",
+ "depth": "खोली",
+ "copyLinkToBlock": "ब्लॉकसाठी लिंक कॉपी करा"
+},
+ "image": {
+ "addAnImage": "प्रतिमा जोडा",
+ "copiedToPasteBoard": "प्रतिमेची लिंक क्लिपबोर्डवर कॉपी केली गेली आहे",
+ "addAnImageDesktop": "प्रतिमा जोडा",
+ "addAnImageMobile": "एक किंवा अधिक प्रतिमा जोडण्यासाठी क्लिक करा",
+ "dropImageToInsert": "प्रतिमा ड्रॉप करून जोडा",
+ "imageUploadFailed": "प्रतिमा अपलोड करण्यात अयशस्वी",
+ "imageDownloadFailed": "प्रतिमा डाउनलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा",
+ "imageDownloadFailedToken": "युजर टोकन नसल्यामुळे प्रतिमा डाउनलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा",
+ "errorCode": "त्रुटी कोड"
+},
+"photoGallery": {
+ "name": "फोटो गॅलरी",
+ "imageKeyword": "प्रतिमा",
+ "imageGalleryKeyword": "प्रतिमा गॅलरी",
+ "photoKeyword": "फोटो",
+ "photoBrowserKeyword": "फोटो ब्राउझर",
+ "galleryKeyword": "गॅलरी",
+ "addImageTooltip": "प्रतिमा जोडा",
+ "changeLayoutTooltip": "लेआउट बदला",
+ "browserLayout": "ब्राउझर",
+ "gridLayout": "ग्रिड",
+ "deleteBlockTooltip": "संपूर्ण गॅलरी हटवा"
+},
+"math": {
+ "copiedToPasteBoard": "समीकरण क्लिपबोर्डवर कॉपी केले गेले आहे"
+},
+"urlPreview": {
+ "copiedToPasteBoard": "लिंक क्लिपबोर्डवर कॉपी केली गेली आहे",
+ "convertToLink": "एंबेड लिंकमध्ये रूपांतर करा"
+},
+"outline": {
+ "addHeadingToCreateOutline": "सामग्री यादी तयार करण्यासाठी शीर्षके जोडा.",
+ "noMatchHeadings": "जुळणारी शीर्षके आढळली नाहीत."
+},
+"table": {
+ "addAfter": "नंतर जोडा",
+ "addBefore": "आधी जोडा",
+ "delete": "हटा",
+ "clear": "सामग्री साफ करा",
+ "duplicate": "डुप्लिकेट करा",
+ "bgColor": "पार्श्वभूमीचा रंग"
+},
+"contextMenu": {
+ "copy": "कॉपी करा",
+ "cut": "कापा",
+ "paste": "पेस्ट करा",
+ "pasteAsPlainText": "साध्या मजकूराच्या स्वरूपात पेस्ट करा"
+},
+"action": "कृती",
+"database": {
+ "selectDataSource": "डेटा स्रोत निवडा",
+ "noDataSource": "डेटा स्रोत नाही",
+ "selectADataSource": "डेटा स्रोत निवडा",
+ "toContinue": "पुढे जाण्यासाठी",
+ "newDatabase": "नवीन डेटाबेस",
+ "linkToDatabase": "डेटाबेसशी लिंक करा"
+},
+"date": "तारीख",
+"video": {
+ "label": "व्हिडिओ",
+ "emptyLabel": "व्हिडिओ जोडा",
+ "placeholder": "व्हिडिओ लिंक पेस्ट करा",
+ "copiedToPasteBoard": "व्हिडिओ लिंक क्लिपबोर्डवर कॉपी केली गेली आहे",
+ "insertVideo": "व्हिडिओ जोडा",
+ "invalidVideoUrl": "ही URL सध्या समर्थित नाही.",
+ "invalidVideoUrlYouTube": "YouTube सध्या समर्थित नाही.",
+ "supportedFormats": "समर्थित स्वरूप: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264"
+},
+"file": {
+ "name": "फाईल",
+ "uploadTab": "अपलोड",
+ "uploadMobile": "फाईल निवडा",
+ "uploadMobileGallery": "फोटो गॅलरीमधून",
+ "networkTab": "लिंक एम्बेड करा",
+ "placeholderText": "फाईल अपलोड किंवा एम्बेड करा",
+ "placeholderDragging": "फाईल ड्रॉप करून अपलोड करा",
+ "dropFileToUpload": "फाईल ड्रॉप करून अपलोड करा",
+ "fileUploadHint": "फाईल ड्रॅग आणि ड्रॉप करा किंवा क्लिक करा ",
+ "fileUploadHintSuffix": "ब्राउझ करा",
+ "networkHint": "फाईल लिंक पेस्ट करा",
+ "networkUrlInvalid": "अवैध URL. कृपया तपासा आणि पुन्हा प्रयत्न करा.",
+ "networkAction": "एम्बेड",
+ "fileTooBigError": "फाईल खूप मोठी आहे, कृपया 10MB पेक्षा कमी फाईल अपलोड करा",
+ "renameFile": {
+ "title": "फाईलचे नाव बदला",
+ "description": "या फाईलसाठी नवीन नाव लिहा",
+ "nameEmptyError": "फाईलचे नाव रिकामे असू शकत नाही."
+ },
+ "uploadedAt": "{} रोजी अपलोड केले",
+ "linkedAt": "{} रोजी लिंक जोडली",
+ "failedToOpenMsg": "उघडण्यात अयशस्वी, फाईल सापडली नाही"
+},
+"subPage": {
+ "handlingPasteHint": " - (पेस्ट प्रक्रिया करत आहे)",
+ "errors": {
+ "failedDeletePage": "पृष्ठ हटवण्यात अयशस्वी",
+ "failedCreatePage": "पृष्ठ तयार करण्यात अयशस्वी",
+ "failedMovePage": "हे पृष्ठ हलवण्यात अयशस्वी",
+ "failedDuplicatePage": "पृष्ठ डुप्लिकेट करण्यात अयशस्वी",
+ "failedDuplicateFindView": "पृष्ठ डुप्लिकेट करण्यात अयशस्वी - मूळ दृश्य सापडले नाही"
+ }
+},
+ "cannotMoveToItsChildren": "मुलांमध्ये हलवू शकत नाही"
+},
+"outlineBlock": {
+ "placeholder": "सामग्री सूची"
+},
+"textBlock": {
+ "placeholder": "कमांडसाठी '/' टाइप करा"
+},
+"title": {
+ "placeholder": "शीर्षक नाही"
+},
+"imageBlock": {
+ "placeholder": "प्रतिमा जोडण्यासाठी क्लिक करा",
+ "upload": {
+ "label": "अपलोड",
+ "placeholder": "प्रतिमा अपलोड करण्यासाठी क्लिक करा"
+ },
+ "url": {
+ "label": "प्रतिमेची URL",
+ "placeholder": "प्रतिमेची URL टाका"
+ },
+ "ai": {
+ "label": "AI द्वारे प्रतिमा तयार करा",
+ "placeholder": "AI द्वारे प्रतिमा तयार करण्यासाठी कृपया संकेत द्या"
+ },
+ "stability_ai": {
+ "label": "Stability AI द्वारे प्रतिमा तयार करा",
+ "placeholder": "Stability AI द्वारे प्रतिमा तयार करण्यासाठी कृपया संकेत द्या"
+ },
+ "support": "प्रतिमेचा कमाल आकार 5MB आहे. समर्थित स्वरूप: JPEG, PNG, GIF, SVG",
+ "error": {
+ "invalidImage": "अवैध प्रतिमा",
+ "invalidImageSize": "प्रतिमेचा आकार 5MB पेक्षा कमी असावा",
+ "invalidImageFormat": "प्रतिमेचे स्वरूप समर्थित नाही. समर्थित स्वरूप: JPEG, PNG, JPG, GIF, SVG, WEBP",
+ "invalidImageUrl": "अवैध प्रतिमेची URL",
+ "noImage": "अशी फाईल किंवा निर्देशिका नाही",
+ "multipleImagesFailed": "एक किंवा अधिक प्रतिमा अपलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा"
+ },
+ "embedLink": {
+ "label": "लिंक एम्बेड करा",
+ "placeholder": "प्रतिमेची लिंक पेस्ट करा किंवा टाका"
+ },
+ "unsplash": {
+ "label": "Unsplash"
+ },
+ "searchForAnImage": "प्रतिमा शोधा",
+ "pleaseInputYourOpenAIKey": "कृपया सेटिंग्ज पृष्ठात आपला AI की प्रविष्ट करा",
+ "saveImageToGallery": "प्रतिमा जतन करा",
+ "failedToAddImageToGallery": "प्रतिमा जतन करण्यात अयशस्वी",
+ "successToAddImageToGallery": "प्रतिमा 'Photos' मध्ये जतन केली",
+ "unableToLoadImage": "प्रतिमा लोड करण्यात अयशस्वी",
+ "maximumImageSize": "कमाल प्रतिमा अपलोड आकार 10MB आहे",
+ "uploadImageErrorImageSizeTooBig": "प्रतिमेचा आकार 10MB पेक्षा कमी असावा",
+ "imageIsUploading": "प्रतिमा अपलोड होत आहे",
+ "openFullScreen": "पूर्ण स्क्रीनमध्ये उघडा",
+ "interactiveViewer": {
+ "toolbar": {
+ "previousImageTooltip": "मागील प्रतिमा",
+ "nextImageTooltip": "पुढील प्रतिमा",
+ "zoomOutTooltip": "लहान करा",
+ "zoomInTooltip": "मोठी करा",
+ "changeZoomLevelTooltip": "झूम पातळी बदला",
+ "openLocalImage": "प्रतिमा उघडा",
+ "downloadImage": "प्रतिमा डाउनलोड करा",
+ "closeViewer": "इंटरअॅक्टिव्ह व्ह्युअर बंद करा",
+ "scalePercentage": "{}%",
+ "deleteImageTooltip": "प्रतिमा हटवा"
+ }
+ }
+},
+ "codeBlock": {
+ "language": {
+ "label": "भाषा",
+ "placeholder": "भाषा निवडा",
+ "auto": "स्वयंचलित"
+ },
+ "copyTooltip": "कॉपी करा",
+ "searchLanguageHint": "भाषा शोधा",
+ "codeCopiedSnackbar": "कोड क्लिपबोर्डवर कॉपी झाला!"
+},
+"inlineLink": {
+ "placeholder": "लिंक पेस्ट करा किंवा टाका",
+ "openInNewTab": "नवीन टॅबमध्ये उघडा",
+ "copyLink": "लिंक कॉपी करा",
+ "removeLink": "लिंक काढा",
+ "url": {
+ "label": "लिंक URL",
+ "placeholder": "लिंक URL टाका"
+ },
+ "title": {
+ "label": "लिंक शीर्षक",
+ "placeholder": "लिंक शीर्षक टाका"
+ }
+},
+"mention": {
+ "placeholder": "कोणीतरी, पृष्ठ किंवा तारीख नमूद करा...",
+ "page": {
+ "label": "पृष्ठाला लिंक करा",
+ "tooltip": "पृष्ठ उघडण्यासाठी क्लिक करा"
+ },
+ "deleted": "हटवले गेले",
+ "deletedContent": "ही सामग्री अस्तित्वात नाही किंवा हटवण्यात आली आहे",
+ "noAccess": "प्रवेश नाही",
+ "deletedPage": "हटवलेले पृष्ठ",
+ "trashHint": " - ट्रॅशमध्ये",
+ "morePages": "अजून पृष्ठे"
+},
+"toolbar": {
+ "resetToDefaultFont": "डीफॉल्ट फॉन्टवर परत जा",
+ "textSize": "मजकूराचा आकार",
+ "textColor": "मजकूराचा रंग",
+ "h1": "मथळा 1",
+ "h2": "मथळा 2",
+ "h3": "मथळा 3",
+ "alignLeft": "डावीकडे संरेखित करा",
+ "alignRight": "उजवीकडे संरेखित करा",
+ "alignCenter": "मध्यभागी संरेखित करा",
+ "link": "लिंक",
+ "textAlign": "मजकूर संरेखन",
+ "moreOptions": "अधिक पर्याय",
+ "font": "फॉन्ट",
+ "inlineCode": "इनलाइन कोड",
+ "suggestions": "सूचना",
+ "turnInto": "मध्ये रूपांतरित करा",
+ "equation": "समीकरण",
+ "insert": "घाला",
+ "linkInputHint": "लिंक पेस्ट करा किंवा पृष्ठे शोधा",
+ "pageOrURL": "पृष्ठ किंवा URL",
+ "linkName": "लिंकचे नाव",
+ "linkNameHint": "लिंकचे नाव प्रविष्ट करा"
+},
+"errorBlock": {
+ "theBlockIsNotSupported": "ब्लॉक सामग्री पार्स करण्यात अक्षम",
+ "clickToCopyTheBlockContent": "ब्लॉक सामग्री कॉपी करण्यासाठी क्लिक करा",
+ "blockContentHasBeenCopied": "ब्लॉक सामग्री कॉपी केली आहे.",
+ "parseError": "{} ब्लॉक पार्स करताना त्रुटी आली.",
+ "copyBlockContent": "ब्लॉक सामग्री कॉपी करा"
+},
+"mobilePageSelector": {
+ "title": "पृष्ठ निवडा",
+ "failedToLoad": "पृष्ठ यादी लोड करण्यात अयशस्वी",
+ "noPagesFound": "कोणतीही पृष्ठे सापडली नाहीत"
+},
+"attachmentMenu": {
+ "choosePhoto": "फोटो निवडा",
+ "takePicture": "फोटो काढा",
+ "chooseFile": "फाईल निवडा"
+ }
+ },
+ "board": {
+ "column": {
+ "label": "स्तंभ",
+ "createNewCard": "नवीन",
+ "renameGroupTooltip": "गटाचे नाव बदलण्यासाठी क्लिक करा",
+ "createNewColumn": "नवीन गट जोडा",
+ "addToColumnTopTooltip": "वर नवीन कार्ड जोडा",
+ "addToColumnBottomTooltip": "खाली नवीन कार्ड जोडा",
+ "renameColumn": "स्तंभाचे नाव बदला",
+ "hideColumn": "लपवा",
+ "newGroup": "नवीन गट",
+ "deleteColumn": "हटवा",
+ "deleteColumnConfirmation": "हा गट आणि त्यामधील सर्व कार्ड्स हटवले जातील. तुम्हाला खात्री आहे का?"
+ },
+ "hiddenGroupSection": {
+ "sectionTitle": "लपवलेले गट",
+ "collapseTooltip": "लपवलेले गट लपवा",
+ "expandTooltip": "लपवलेले गट पाहा"
+ },
+ "cardDetail": "कार्ड तपशील",
+ "cardActions": "कार्ड क्रिया",
+ "cardDuplicated": "कार्डची प्रत तयार झाली",
+ "cardDeleted": "कार्ड हटवले गेले",
+ "showOnCard": "कार्ड तपशिलावर दाखवा",
+ "setting": "सेटिंग",
+ "propertyName": "गुणधर्माचे नाव",
+ "menuName": "बोर्ड",
+ "showUngrouped": "गटात नसलेली कार्ड्स दाखवा",
+ "ungroupedButtonText": "गट नसलेली",
+ "ungroupedButtonTooltip": "ज्या कार्ड्स कोणत्याही गटात नाहीत",
+ "ungroupedItemsTitle": "बोर्डमध्ये जोडण्यासाठी क्लिक करा",
+ "groupBy": "या आधारावर गट करा",
+ "groupCondition": "गट स्थिती",
+ "referencedBoardPrefix": "याचे दृश्य",
+ "notesTooltip": "नोट्स आहेत",
+ "mobile": {
+ "editURL": "URL संपादित करा",
+ "showGroup": "गट दाखवा",
+ "showGroupContent": "हा गट बोर्डवर दाखवायचा आहे का?",
+ "failedToLoad": "बोर्ड दृश्य लोड होण्यात अयशस्वी"
+ },
+ "dateCondition": {
+ "weekOf": "{} - {} ची आठवडा",
+ "today": "आज",
+ "yesterday": "काल",
+ "tomorrow": "उद्या",
+ "lastSevenDays": "शेवटचे ७ दिवस",
+ "nextSevenDays": "पुढील ७ दिवस",
+ "lastThirtyDays": "शेवटचे ३० दिवस",
+ "nextThirtyDays": "पुढील ३० दिवस"
+ },
+ "noGroup": "गटासाठी कोणताही गुणधर्म निवडलेला नाही",
+ "noGroupDesc": "बोर्ड दृश्य दाखवण्यासाठी गट करण्याचा एक गुणधर्म आवश्यक आहे",
+ "media": {
+ "cardText": "{} {}",
+ "fallbackName": "फायली"
+ }
+},
+ "calendar": {
+ "menuName": "कॅलेंडर",
+ "defaultNewCalendarTitle": "नाव नाही",
+ "newEventButtonTooltip": "नवीन इव्हेंट जोडा",
+ "navigation": {
+ "today": "आज",
+ "jumpToday": "आजवर जा",
+ "previousMonth": "मागील महिना",
+ "nextMonth": "पुढील महिना",
+ "views": {
+ "day": "दिवस",
+ "week": "आठवडा",
+ "month": "महिना",
+ "year": "वर्ष"
+ }
+ },
+ "mobileEventScreen": {
+ "emptyTitle": "सध्या कोणतेही इव्हेंट नाहीत",
+ "emptyBody": "या दिवशी इव्हेंट तयार करण्यासाठी प्लस बटणावर क्लिक करा."
+ },
+ "settings": {
+ "showWeekNumbers": "आठवड्याचे क्रमांक दाखवा",
+ "showWeekends": "सप्ताहांत दाखवा",
+ "firstDayOfWeek": "आठवड्याची सुरुवात",
+ "layoutDateField": "कॅलेंडर मांडणी दिनांकानुसार",
+ "changeLayoutDateField": "मांडणी फील्ड बदला",
+ "noDateTitle": "तारीख नाही",
+ "noDateHint": {
+ "zero": "नियोजित नसलेली इव्हेंट्स येथे दिसतील",
+ "one": "{count} नियोजित नसलेली इव्हेंट",
+ "other": "{count} नियोजित नसलेल्या इव्हेंट्स"
+ },
+ "unscheduledEventsTitle": "नियोजित नसलेल्या इव्हेंट्स",
+ "clickToAdd": "कॅलेंडरमध्ये जोडण्यासाठी क्लिक करा",
+ "name": "कॅलेंडर सेटिंग्ज",
+ "clickToOpen": "रेकॉर्ड उघडण्यासाठी क्लिक करा"
+ },
+ "referencedCalendarPrefix": "याचे दृश्य",
+ "quickJumpYear": "या वर्षावर जा",
+ "duplicateEvent": "इव्हेंट डुप्लिकेट करा"
+},
+ "errorDialog": {
+ "title": "@:appName त्रुटी",
+ "howToFixFallback": "या गैरसोयीबद्दल आम्ही दिलगीर आहोत! कृपया GitHub पेजवर त्रुटीबद्दल माहिती देणारे एक issue सबमिट करा.",
+ "howToFixFallbackHint1": "या गैरसोयीबद्दल आम्ही दिलगीर आहोत! कृपया ",
+ "howToFixFallbackHint2": " पेजवर त्रुटीचे वर्णन करणारे issue सबमिट करा.",
+ "github": "GitHub वर पहा"
+},
+"search": {
+ "label": "शोध",
+ "sidebarSearchIcon": "पृष्ठ शोधा आणि पटकन जा",
+ "placeholder": {
+ "actions": "कृती शोधा..."
+ }
+},
+"message": {
+ "copy": {
+ "success": "कॉपी झाले!",
+ "fail": "कॉपी करू शकत नाही"
+ }
+},
+"unSupportBlock": "सध्याचा व्हर्जन या ब्लॉकला समर्थन देत नाही.",
+"views": {
+ "deleteContentTitle": "तुम्हाला हे {pageType} हटवायचे आहे का?",
+ "deleteContentCaption": "हे {pageType} हटवल्यास, तुम्ही ते trash मधून पुनर्संचयित करू शकता."
+},
+ "colors": {
+ "custom": "सानुकूल",
+ "default": "डीफॉल्ट",
+ "red": "लाल",
+ "orange": "संत्रा",
+ "yellow": "पिवळा",
+ "green": "हिरवा",
+ "blue": "निळा",
+ "purple": "जांभळा",
+ "pink": "गुलाबी",
+ "brown": "तपकिरी",
+ "gray": "करड्या रंगाचा"
+},
+ "emoji": {
+ "emojiTab": "इमोजी",
+ "search": "इमोजी शोधा",
+ "noRecent": "अलीकडील कोणतेही इमोजी नाहीत",
+ "noEmojiFound": "कोणतेही इमोजी सापडले नाहीत",
+ "filter": "फिल्टर",
+ "random": "योगायोगाने",
+ "selectSkinTone": "त्वचेचा टोन निवडा",
+ "remove": "इमोजी काढा",
+ "categories": {
+ "smileys": "स्मायली आणि भावना",
+ "people": "लोक",
+ "animals": "प्राणी आणि निसर्ग",
+ "food": "अन्न",
+ "activities": "क्रिया",
+ "places": "स्थळे",
+ "objects": "वस्तू",
+ "symbols": "चिन्हे",
+ "flags": "ध्वज",
+ "nature": "निसर्ग",
+ "frequentlyUsed": "नेहमी वापरलेले"
+ },
+ "skinTone": {
+ "default": "डीफॉल्ट",
+ "light": "हलका",
+ "mediumLight": "मध्यम-हलका",
+ "medium": "मध्यम",
+ "mediumDark": "मध्यम-गडद",
+ "dark": "गडद"
+ },
+ "openSourceIconsFrom": "खुल्या स्रोताचे आयकॉन्स"
+},
+ "inlineActions": {
+ "noResults": "निकाल नाही",
+ "recentPages": "अलीकडील पृष्ठे",
+ "pageReference": "पृष्ठ संदर्भ",
+ "docReference": "दस्तऐवज संदर्भ",
+ "boardReference": "बोर्ड संदर्भ",
+ "calReference": "कॅलेंडर संदर्भ",
+ "gridReference": "ग्रिड संदर्भ",
+ "date": "तारीख",
+ "reminder": {
+ "groupTitle": "स्मरणपत्र",
+ "shortKeyword": "remind"
+ },
+ "createPage": "\"{}\" उप-पृष्ठ तयार करा"
+},
+ "datePicker": {
+ "dateTimeFormatTooltip": "सेटिंग्जमध्ये तारीख आणि वेळ फॉरमॅट बदला",
+ "dateFormat": "तारीख फॉरमॅट",
+ "includeTime": "वेळ समाविष्ट करा",
+ "isRange": "शेवटची तारीख",
+ "timeFormat": "वेळ फॉरमॅट",
+ "clearDate": "तारीख साफ करा",
+ "reminderLabel": "स्मरणपत्र",
+ "selectReminder": "स्मरणपत्र निवडा",
+ "reminderOptions": {
+ "none": "काहीही नाही",
+ "atTimeOfEvent": "इव्हेंटच्या वेळी",
+ "fiveMinsBefore": "५ मिनिटे आधी",
+ "tenMinsBefore": "१० मिनिटे आधी",
+ "fifteenMinsBefore": "१५ मिनिटे आधी",
+ "thirtyMinsBefore": "३० मिनिटे आधी",
+ "oneHourBefore": "१ तास आधी",
+ "twoHoursBefore": "२ तास आधी",
+ "onDayOfEvent": "इव्हेंटच्या दिवशी",
+ "oneDayBefore": "१ दिवस आधी",
+ "twoDaysBefore": "२ दिवस आधी",
+ "oneWeekBefore": "१ आठवडा आधी",
+ "custom": "सानुकूल"
+ }
+},
+ "relativeDates": {
+ "yesterday": "काल",
+ "today": "आज",
+ "tomorrow": "उद्या",
+ "oneWeek": "१ आठवडा"
+},
+ "notificationHub": {
+ "title": "सूचना",
+ "mobile": {
+ "title": "अपडेट्स"
+ },
+ "emptyTitle": "सर्व पूर्ण झाले!",
+ "emptyBody": "कोणतीही प्रलंबित सूचना किंवा कृती नाहीत. शांततेचा आनंद घ्या.",
+ "tabs": {
+ "inbox": "इनबॉक्स",
+ "upcoming": "आगामी"
+ },
+ "actions": {
+ "markAllRead": "सर्व वाचलेल्या म्हणून चिन्हित करा",
+ "showAll": "सर्व",
+ "showUnreads": "न वाचलेल्या"
+ },
+ "filters": {
+ "ascending": "आरोही",
+ "descending": "अवरोही",
+ "groupByDate": "तारीखेनुसार गटबद्ध करा",
+ "showUnreadsOnly": "फक्त न वाचलेल्या दाखवा",
+ "resetToDefault": "डीफॉल्टवर रीसेट करा"
+ }
+},
+ "reminderNotification": {
+ "title": "स्मरणपत्र",
+ "message": "तुम्ही विसरण्याआधी हे तपासण्याचे लक्षात ठेवा!",
+ "tooltipDelete": "हटवा",
+ "tooltipMarkRead": "वाचले म्हणून चिन्हित करा",
+ "tooltipMarkUnread": "न वाचले म्हणून चिन्हित करा"
+},
+ "findAndReplace": {
+ "find": "शोधा",
+ "previousMatch": "मागील जुळणारे",
+ "nextMatch": "पुढील जुळणारे",
+ "close": "बंद करा",
+ "replace": "बदला",
+ "replaceAll": "सर्व बदला",
+ "noResult": "कोणतेही निकाल नाहीत",
+ "caseSensitive": "केस सेंसिटिव्ह",
+ "searchMore": "अधिक निकालांसाठी शोधा"
+},
+ "error": {
+ "weAreSorry": "आम्ही क्षमस्व आहोत",
+ "loadingViewError": "हे दृश्य लोड करण्यात अडचण येत आहे. कृपया तुमचे इंटरनेट कनेक्शन तपासा, अॅप रीफ्रेश करा, आणि समस्या कायम असल्यास आमच्याशी संपर्क साधा.",
+ "syncError": "इतर डिव्हाइसमधून डेटा सिंक झाला नाही",
+ "syncErrorHint": "कृपया हे पृष्ठ शेवटचे जिथे संपादित केले होते त्या डिव्हाइसवर उघडा, आणि मग सध्याच्या डिव्हाइसवर पुन्हा उघडा.",
+ "clickToCopy": "एरर कोड कॉपी करण्यासाठी क्लिक करा"
+},
+ "editor": {
+ "bold": "जाड",
+ "bulletedList": "बुलेट यादी",
+ "bulletedListShortForm": "बुलेट",
+ "checkbox": "चेकबॉक्स",
+ "embedCode": "कोड एम्बेड करा",
+ "heading1": "H1",
+ "heading2": "H2",
+ "heading3": "H3",
+ "highlight": "हायलाइट",
+ "color": "रंग",
+ "image": "प्रतिमा",
+ "date": "तारीख",
+ "page": "पृष्ठ",
+ "italic": "तिरका",
+ "link": "लिंक",
+ "numberedList": "क्रमांकित यादी",
+ "numberedListShortForm": "क्रमांकित",
+ "toggleHeading1ShortForm": "Toggle H1",
+ "toggleHeading2ShortForm": "Toggle H2",
+ "toggleHeading3ShortForm": "Toggle H3",
+ "quote": "कोट",
+ "strikethrough": "ओढून टाका",
+ "text": "मजकूर",
+ "underline": "अधोरेखित",
+ "fontColorDefault": "डीफॉल्ट",
+ "fontColorGray": "धूसर",
+ "fontColorBrown": "तपकिरी",
+ "fontColorOrange": "केशरी",
+ "fontColorYellow": "पिवळा",
+ "fontColorGreen": "हिरवा",
+ "fontColorBlue": "निळा",
+ "fontColorPurple": "जांभळा",
+ "fontColorPink": "पिंग",
+ "fontColorRed": "लाल",
+ "backgroundColorDefault": "डीफॉल्ट पार्श्वभूमी",
+ "backgroundColorGray": "धूसर पार्श्वभूमी",
+ "backgroundColorBrown": "तपकिरी पार्श्वभूमी",
+ "backgroundColorOrange": "केशरी पार्श्वभूमी",
+ "backgroundColorYellow": "पिवळी पार्श्वभूमी",
+ "backgroundColorGreen": "हिरवी पार्श्वभूमी",
+ "backgroundColorBlue": "निळी पार्श्वभूमी",
+ "backgroundColorPurple": "जांभळी पार्श्वभूमी",
+ "backgroundColorPink": "पिंग पार्श्वभूमी",
+ "backgroundColorRed": "लाल पार्श्वभूमी",
+ "backgroundColorLime": "लिंबू पार्श्वभूमी",
+ "backgroundColorAqua": "पाण्याचा पार्श्वभूमी",
+ "done": "पूर्ण",
+ "cancel": "रद्द करा",
+ "tint1": "टिंट 1",
+ "tint2": "टिंट 2",
+ "tint3": "टिंट 3",
+ "tint4": "टिंट 4",
+ "tint5": "टिंट 5",
+ "tint6": "टिंट 6",
+ "tint7": "टिंट 7",
+ "tint8": "टिंट 8",
+ "tint9": "टिंट 9",
+ "lightLightTint1": "जांभळा",
+ "lightLightTint2": "पिंग",
+ "lightLightTint3": "फिकट पिंग",
+ "lightLightTint4": "केशरी",
+ "lightLightTint5": "पिवळा",
+ "lightLightTint6": "लिंबू",
+ "lightLightTint7": "हिरवा",
+ "lightLightTint8": "पाणी",
+ "lightLightTint9": "निळा",
+ "urlHint": "URL",
+ "mobileHeading1": "Heading 1",
+ "mobileHeading2": "Heading 2",
+ "mobileHeading3": "Heading 3",
+ "mobileHeading4": "Heading 4",
+ "mobileHeading5": "Heading 5",
+ "mobileHeading6": "Heading 6",
+ "textColor": "मजकूराचा रंग",
+ "backgroundColor": "पार्श्वभूमीचा रंग",
+ "addYourLink": "तुमची लिंक जोडा",
+ "openLink": "लिंक उघडा",
+ "copyLink": "लिंक कॉपी करा",
+ "removeLink": "लिंक काढा",
+ "editLink": "लिंक संपादित करा",
+ "linkText": "मजकूर",
+ "linkTextHint": "कृपया मजकूर प्रविष्ट करा",
+ "linkAddressHint": "कृपया URL प्रविष्ट करा",
+ "highlightColor": "हायलाइट रंग",
+ "clearHighlightColor": "हायलाइट काढा",
+ "customColor": "स्वतःचा रंग",
+ "hexValue": "Hex मूल्य",
+ "opacity": "अपारदर्शकता",
+ "resetToDefaultColor": "डीफॉल्ट रंगावर रीसेट करा",
+ "ltr": "LTR",
+ "rtl": "RTL",
+ "auto": "स्वयंचलित",
+ "cut": "कट",
+ "copy": "कॉपी",
+ "paste": "पेस्ट",
+ "find": "शोधा",
+ "select": "निवडा",
+ "selectAll": "सर्व निवडा",
+ "previousMatch": "मागील जुळणारे",
+ "nextMatch": "पुढील जुळणारे",
+ "closeFind": "बंद करा",
+ "replace": "बदला",
+ "replaceAll": "सर्व बदला",
+ "regex": "Regex",
+ "caseSensitive": "केस सेंसिटिव्ह",
+ "uploadImage": "प्रतिमा अपलोड करा",
+ "urlImage": "URL प्रतिमा",
+ "incorrectLink": "चुकीची लिंक",
+ "upload": "अपलोड",
+ "chooseImage": "प्रतिमा निवडा",
+ "loading": "लोड करत आहे",
+ "imageLoadFailed": "प्रतिमा लोड करण्यात अयशस्वी",
+ "divider": "विभाजक",
+ "table": "तक्त्याचे स्वरूप",
+ "colAddBefore": "यापूर्वी स्तंभ जोडा",
+ "rowAddBefore": "यापूर्वी पंक्ती जोडा",
+ "colAddAfter": "यानंतर स्तंभ जोडा",
+ "rowAddAfter": "यानंतर पंक्ती जोडा",
+ "colRemove": "स्तंभ काढा",
+ "rowRemove": "पंक्ती काढा",
+ "colDuplicate": "स्तंभ डुप्लिकेट",
+ "rowDuplicate": "पंक्ती डुप्लिकेट",
+ "colClear": "सामग्री साफ करा",
+ "rowClear": "सामग्री साफ करा",
+ "slashPlaceHolder": "'/' टाइप करा आणि घटक जोडा किंवा टाइप सुरू करा",
+ "typeSomething": "काहीतरी लिहा...",
+ "toggleListShortForm": "टॉगल",
+ "quoteListShortForm": "कोट",
+ "mathEquationShortForm": "सूत्र",
+ "codeBlockShortForm": "कोड"
+},
+ "favorite": {
+ "noFavorite": "कोणतेही आवडते पृष्ठ नाही",
+ "noFavoriteHintText": "पृष्ठाला डावीकडे स्वाइप करा आणि ते आवडत्या यादीत जोडा",
+ "removeFromSidebar": "साइडबारमधून काढा",
+ "addToSidebar": "साइडबारमध्ये पिन करा"
+},
+"cardDetails": {
+ "notesPlaceholder": "/ टाइप करा ब्लॉक घालण्यासाठी, किंवा टाइप करायला सुरुवात करा"
+},
+"blockPlaceholders": {
+ "todoList": "करण्याची यादी",
+ "bulletList": "यादी",
+ "numberList": "क्रमांकित यादी",
+ "quote": "कोट",
+ "heading": "मथळा {}"
+},
+"titleBar": {
+ "pageIcon": "पृष्ठ चिन्ह",
+ "language": "भाषा",
+ "font": "फॉन्ट",
+ "actions": "क्रिया",
+ "date": "तारीख",
+ "addField": "फील्ड जोडा",
+ "userIcon": "वापरकर्त्याचे चिन्ह"
+},
+"noLogFiles": "कोणतीही लॉग फाइल्स नाहीत",
+"newSettings": {
+ "myAccount": {
+ "title": "माझे खाते",
+ "subtitle": "तुमचा प्रोफाइल सानुकूल करा, खाते सुरक्षा व्यवस्थापित करा, AI कीज पहा किंवा लॉगिन करा.",
+ "profileLabel": "खाते नाव आणि प्रोफाइल चित्र",
+ "profileNamePlaceholder": "तुमचे नाव प्रविष्ट करा",
+ "accountSecurity": "खाते सुरक्षा",
+ "2FA": "2-स्टेप प्रमाणीकरण",
+ "aiKeys": "AI कीज",
+ "accountLogin": "खाते लॉगिन",
+ "updateNameError": "नाव अपडेट करण्यात अयशस्वी",
+ "updateIconError": "चिन्ह अपडेट करण्यात अयशस्वी",
+ "aboutAppFlowy": "@:appName विषयी",
+ "deleteAccount": {
+ "title": "खाते हटवा",
+ "subtitle": "तुमचे खाते आणि सर्व डेटा कायमचे हटवा.",
+ "description": "तुमचे खाते कायमचे हटवले जाईल आणि सर्व वर्कस्पेसमधून प्रवेश काढून टाकला जाईल.",
+ "deleteMyAccount": "माझे खाते हटवा",
+ "dialogTitle": "खाते हटवा",
+ "dialogContent1": "तुम्हाला खात्री आहे की तुम्ही तुमचे खाते कायमचे हटवू इच्छिता?",
+ "dialogContent2": "ही क्रिया पूर्ववत केली जाऊ शकत नाही. हे सर्व वर्कस्पेसमधून प्रवेश हटवेल, खाजगी वर्कस्पेस मिटवेल, आणि सर्व शेअर्ड वर्कस्पेसमधून काढून टाकेल.",
+ "confirmHint1": "कृपया पुष्टीसाठी \"@:newSettings.myAccount.deleteAccount.confirmHint3\" टाइप करा.",
+ "confirmHint2": "मला समजले आहे की ही क्रिया अपरिवर्तनीय आहे आणि माझे खाते व सर्व संबंधित डेटा कायमचा हटवला जाईल.",
+ "confirmHint3": "DELETE MY ACCOUNT",
+ "checkToConfirmError": "हटवण्यासाठी पुष्टी बॉक्स निवडणे आवश्यक आहे",
+ "failedToGetCurrentUser": "वर्तमान वापरकर्त्याचा ईमेल मिळवण्यात अयशस्वी",
+ "confirmTextValidationFailed": "तुमचा पुष्टी मजकूर \"@:newSettings.myAccount.deleteAccount.confirmHint3\" शी जुळत नाही",
+ "deleteAccountSuccess": "खाते यशस्वीरित्या हटवले गेले"
+ }
+ },
+ "workplace": {
+ "name": "वर्कस्पेस",
+ "title": "वर्कस्पेस सेटिंग्स",
+ "subtitle": "तुमचा वर्कस्पेस लुक, थीम, फॉन्ट, मजकूर लेआउट, तारीख, वेळ आणि भाषा सानुकूल करा.",
+ "workplaceName": "वर्कस्पेसचे नाव",
+ "workplaceNamePlaceholder": "वर्कस्पेसचे नाव टाका",
+ "workplaceIcon": "वर्कस्पेस चिन्ह",
+ "workplaceIconSubtitle": "एक प्रतिमा अपलोड करा किंवा इमोजी वापरा. हे साइडबार आणि सूचना मध्ये दर्शवले जाईल.",
+ "renameError": "वर्कस्पेसचे नाव बदलण्यात अयशस्वी",
+ "updateIconError": "चिन्ह अपडेट करण्यात अयशस्वी",
+ "chooseAnIcon": "चिन्ह निवडा",
+ "appearance": {
+ "name": "दृश्यरूप",
+ "themeMode": {
+ "auto": "स्वयंचलित",
+ "light": "प्रकाश मोड",
+ "dark": "गडद मोड"
+ },
+ "language": "भाषा"
+ }
+ },
+ "syncState": {
+ "syncing": "सिंक्रोनायझ करत आहे",
+ "synced": "सिंक्रोनायझ झाले",
+ "noNetworkConnected": "नेटवर्क कनेक्ट केलेले नाही"
+ }
+},
+ "pageStyle": {
+ "title": "पृष्ठ शैली",
+ "layout": "लेआउट",
+ "coverImage": "मुखपृष्ठ प्रतिमा",
+ "pageIcon": "पृष्ठ चिन्ह",
+ "colors": "रंग",
+ "gradient": "ग्रेडियंट",
+ "backgroundImage": "पार्श्वभूमी प्रतिमा",
+ "presets": "पूर्वनियोजित",
+ "photo": "फोटो",
+ "unsplash": "Unsplash",
+ "pageCover": "पृष्ठ कव्हर",
+ "none": "काही नाही",
+ "openSettings": "सेटिंग्स उघडा",
+ "photoPermissionTitle": "@:appName तुमच्या फोटो लायब्ररीमध्ये प्रवेश करू इच्छित आहे",
+ "photoPermissionDescription": "तुमच्या कागदपत्रांमध्ये प्रतिमा जोडण्यासाठी @:appName ला तुमच्या फोटोंमध्ये प्रवेश आवश्यक आहे",
+ "cameraPermissionTitle": "@:appName तुमच्या कॅमेऱ्याला प्रवेश करू इच्छित आहे",
+ "cameraPermissionDescription": "कॅमेऱ्यातून प्रतिमा जोडण्यासाठी @:appName ला तुमच्या कॅमेऱ्याचा प्रवेश आवश्यक आहे",
+ "doNotAllow": "परवानगी देऊ नका",
+ "image": "प्रतिमा"
+},
+"commandPalette": {
+ "placeholder": "शोधा किंवा प्रश्न विचारा...",
+ "bestMatches": "सर्वोत्तम जुळवणी",
+ "recentHistory": "अलीकडील इतिहास",
+ "navigateHint": "नेव्हिगेट करण्यासाठी",
+ "loadingTooltip": "आम्ही निकाल शोधत आहोत...",
+ "betaLabel": "बेटा",
+ "betaTooltip": "सध्या आम्ही फक्त दस्तऐवज आणि पृष्ठ शोध समर्थन करतो",
+ "fromTrashHint": "कचरापेटीतून",
+ "noResultsHint": "आपण जे शोधत आहात ते सापडले नाही, कृपया दुसरा शब्द वापरून शोधा.",
+ "clearSearchTooltip": "शोध फील्ड साफ करा"
+},
+"space": {
+ "delete": "हटवा",
+ "deleteConfirmation": "हटवा: ",
+ "deleteConfirmationDescription": "या स्पेसमधील सर्व पृष्ठे हटवली जातील आणि कचरापेटीत टाकली जातील, आणि प्रकाशित पृष्ठे अनपब्लिश केली जातील.",
+ "rename": "स्पेसचे नाव बदला",
+ "changeIcon": "चिन्ह बदला",
+ "manage": "स्पेस व्यवस्थापित करा",
+ "addNewSpace": "स्पेस तयार करा",
+ "collapseAllSubPages": "सर्व उपपृष्ठे संकुचित करा",
+ "createNewSpace": "नवीन स्पेस तयार करा",
+ "createSpaceDescription": "तुमचे कार्य अधिक चांगल्या प्रकारे आयोजित करण्यासाठी अनेक सार्वजनिक व खाजगी स्पेस तयार करा.",
+ "spaceName": "स्पेसचे नाव",
+ "spaceNamePlaceholder": "उदा. मार्केटिंग, अभियांत्रिकी, HR",
+ "permission": "स्पेस परवानगी",
+ "publicPermission": "सार्वजनिक",
+ "publicPermissionDescription": "पूर्ण प्रवेशासह सर्व वर्कस्पेस सदस्य",
+ "privatePermission": "खाजगी",
+ "privatePermissionDescription": "फक्त तुम्हाला या स्पेसमध्ये प्रवेश आहे",
+ "spaceIconBackground": "पार्श्वभूमीचा रंग",
+ "spaceIcon": "चिन्ह",
+ "dangerZone": "धोकादायक क्षेत्र",
+ "unableToDeleteLastSpace": "शेवटची स्पेस हटवता येणार नाही",
+ "unableToDeleteSpaceNotCreatedByYou": "तुमच्याद्वारे तयार न केलेली स्पेस हटवता येणार नाही",
+ "enableSpacesForYourWorkspace": "तुमच्या वर्कस्पेससाठी स्पेस सक्षम करा",
+ "title": "स्पेसेस",
+ "defaultSpaceName": "सामान्य",
+ "upgradeSpaceTitle": "स्पेस सक्षम करा",
+ "upgradeSpaceDescription": "तुमच्या वर्कस्पेसचे अधिक चांगल्या प्रकारे व्यवस्थापन करण्यासाठी अनेक सार्वजनिक आणि खाजगी स्पेस तयार करा.",
+ "upgrade": "अपग्रेड",
+ "upgradeYourSpace": "अनेक स्पेस तयार करा",
+ "quicklySwitch": "पुढील स्पेसवर पटकन स्विच करा",
+ "duplicate": "स्पेस डुप्लिकेट करा",
+ "movePageToSpace": "पृष्ठ स्पेसमध्ये हलवा",
+ "cannotMovePageToDatabase": "पृष्ठ डेटाबेसमध्ये हलवता येणार नाही",
+ "switchSpace": "स्पेस स्विच करा",
+ "spaceNameCannotBeEmpty": "स्पेसचे नाव रिकामे असू शकत नाही",
+ "success": {
+ "deleteSpace": "स्पेस यशस्वीरित्या हटवली",
+ "renameSpace": "स्पेसचे नाव यशस्वीरित्या बदलले",
+ "duplicateSpace": "स्पेस यशस्वीरित्या डुप्लिकेट केली",
+ "updateSpace": "स्पेस यशस्वीरित्या अपडेट केली"
+ },
+ "error": {
+ "deleteSpace": "स्पेस हटवण्यात अयशस्वी",
+ "renameSpace": "स्पेसचे नाव बदलण्यात अयशस्वी",
+ "duplicateSpace": "स्पेस डुप्लिकेट करण्यात अयशस्वी",
+ "updateSpace": "स्पेस अपडेट करण्यात अयशस्वी"
+ },
+ "createSpace": "स्पेस तयार करा",
+ "manageSpace": "स्पेस व्यवस्थापित करा",
+ "renameSpace": "स्पेसचे नाव बदला",
+ "mSpaceIconColor": "स्पेस चिन्हाचा रंग",
+ "mSpaceIcon": "स्पेस चिन्ह"
+},
+ "publish": {
+ "hasNotBeenPublished": "हे पृष्ठ अजून प्रकाशित केलेले नाही",
+ "spaceHasNotBeenPublished": "स्पेस प्रकाशित करण्यासाठी समर्थन नाही",
+ "reportPage": "पृष्ठाची तक्रार करा",
+ "databaseHasNotBeenPublished": "डेटाबेस प्रकाशित करण्यास समर्थन नाही.",
+ "createdWith": "यांनी तयार केले",
+ "downloadApp": "AppFlowy डाउनलोड करा",
+ "copy": {
+ "codeBlock": "कोड ब्लॉकची सामग्री क्लिपबोर्डवर कॉपी केली गेली आहे",
+ "imageBlock": "प्रतिमा लिंक क्लिपबोर्डवर कॉपी केली गेली आहे",
+ "mathBlock": "गणितीय समीकरण क्लिपबोर्डवर कॉपी केले गेले आहे",
+ "fileBlock": "फाइल लिंक क्लिपबोर्डवर कॉपी केली गेली आहे"
+ },
+ "containsPublishedPage": "या पृष्ठात एक किंवा अधिक प्रकाशित पृष्ठे आहेत. तुम्ही पुढे गेल्यास ती अनपब्लिश होतील. तुम्हाला हटवणे चालू ठेवायचे आहे का?",
+ "publishSuccessfully": "यशस्वीरित्या प्रकाशित झाले",
+ "unpublishSuccessfully": "यशस्वीरित्या अनपब्लिश झाले",
+ "publishFailed": "प्रकाशित करण्यात अयशस्वी",
+ "unpublishFailed": "अनपब्लिश करण्यात अयशस्वी",
+ "noAccessToVisit": "या पृष्ठावर प्रवेश नाही...",
+ "createWithAppFlowy": "AppFlowy ने वेबसाइट तयार करा",
+ "fastWithAI": "AI सह जलद आणि सोपे.",
+ "tryItNow": "आत्ताच वापरून पहा",
+ "onlyGridViewCanBePublished": "फक्त Grid view प्रकाशित केला जाऊ शकतो",
+ "database": {
+ "zero": "{} निवडलेले दृश्य प्रकाशित करा",
+ "one": "{} निवडलेली दृश्ये प्रकाशित करा",
+ "many": "{} निवडलेली दृश्ये प्रकाशित करा",
+ "other": "{} निवडलेली दृश्ये प्रकाशित करा"
+ },
+ "mustSelectPrimaryDatabase": "प्राथमिक दृश्य निवडणे आवश्यक आहे",
+ "noDatabaseSelected": "कोणताही डेटाबेस निवडलेला नाही, कृपया किमान एक डेटाबेस निवडा.",
+ "unableToDeselectPrimaryDatabase": "प्राथमिक डेटाबेस अननिवड करता येणार नाही",
+ "saveThisPage": "या टेम्पलेटपासून सुरू करा",
+ "duplicateTitle": "तुम्हाला हे कुठे जोडायचे आहे",
+ "selectWorkspace": "वर्कस्पेस निवडा",
+ "addTo": "मध्ये जोडा",
+ "duplicateSuccessfully": "तुमच्या वर्कस्पेसमध्ये जोडले गेले",
+ "duplicateSuccessfullyDescription": "AppFlowy स्थापित केले नाही? तुम्ही 'डाउनलोड' वर क्लिक केल्यावर डाउनलोड आपोआप सुरू होईल.",
+ "downloadIt": "डाउनलोड करा",
+ "openApp": "अॅपमध्ये उघडा",
+ "duplicateFailed": "डुप्लिकेट करण्यात अयशस्वी",
+ "membersCount": {
+ "zero": "सदस्य नाहीत",
+ "one": "1 सदस्य",
+ "many": "{count} सदस्य",
+ "other": "{count} सदस्य"
+ },
+ "useThisTemplate": "हा टेम्पलेट वापरा"
+},
+"web": {
+ "continue": "पुढे जा",
+ "or": "किंवा",
+ "continueWithGoogle": "Google सह पुढे जा",
+ "continueWithGithub": "GitHub सह पुढे जा",
+ "continueWithDiscord": "Discord सह पुढे जा",
+ "continueWithApple": "Apple सह पुढे जा",
+ "moreOptions": "अधिक पर्याय",
+ "collapse": "आकुंचन",
+ "signInAgreement": "\"पुढे जा\" क्लिक करून, तुम्ही AppFlowy च्या अटींना सहमती दिली आहे",
+ "signInLocalAgreement": "\"सुरुवात करा\" क्लिक करून, तुम्ही AppFlowy च्या अटींना सहमती दिली आहे",
+ "and": "आणि",
+ "termOfUse": "वापर अटी",
+ "privacyPolicy": "गोपनीयता धोरण",
+ "signInError": "साइन इन त्रुटी",
+ "login": "साइन अप किंवा लॉग इन करा",
+ "fileBlock": {
+ "uploadedAt": "{time} रोजी अपलोड केले",
+ "linkedAt": "{time} रोजी लिंक जोडली",
+ "empty": "फाईल अपलोड करा किंवा एम्बेड करा",
+ "uploadFailed": "अपलोड अयशस्वी, कृपया पुन्हा प्रयत्न करा",
+ "retry": "पुन्हा प्रयत्न करा"
+ },
+ "importNotion": "Notion वरून आयात करा",
+ "import": "आयात करा",
+ "importSuccess": "यशस्वीरित्या अपलोड केले",
+ "importSuccessMessage": "आम्ही तुम्हाला आयात पूर्ण झाल्यावर सूचित करू. त्यानंतर, तुम्ही साइडबारमध्ये तुमची आयात केलेली पृष्ठे पाहू शकता.",
+ "importFailed": "आयात अयशस्वी, कृपया फाईल फॉरमॅट तपासा",
+ "dropNotionFile": "तुमची Notion zip फाईल येथे ड्रॉप करा किंवा ब्राउझ करा",
+ "error": {
+ "pageNameIsEmpty": "पृष्ठाचे नाव रिकामे आहे, कृपया दुसरे नाव वापरून पहा"
+ }
+},
+ "globalComment": {
+ "comments": "टिप्पण्या",
+ "addComment": "टिप्पणी जोडा",
+ "reactedBy": "यांनी प्रतिक्रिया दिली",
+ "addReaction": "प्रतिक्रिया जोडा",
+ "reactedByMore": "आणि {count} इतर",
+ "showSeconds": {
+ "one": "1 सेकंदापूर्वी",
+ "other": "{count} सेकंदांपूर्वी",
+ "zero": "आत्ताच",
+ "many": "{count} सेकंदांपूर्वी"
+ },
+ "showMinutes": {
+ "one": "1 मिनिटापूर्वी",
+ "other": "{count} मिनिटांपूर्वी",
+ "many": "{count} मिनिटांपूर्वी"
+ },
+ "showHours": {
+ "one": "1 तासापूर्वी",
+ "other": "{count} तासांपूर्वी",
+ "many": "{count} तासांपूर्वी"
+ },
+ "showDays": {
+ "one": "1 दिवसापूर्वी",
+ "other": "{count} दिवसांपूर्वी",
+ "many": "{count} दिवसांपूर्वी"
+ },
+ "showMonths": {
+ "one": "1 महिन्यापूर्वी",
+ "other": "{count} महिन्यांपूर्वी",
+ "many": "{count} महिन्यांपूर्वी"
+ },
+ "showYears": {
+ "one": "1 वर्षापूर्वी",
+ "other": "{count} वर्षांपूर्वी",
+ "many": "{count} वर्षांपूर्वी"
+ },
+ "reply": "उत्तर द्या",
+ "deleteComment": "टिप्पणी हटवा",
+ "youAreNotOwner": "तुम्ही या टिप्पणीचे मालक नाही",
+ "confirmDeleteDescription": "तुम्हाला ही टिप्पणी हटवायची आहे याची खात्री आहे का?",
+ "hasBeenDeleted": "हटवले गेले",
+ "replyingTo": "याला उत्तर देत आहे",
+ "noAccessDeleteComment": "तुम्हाला ही टिप्पणी हटवण्याची परवानगी नाही",
+ "collapse": "संकुचित करा",
+ "readMore": "अधिक वाचा",
+ "failedToAddComment": "टिप्पणी जोडण्यात अयशस्वी",
+ "commentAddedSuccessfully": "टिप्पणी यशस्वीरित्या जोडली गेली.",
+ "commentAddedSuccessTip": "तुम्ही नुकतीच एक टिप्पणी जोडली किंवा उत्तर दिले आहे. वर जाऊन ताजी टिप्पण्या पाहायच्या का?"
+},
+ "template": {
+ "asTemplate": "टेम्पलेट म्हणून जतन करा",
+ "name": "टेम्पलेट नाव",
+ "description": "टेम्पलेट वर्णन",
+ "about": "टेम्पलेट माहिती",
+ "deleteFromTemplate": "टेम्पलेटमधून हटवा",
+ "preview": "टेम्पलेट पूर्वदृश्य",
+ "categories": "टेम्पलेट श्रेणी",
+ "isNewTemplate": "नवीन टेम्पलेटमध्ये पिन करा",
+ "featured": "वैशिष्ट्यीकृतमध्ये पिन करा",
+ "relatedTemplates": "संबंधित टेम्पलेट्स",
+ "requiredField": "{field} आवश्यक आहे",
+ "addCategory": "\"{category}\" जोडा",
+ "addNewCategory": "नवीन श्रेणी जोडा",
+ "addNewCreator": "नवीन निर्माता जोडा",
+ "deleteCategory": "श्रेणी हटवा",
+ "editCategory": "श्रेणी संपादित करा",
+ "editCreator": "निर्माता संपादित करा",
+ "category": {
+ "name": "श्रेणीचे नाव",
+ "icon": "श्रेणी चिन्ह",
+ "bgColor": "श्रेणी पार्श्वभूमीचा रंग",
+ "priority": "श्रेणी प्राधान्य",
+ "desc": "श्रेणीचे वर्णन",
+ "type": "श्रेणी प्रकार",
+ "icons": "श्रेणी चिन्हे",
+ "colors": "श्रेणी रंग",
+ "byUseCase": "वापराच्या आधारे",
+ "byFeature": "वैशिष्ट्यांनुसार",
+ "deleteCategory": "श्रेणी हटवा",
+ "deleteCategoryDescription": "तुम्हाला ही श्रेणी हटवायची आहे का?",
+ "typeToSearch": "श्रेणी शोधण्यासाठी टाइप करा..."
+ },
+ "creator": {
+ "label": "टेम्पलेट निर्माता",
+ "name": "निर्मात्याचे नाव",
+ "avatar": "निर्मात्याचा अवतार",
+ "accountLinks": "निर्मात्याचे खाते दुवे",
+ "uploadAvatar": "अवतार अपलोड करण्यासाठी क्लिक करा",
+ "deleteCreator": "निर्माता हटवा",
+ "deleteCreatorDescription": "तुम्हाला हा निर्माता हटवायचा आहे का?",
+ "typeToSearch": "निर्माते शोधण्यासाठी टाइप करा..."
+ },
+ "uploadSuccess": "टेम्पलेट यशस्वीरित्या अपलोड झाले",
+ "uploadSuccessDescription": "तुमचे टेम्पलेट यशस्वीरित्या अपलोड झाले आहे. आता तुम्ही ते टेम्पलेट गॅलरीमध्ये पाहू शकता.",
+ "viewTemplate": "टेम्पलेट पहा",
+ "deleteTemplate": "टेम्पलेट हटवा",
+ "deleteSuccess": "टेम्पलेट यशस्वीरित्या हटवले गेले",
+ "deleteTemplateDescription": "याचा वर्तमान पृष्ठ किंवा प्रकाशित स्थितीवर परिणाम होणार नाही. तुम्हाला हे टेम्पलेट हटवायचे आहे का?",
+ "addRelatedTemplate": "संबंधित टेम्पलेट जोडा",
+ "removeRelatedTemplate": "संबंधित टेम्पलेट हटवा",
+ "uploadAvatar": "अवतार अपलोड करा",
+ "searchInCategory": "{category} मध्ये शोधा",
+ "label": "टेम्पलेट्स"
+},
+ "fileDropzone": {
+ "dropFile": "फाइल अपलोड करण्यासाठी येथे क्लिक करा किंवा ड्रॅग करा",
+ "uploading": "अपलोड करत आहे...",
+ "uploadFailed": "अपलोड अयशस्वी",
+ "uploadSuccess": "अपलोड यशस्वी",
+ "uploadSuccessDescription": "फाइल यशस्वीरित्या अपलोड झाली आहे",
+ "uploadFailedDescription": "फाइल अपलोड अयशस्वी झाली आहे",
+ "uploadingDescription": "फाइल अपलोड होत आहे"
+},
+ "gallery": {
+ "preview": "पूर्ण स्क्रीनमध्ये उघडा",
+ "copy": "कॉपी करा",
+ "download": "डाउनलोड",
+ "prev": "मागील",
+ "next": "पुढील",
+ "resetZoom": "झूम रिसेट करा",
+ "zoomIn": "झूम इन",
+ "zoomOut": "झूम आउट"
+},
+ "invitation": {
+ "join": "सामील व्हा",
+ "on": "वर",
+ "invitedBy": "यांनी आमंत्रित केले",
+ "membersCount": {
+ "zero": "{count} सदस्य",
+ "one": "{count} सदस्य",
+ "many": "{count} सदस्य",
+ "other": "{count} सदस्य"
+ },
+ "tip": "तुम्हाला खालील माहितीच्या आधारे या कार्यक्षेत्रात सामील होण्यासाठी आमंत्रित करण्यात आले आहे. ही माहिती चुकीची असल्यास, कृपया प्रशासकाशी संपर्क साधा.",
+ "joinWorkspace": "वर्कस्पेसमध्ये सामील व्हा",
+ "success": "तुम्ही यशस्वीरित्या वर्कस्पेसमध्ये सामील झाला आहात",
+ "successMessage": "आता तुम्ही सर्व पृष्ठे आणि कार्यक्षेत्रे वापरू शकता.",
+ "openWorkspace": "AppFlowy उघडा",
+ "alreadyAccepted": "तुम्ही आधीच आमंत्रण स्वीकारले आहे",
+ "errorModal": {
+ "title": "काहीतरी चुकले आहे",
+ "description": "तुमचे सध्याचे खाते {email} कदाचित या वर्कस्पेसमध्ये प्रवेशासाठी पात्र नाही. कृपया योग्य खात्याने लॉग इन करा किंवा वर्कस्पेस मालकाशी संपर्क साधा.",
+ "contactOwner": "मालकाशी संपर्क करा",
+ "close": "मुख्यपृष्ठावर परत जा",
+ "changeAccount": "खाते बदला"
+ }
+},
+ "requestAccess": {
+ "title": "या पृष्ठासाठी प्रवेश नाही",
+ "subtitle": "तुम्ही या पृष्ठाच्या मालकाकडून प्रवेशासाठी विनंती करू शकता. मंजुरीनंतर तुम्हाला हे पृष्ठ पाहता येईल.",
+ "requestAccess": "प्रवेशाची विनंती करा",
+ "backToHome": "मुख्यपृष्ठावर परत जा",
+ "tip": "तुम्ही सध्या म्हणून लॉग इन आहात.",
+ "mightBe": "कदाचित तुम्हाला दुसऱ्या खात्याने लॉग इन करणे आवश्यक आहे.",
+ "successful": "विनंती यशस्वीपणे पाठवली गेली",
+ "successfulMessage": "मालकाने मंजुरी दिल्यावर तुम्हाला सूचित केले जाईल.",
+ "requestError": "प्रवेशाची विनंती अयशस्वी",
+ "repeatRequestError": "तुम्ही यासाठी आधीच विनंती केली आहे"
+},
+ "approveAccess": {
+ "title": "वर्कस्पेसमध्ये सामील होण्यासाठी विनंती मंजूर करा",
+ "requestSummary": " यांनी मध्ये सामील होण्यासाठी आणि पाहण्यासाठी विनंती केली आहे",
+ "upgrade": "अपग्रेड",
+ "downloadApp": "AppFlowy डाउनलोड करा",
+ "approveButton": "मंजूर करा",
+ "approveSuccess": "मंजूर यशस्वी",
+ "approveError": "मंजुरी अयशस्वी. कृपया वर्कस्पेस मर्यादा ओलांडलेली नाही याची खात्री करा",
+ "getRequestInfoError": "विनंतीची माहिती मिळवण्यात अयशस्वी",
+ "memberCount": {
+ "zero": "कोणतेही सदस्य नाहीत",
+ "one": "1 सदस्य",
+ "many": "{count} सदस्य",
+ "other": "{count} सदस्य"
+ },
+ "alreadyProTitle": "वर्कस्पेस योजना मर्यादा गाठली आहे",
+ "alreadyProMessage": "अधिक सदस्यांसाठी शी संपर्क साधा",
+ "repeatApproveError": "तुम्ही ही विनंती आधीच मंजूर केली आहे",
+ "ensurePlanLimit": "कृपया खात्री करा की योजना मर्यादा ओलांडलेली नाही. जर ओलांडली असेल तर वर्कस्पेस योजना करा किंवा करा.",
+ "requestToJoin": "मध्ये सामील होण्यासाठी विनंती केली",
+ "asMember": "सदस्य म्हणून"
+},
+ "upgradePlanModal": {
+ "title": "Pro प्लॅनवर अपग्रेड करा",
+ "message": "{name} ने फ्री सदस्य मर्यादा गाठली आहे. अधिक सदस्य आमंत्रित करण्यासाठी Pro प्लॅनवर अपग्रेड करा.",
+ "upgradeSteps": "AppFlowy वर तुमची योजना कशी अपग्रेड करावी:",
+ "step1": "1. सेटिंग्जमध्ये जा",
+ "step2": "2. 'योजना' वर क्लिक करा",
+ "step3": "3. 'योजना बदला' निवडा",
+ "appNote": "नोंद:",
+ "actionButton": "अपग्रेड करा",
+ "downloadLink": "अॅप डाउनलोड करा",
+ "laterButton": "नंतर",
+ "refreshNote": "यशस्वी अपग्रेडनंतर, तुमची नवीन वैशिष्ट्ये सक्रिय करण्यासाठी वर क्लिक करा.",
+ "refresh": "येथे"
+},
+ "breadcrumbs": {
+ "label": "ब्रेडक्रम्स"
+},
+ "time": {
+ "justNow": "आत्ताच",
+ "seconds": {
+ "one": "1 सेकंद",
+ "other": "{count} सेकंद"
+ },
+ "minutes": {
+ "one": "1 मिनिट",
+ "other": "{count} मिनिटे"
+ },
+ "hours": {
+ "one": "1 तास",
+ "other": "{count} तास"
+ },
+ "days": {
+ "one": "1 दिवस",
+ "other": "{count} दिवस"
+ },
+ "weeks": {
+ "one": "1 आठवडा",
+ "other": "{count} आठवडे"
+ },
+ "months": {
+ "one": "1 महिना",
+ "other": "{count} महिने"
+ },
+ "years": {
+ "one": "1 वर्ष",
+ "other": "{count} वर्षे"
+ },
+ "ago": "पूर्वी",
+ "yesterday": "काल",
+ "today": "आज"
+},
+ "members": {
+ "zero": "सदस्य नाहीत",
+ "one": "1 सदस्य",
+ "many": "{count} सदस्य",
+ "other": "{count} सदस्य"
+},
+ "tabMenu": {
+ "close": "बंद करा",
+ "closeDisabledHint": "पिन केलेले टॅब बंद करता येत नाही, कृपया आधी अनपिन करा",
+ "closeOthers": "इतर टॅब बंद करा",
+ "closeOthersHint": "हे सर्व अनपिन केलेले टॅब्स बंद करेल या टॅब वगळता",
+ "closeOthersDisabledHint": "सर्व टॅब पिन केलेले आहेत, बंद करण्यासाठी कोणतेही टॅब सापडले नाहीत",
+ "favorite": "आवडते",
+ "unfavorite": "आवडते काढा",
+ "favoriteDisabledHint": "हे दृश्य आवडते म्हणून जतन करता येत नाही",
+ "pinTab": "पिन करा",
+ "unpinTab": "अनपिन करा"
+},
+ "openFileMessage": {
+ "success": "फाइल यशस्वीरित्या उघडली",
+ "fileNotFound": "फाइल सापडली नाही",
+ "noAppToOpenFile": "ही फाइल उघडण्यासाठी कोणतेही अॅप उपलब्ध नाही",
+ "permissionDenied": "ही फाइल उघडण्यासाठी परवानगी नाही",
+ "unknownError": "फाइल उघडण्यात अयशस्वी"
+},
+ "inviteMember": {
+ "requestInviteMembers": "तुमच्या वर्कस्पेसमध्ये आमंत्रित करा",
+ "inviteFailedMemberLimit": "सदस्य मर्यादा गाठली आहे, कृपया ",
+ "upgrade": "अपग्रेड करा",
+ "addEmail": "email@example.com, email2@example.com...",
+ "requestInvites": "आमंत्रण पाठवा",
+ "inviteAlready": "तुम्ही या ईमेलला आधीच आमंत्रित केले आहे: {email}",
+ "inviteSuccess": "आमंत्रण यशस्वीपणे पाठवले",
+ "description": "खाली ईमेल कॉमा वापरून टाका. शुल्क सदस्य संख्येवर आधारित असते.",
+ "emails": "ईमेल"
+},
+ "quickNote": {
+ "label": "झटपट नोंद",
+ "quickNotes": "झटपट नोंदी",
+ "search": "झटपट नोंदी शोधा",
+ "collapseFullView": "पूर्ण दृश्य लपवा",
+ "expandFullView": "पूर्ण दृश्य उघडा",
+ "createFailed": "झटपट नोंद तयार करण्यात अयशस्वी",
+ "quickNotesEmpty": "कोणत्याही झटपट नोंदी नाहीत",
+ "emptyNote": "रिकामी नोंद",
+ "deleteNotePrompt": "निवडलेली नोंद कायमची हटवली जाईल. तुम्हाला नक्की ती हटवायची आहे का?",
+ "addNote": "नवीन नोंद",
+ "noAdditionalText": "अधिक माहिती नाही"
+},
+ "subscribe": {
+ "upgradePlanTitle": "योजना तुलना करा आणि निवडा",
+ "yearly": "वार्षिक",
+ "save": "{discount}% बचत",
+ "monthly": "मासिक",
+ "priceIn": "किंमत येथे: ",
+ "free": "फ्री",
+ "pro": "प्रो",
+ "freeDescription": "2 सदस्यांपर्यंत वैयक्तिक वापरकर्त्यांसाठी सर्व काही आयोजित करण्यासाठी",
+ "proDescription": "प्रकल्प आणि टीम नॉलेज व्यवस्थापित करण्यासाठी लहान टीमसाठी",
+ "proDuration": {
+ "monthly": "प्रति सदस्य प्रति महिना\nमासिक बिलिंग",
+ "yearly": "प्रति सदस्य प्रति महिना\nवार्षिक बिलिंग"
+ },
+ "cancel": "खालच्या योजनेवर जा",
+ "changePlan": "प्रो योजनेवर अपग्रेड करा",
+ "everythingInFree": "फ्री योजनेतील सर्व काही +",
+ "currentPlan": "सध्याची योजना",
+ "freeDuration": "कायम",
+ "freePoints": {
+ "first": "1 सहकार्यात्मक वर्कस्पेस (2 सदस्यांपर्यंत)",
+ "second": "अमर्यादित पृष्ठे आणि ब्लॉक्स",
+ "three": "5 GB संचयन",
+ "four": "बुद्धिमान शोध",
+ "five": "20 AI प्रतिसाद",
+ "six": "मोबाईल अॅप",
+ "seven": "रिअल-टाइम सहकार्य"
+ },
+ "proPoints": {
+ "first": "अमर्यादित संचयन",
+ "second": "10 वर्कस्पेस सदस्यांपर्यंत",
+ "three": "अमर्यादित AI प्रतिसाद",
+ "four": "अमर्यादित फाइल अपलोड्स",
+ "five": "कस्टम नेमस्पेस"
+ },
+ "cancelPlan": {
+ "title": "आपल्याला जाताना पाहून वाईट वाटते",
+ "success": "आपली सदस्यता यशस्वीरित्या रद्द झाली आहे",
+ "description": "आपल्याला जाताना वाईट वाटते. कृपया आम्हाला सुधारण्यासाठी तुमचे अभिप्राय कळवा. खालील काही प्रश्नांना उत्तर द्या.",
+ "commonOther": "इतर",
+ "otherHint": "आपले उत्तर येथे लिहा",
+ "questionOne": {
+ "question": "तुम्ही AppFlowy Pro सदस्यता का रद्द केली?",
+ "answerOne": "खर्च खूप जास्त आहे",
+ "answerTwo": "वैशिष्ट्ये अपेक्षेनुसार नव्हती",
+ "answerThree": "चांगला पर्याय सापडला",
+ "answerFour": "खर्च योग्य ठरण्यासाठी वापर पुरेसा नव्हता",
+ "answerFive": "सेवा समस्या किंवा तांत्रिक अडचणी"
+ },
+ "questionTwo": {
+ "question": "तुम्ही भविष्यात AppFlowy Pro पुन्हा घेण्याची शक्यता किती आहे?",
+ "answerOne": "खूप शक्यता आहे",
+ "answerTwo": "काहीशी शक्यता आहे",
+ "answerThree": "निश्चित नाही",
+ "answerFour": "अल्प शक्यता आहे",
+ "answerFive": "शक्यता नाही"
+ },
+ "questionThree": {
+ "question": "सदस्यतेदरम्यान कोणते Pro फिचर तुम्हाला सर्वात जास्त उपयोगी वाटले?",
+ "answerOne": "मल्टी-यूजर सहकार्य",
+ "answerTwo": "लांब कालावधीसाठी आवृत्ती इतिहास",
+ "answerThree": "अमर्यादित AI प्रतिसाद",
+ "answerFour": "स्थानिक AI मॉडेल्सचा प्रवेश"
+ },
+ "questionFour": {
+ "question": "AppFlowy बाबत तुमचा एकूण अनुभव कसा होता?",
+ "answerOne": "छान",
+ "answerTwo": "चांगला",
+ "answerThree": "सामान्य",
+ "answerFour": "थोडासा वाईट",
+ "answerFive": "असंतोषजनक"
+ }
+ }
+},
+ "ai": {
+ "contentPolicyViolation": "संवेदनशील सामग्रीमुळे प्रतिमा निर्मिती अयशस्वी झाली. कृपया तुमचे इनपुट पुन्हा लिहा आणि पुन्हा प्रयत्न करा.",
+ "textLimitReachedDescription": "तुमच्या वर्कस्पेसमध्ये मोफत AI प्रतिसाद संपले आहेत. अनलिमिटेड प्रतिसादांसाठी प्रो योजना घेण्याचा किंवा AI अॅड-ऑन खरेदी करण्याचा विचार करा.",
+ "imageLimitReachedDescription": "तुमचे मोफत AI प्रतिमा कोटा संपले आहे. कृपया प्रो योजना घ्या किंवा AI अॅड-ऑन खरेदी करा.",
+ "limitReachedAction": {
+ "textDescription": "तुमच्या वर्कस्पेसमध्ये मोफत AI प्रतिसाद संपले आहेत. अधिक प्रतिसाद मिळवण्यासाठी कृपया",
+ "imageDescription": "तुमचे मोफत AI प्रतिमा कोटा संपले आहे. कृपया",
+ "upgrade": "अपग्रेड करा",
+ "toThe": "या योजनेवर",
+ "proPlan": "प्रो योजना",
+ "orPurchaseAn": "किंवा खरेदी करा",
+ "aiAddon": "AI अॅड-ऑन"
+ },
+ "editing": "संपादन करत आहे",
+ "analyzing": "विश्लेषण करत आहे",
+ "continueWritingEmptyDocumentTitle": "लेखन सुरू ठेवता आले नाही",
+ "continueWritingEmptyDocumentDescription": "तुमच्या दस्तऐवजातील मजकूर वाढवण्यात अडचण येत आहे. कृपया एक छोटं परिचय लिहा, मग आम्ही पुढे नेऊ!",
+ "more": "अधिक"
+},
+ "autoUpdate": {
+ "criticalUpdateTitle": "अद्यतन आवश्यक आहे",
+ "criticalUpdateDescription": "तुमचा अनुभव सुधारण्यासाठी आम्ही सुधारणा केल्या आहेत! कृपया {currentVersion} वरून {newVersion} वर अद्यतन करा.",
+ "criticalUpdateButton": "अद्यतन करा",
+ "bannerUpdateTitle": "नवीन आवृत्ती उपलब्ध!",
+ "bannerUpdateDescription": "नवीन वैशिष्ट्ये आणि सुधारणांसाठी. अद्यतनासाठी 'Update' क्लिक करा.",
+ "bannerUpdateButton": "अद्यतन करा",
+ "settingsUpdateTitle": "नवीन आवृत्ती ({newVersion}) उपलब्ध!",
+ "settingsUpdateDescription": "सध्याची आवृत्ती: {currentVersion} (अधिकृत बिल्ड) → {newVersion}",
+ "settingsUpdateButton": "अद्यतन करा",
+ "settingsUpdateWhatsNew": "काय नवीन आहे"
+},
+ "lockPage": {
+ "lockPage": "लॉक केलेले",
+ "reLockPage": "पुन्हा लॉक करा",
+ "lockTooltip": "अनवधानाने संपादन टाळण्यासाठी पृष्ठ लॉक केले आहे. अनलॉक करण्यासाठी क्लिक करा.",
+ "pageLockedToast": "पृष्ठ लॉक केले आहे. कोणी अनलॉक करेपर्यंत संपादन अक्षम आहे.",
+ "lockedOperationTooltip": "पृष्ठ अनवधानाने संपादनापासून वाचवण्यासाठी लॉक केले आहे."
+},
+ "suggestion": {
+ "accept": "स्वीकारा",
+ "keep": "जसे आहे तसे ठेवा",
+ "discard": "रद्द करा",
+ "close": "बंद करा",
+ "tryAgain": "पुन्हा प्रयत्न करा",
+ "rewrite": "पुन्हा लिहा",
+ "insertBelow": "खाली टाका"
+}
+}
diff --git a/frontend/appflowy_flutter/distribute_options.yaml b/frontend/appflowy_flutter/distribute_options.yaml
new file mode 100644
index 0000000000..60f603a938
--- /dev/null
+++ b/frontend/appflowy_flutter/distribute_options.yaml
@@ -0,0 +1,12 @@
+output: dist/
+releases:
+ - name: dev
+ jobs:
+ - name: release-dev-linux-deb
+ package:
+ platform: linux
+ target: deb
+ - name: release-dev-linux-rpm
+ package:
+ platform: linux
+ target: rpm
diff --git a/frontend/appflowy_flutter/dsa_pub.pem b/frontend/appflowy_flutter/dsa_pub.pem
new file mode 100644
index 0000000000..6a9d213b8a
--- /dev/null
+++ b/frontend/appflowy_flutter/dsa_pub.pem
@@ -0,0 +1,36 @@
+-----BEGIN PUBLIC KEY-----
+MIIGQzCCBDUGByqGSM44BAEwggQoAoICAQDlkozRmUnVH1MJFqOamAmUYu0YruaT
+rrt6rCIZ0LFrfNnmHA4LOQEcXwBTTyn5sBmkPq+lb/rjmERKhmvl1rfo6q7tJ8mG
+4TWqSu0tOJQ6QxexnNW4yhzK/r9MS5MQus4Al+y2hQLaAMOUIOnaWIrC9OHy7xyw
++sVipECVKyQqipS4shGUSqbcN+ocQuTB+I0MtIjBii0DGSEY3pxQrfNWjHFL7iTV
+KiTn3YOWPJQvv3FvEDrN+5xU5JZpD97ZhXaJpLUyOQaNvcPaOELPWcOSJwqHOpf5
+b5N/VZ8SGbHNdxy9d5sSChBgtuAOihEhSp6SjFQ9eVHOf4NyJwSEMmi0gpdpqm4Z
+QRJUnM2zIi0p9twR9FRYXzrxOs6yGCQEY+xFG93ShTLTj3zMrIyFqBsqEwFyJiJW
+YWe/zp0V7UlLP+jXO9u9eghNmly7QVqD2P0qs/1V0jZFRuLWpsv4inau/qMZ5EhG
+G4xCJZXfN1pkehy6e05/h+vs5anK3Wa/H8AtY6cK4CpzAanELvn3AH7VLbAhLswu
+6d5CV+DoFgxCWMzGBSdmCYU+2wRLaL8Q9TZHDR+pvQlunEFdfFoGES9WjBPhAsVA
+6Mq22U8XSje9yHI3X9Eqe/7a+ajSgcGmB7oQ11+4xf5h2PtubRW/JL0KMjxCxMTp
+q1md6Ndx/ptBUwIdAIOyiKb2YcTLWAOt+LAlRXMsY1+W4pTXJfV6RcMCggIAPxbd
+0HNj2O/aQhJxNZDMBIcx6+cZ+LKch7qLcaEpVqWHvDSnR2eOJJzWn0RoKK+Vuix/
+4T8texSQkWxAeFFdo6kyrR9XNL7hqEFFq8o9VpmvRzvG6h/bBgh3AHAQE3p/8Wrb
+K13IhnlWqd0MjFufSphm63o0gaWl95j+6KeUoKQnioetu9HiMtFKx0d/KYqTQJg7
+hvR6VNCU2oShfXR3ce7RnUYwD37+djrUjUkoAZkZq2KoxBiKyeoSIeqAme19tKcO
+s6b17mhALELuJ+NtDwlDunyiCDUYX9lTPijHwKeIFtBs38+OtRk3aIqmWTQdbsCz
+Axp+kUMA5ESBME/RBNCSPHuDvtA3wfWvNbA5DXfZLwCgNSxhekq8XntIsRzfJ4v4
+uPzKFcVM3+sUUfSF04HHC9ol+PpLqXUyMnskiizqxFPq7H+6tyFZ7X2HiG6TjcfV
+Wthmv+JyfcABjVnk2qFH7GagENbdtYmfUox13LhE59Sh5chaJnCFtCDp8NClWgZn
+ixCOFQ9EgTLaH6MovTvWpEgG2MfBCu5SMUHi2qSflorqpRFH+rA7NZSnyz3wm7NB
++fJSOP0IjEkOh7MafU6Z61oK9WY/Fc+F1zIENVv8PUc3p75y/4RAp4xzyKcTilaN
+C9U/3MRr3QmWwY7ejtZx6xdOxsvWBRDRSNbDdIkDggIGAAKCAgEAt1DHYZoeXY0r
+vYXmxdNO6zfnbz1GGZHXpakzm9h4BrxPDP5J8DQ9ZeVVKg5+cU9AyMO3cZHp7wkx
+k6IB+ZDUpqO1D3lWriRl2fI8cS4edI0fzpnW1nyhhFD4MbKmP+v27aH+DhZ4Up3y
+GMmJTLmKiYx1EgZp7Sx77PBYDVMsKKd3h9+Hjp2YtUTfD2lleAmC+wcQGZiNtGw/
+eKpsmUVnWrepOdntWTtCQi1OvfcHaF2QmgktCq+68hbDNYWaXmzVIiQqrdv/zzOG
+hCFIrRGWemrxL0iFG4Pzc4UfOINsISQLcUxRuF6pQWPxF8O/mWKfzAeqWxmIujUM
+EoSEuI3yQ8VjlYpW/8FSK7UhgnHBHOpCJWPWs/vQXAnaUR2PYyzuIzhVEhFs8YA8
+iBIKnixIC2hu0YbEk3TBr/TRcbd7mDw9Mq7NT88xzdU13+Wh+4zhdX3rtBHYzBtI
+7GaONGUNyY4h0duoyLpH6dxevaeKN6/bEdzYESjoE58QA88CpnAZGhJVphAba4cb
+w6GTDhK3RlPWh6hRqJwLDILGtnJS3UKeBDRmKMqNuqmHqPjyAAvt9JBO8lzjoLgf
+1cDsXHNWBVwA2jsX2CukNJPlY1Fa3MWhdaUXmy6QGMSisr1sptvBt1Phry8T2u+P
+Y29SB4jvwqls268rP0cWqy4WXwlVwuc=
+-----END PUBLIC KEY-----
diff --git a/frontend/appflowy_flutter/integration_test/desktop/board/board_hide_groups_test.dart b/frontend/appflowy_flutter/integration_test/desktop/board/board_hide_groups_test.dart
index 15da47f0f1..6a012ac763 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/board/board_hide_groups_test.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/board/board_hide_groups_test.dart
@@ -23,24 +23,24 @@ void main() {
final expandFinder = find.byFlowySvg(FlowySvgs.hamburger_s_s);
// Is expanded by default
- expect(collapseFinder, findsOneWidget);
- expect(expandFinder, findsNothing);
-
- // Collapse hidden groups
- await tester.tap(collapseFinder);
- await tester.pumpAndSettle();
-
- // Is collapsed
expect(collapseFinder, findsNothing);
expect(expandFinder, findsOneWidget);
- // Expand hidden groups
+ // Collapse hidden groups
await tester.tap(expandFinder);
await tester.pumpAndSettle();
- // Is expanded
+ // Is collapsed
expect(collapseFinder, findsOneWidget);
expect(expandFinder, findsNothing);
+
+ // Expand hidden groups
+ await tester.tap(collapseFinder);
+ await tester.pumpAndSettle();
+
+ // Is expanded
+ expect(collapseFinder, findsNothing);
+ expect(expandFinder, findsOneWidget);
});
testWidgets('hide first group, and show it again', (tester) async {
@@ -48,6 +48,9 @@ void main() {
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board);
+ final expandFinder = find.byFlowySvg(FlowySvgs.hamburger_s_s);
+ await tester.tapButton(expandFinder);
+
// Tap the options of the first group
final optionsFinder = find
.descendant(
diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/cloud_runner.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/cloud_runner.dart
index bc994eba99..a8c05d5f80 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/cloud/cloud_runner.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/cloud_runner.dart
@@ -1,5 +1,6 @@
import 'data_migration/data_migration_test_runner.dart'
as data_migration_test_runner;
+import 'database/database_test_runner.dart' as database_test_runner;
import 'document/document_test_runner.dart' as document_test_runner;
import 'set_env.dart' as preset_af_cloud_env_test;
import 'sidebar/sidebar_icon_test.dart' as sidebar_icon_test;
@@ -28,4 +29,7 @@ Future main() async {
sidebar_move_page_test.main();
sidebar_rename_untitled_test.main();
sidebar_icon_test.main();
+
+ // database
+ database_test_runner.main();
}
diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/data_migration/anon_user_data_migration_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/data_migration/anon_user_data_migration_test.dart
index 06c27093f9..e34ac02aab 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/cloud/data_migration/anon_user_data_migration_test.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/data_migration/anon_user_data_migration_test.dart
@@ -15,7 +15,6 @@ void main() {
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
- await tester.tapContinousAnotherWay();
await tester.tapAnonymousSignInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
@@ -31,12 +30,6 @@ void main() {
await tester.enterUserName('local_user');
// Scroll to sign-in
- await tester.scrollUntilVisible(
- find.byType(AccountSignInOutButton),
- 100,
- scrollable: find.findSettingsScrollable(),
- );
-
await tester.tapButton(find.byType(AccountSignInOutButton));
// sign up with Google
diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/database/database_image_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/database/database_image_test.dart
new file mode 100644
index 0000000000..5561d40033
--- /dev/null
+++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/database/database_image_test.dart
@@ -0,0 +1,80 @@
+import 'dart:io';
+
+import 'package:appflowy/core/config/kv.dart';
+import 'package:appflowy/core/config/kv_keys.dart';
+import 'package:appflowy/env/cloud_env.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart';
+import 'package:appflowy/startup/startup.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
+import 'package:appflowy_editor/appflowy_editor.dart'
+ hide UploadImageMenu, ResizableImage;
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+import 'package:path/path.dart' as p;
+import 'package:path_provider/path_provider.dart';
+
+import '../../../shared/constants.dart';
+import '../../../shared/database_test_op.dart';
+import '../../../shared/mock/mock_file_picker.dart';
+import '../../../shared/util.dart';
+
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+ // copy link to block
+ group('database image:', () {
+ testWidgets('insert image', (tester) async {
+ await tester.initializeAppFlowy(
+ cloudType: AuthenticatorType.appflowyCloudSelfHost,
+ );
+ await tester.tapGoogleLoginInButton();
+ await tester.expectToSeeHomePageWithGetStartedPage();
+
+ // open the first row detail page and upload an image
+ await tester.createNewPageInSpace(
+ spaceName: Constants.generalSpaceName,
+ layout: ViewLayoutPB.Grid,
+ pageName: 'database image',
+ );
+ await tester.openFirstRowDetailPage();
+
+ // insert an image block
+ {
+ await tester.editor.tapLineOfEditorAt(0);
+ await tester.editor.showSlashMenu();
+ await tester.editor.tapSlashMenuItemWithName(
+ LocaleKeys.document_slashMenu_name_image.tr(),
+ );
+ }
+
+ // upload an image
+ {
+ final image = await rootBundle.load('assets/test/images/sample.jpeg');
+ final tempDirectory = await getTemporaryDirectory();
+ final imagePath = p.join(tempDirectory.path, 'sample.jpeg');
+ final file = File(imagePath)
+ ..writeAsBytesSync(image.buffer.asUint8List());
+
+ mockPickFilePaths(
+ paths: [imagePath],
+ );
+
+ await getIt().set(KVKeys.kCloudType, '0');
+ await tester.tapButtonWithName(
+ LocaleKeys.document_imageBlock_upload_placeholder.tr(),
+ );
+ await tester.pumpAndSettle();
+ expect(find.byType(ResizableImage), findsOneWidget);
+ final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
+ expect(node.type, ImageBlockKeys.type);
+ expect(node.attributes[ImageBlockKeys.url], isNotEmpty);
+
+ // remove the temp file
+ file.deleteSync();
+ }
+ });
+ });
+}
diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/database/database_test_runner.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/database/database_test_runner.dart
new file mode 100644
index 0000000000..4d1a623f07
--- /dev/null
+++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/database/database_test_runner.dart
@@ -0,0 +1,9 @@
+import 'package:integration_test/integration_test.dart';
+
+import 'database_image_test.dart' as database_image_test;
+
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+ database_image_test.main();
+}
diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_ai_writer_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_ai_writer_test.dart
index 32737a23d1..f163608ccb 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_ai_writer_test.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_ai_writer_test.dart
@@ -33,7 +33,7 @@ void main() {
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_aiWriter.tr(),
);
- expect(find.byType(AIWriterBlockComponent), findsOneWidget);
+ expect(find.byType(AiWriterBlockComponent), findsOneWidget);
// switch to another page
await tester.openPage(Constants.gettingStartedPageName);
@@ -41,7 +41,7 @@ void main() {
await tester.openPage(pageName);
// expect the ai writer block is not in the document
- expect(find.byType(AIWriterBlockComponent), findsNothing);
+ expect(find.byType(AiWriterBlockComponent), findsNothing);
});
});
}
diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_option_actions_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_option_actions_test.dart
index 0289fbe176..1bc9bd8f92 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_option_actions_test.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_option_actions_test.dart
@@ -57,7 +57,7 @@ void main() {
// move the checkbox to the child of the block at path [9]
await tester.editor.dragBlock(
[10],
- const Offset(80, -30),
+ const Offset(120, -20),
);
// wait for the move animation to complete
diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/appflowy_cloud_auth_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/appflowy_cloud_auth_test.dart
index 0b649fd6d5..fd65c29927 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/appflowy_cloud_auth_test.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/appflowy_cloud_auth_test.dart
@@ -57,12 +57,6 @@ void main() {
await tester.openSettings();
await tester.openSettingsPage(SettingsPage.account);
- // Scroll to sign-in
- await tester.scrollUntilVisible(
- find.byType(AccountSignInOutButton),
- 100,
- scrollable: find.findSettingsScrollable(),
- );
await tester.tapButton(find.byType(AccountSignInOutButton));
tester.expectToSeeGoogleLoginButton();
diff --git a/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart b/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart
index 3271070c74..0b77a0167b 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart
@@ -6,7 +6,7 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.dart';
-import 'package:appflowy/workspace/presentation/command_palette/widgets/search_result_tile.dart';
+import 'package:appflowy/workspace/presentation/command_palette/widgets/search_result_cell.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -44,12 +44,12 @@ void main() {
await tester.pumpAndSettle(const Duration(milliseconds: 200));
// Expect two search results "ViewOna" and "ViewOne" (Distance 1 to ViewOna)
- expect(find.byType(SearchResultTile), findsNWidgets(2));
+ expect(find.byType(SearchResultCell), findsNWidgets(2));
// The score should be higher for "ViewOna" thus it should be shown first
final secondDocumentWidget = tester
- .widget(find.byType(SearchResultTile).first) as SearchResultTile;
- expect(secondDocumentWidget.result.data, secondDocument);
+ .widget(find.byType(SearchResultCell).first) as SearchResultCell;
+ expect(secondDocumentWidget.item.displayName, secondDocument);
// Change search to "ViewOne"
await tester.enterText(searchFieldFinder, firstDocument);
@@ -57,9 +57,9 @@ void main() {
// The score should be higher for "ViewOne" thus it should be shown first
final firstDocumentWidget = tester.widget(
- find.byType(SearchResultTile).first,
- ) as SearchResultTile;
- expect(firstDocumentWidget.result.data, firstDocument);
+ find.byType(SearchResultCell).first,
+ ) as SearchResultCell;
+ expect(firstDocumentWidget.item.displayName, firstDocument);
});
testWidgets('Displaying icons in search results', (tester) async {
@@ -89,11 +89,11 @@ void main() {
);
await tester.enterText(searchFieldFinder, 'Page-$randomValue');
await tester.pumpAndSettle(const Duration(milliseconds: 200));
- expect(find.byType(SearchResultTile), findsNWidgets(2));
+ expect(find.byType(SearchResultCell), findsNWidgets(2));
/// check results
final svgs = find.descendant(
- of: find.byType(SearchResultTile),
+ of: find.byType(SearchResultCell),
matching: find.byType(FlowySvg),
);
expect(svgs, findsNWidgets(2));
diff --git a/frontend/appflowy_flutter/integration_test/desktop/command_palette/recent_history_test.dart b/frontend/appflowy_flutter/integration_test/desktop/command_palette/recent_history_test.dart
index 277ae8f21e..b9495ae0e7 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/command_palette/recent_history_test.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/command_palette/recent_history_test.dart
@@ -1,5 +1,5 @@
import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart';
-import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_view_tile.dart';
+import 'package:appflowy/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_views_list.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -27,11 +27,12 @@ void main() {
expect(find.byType(RecentViewsList), findsOneWidget);
// Expect three recent history items
- expect(find.byType(RecentViewTile), findsNWidgets(3));
+ expect(find.byType(SearchRecentViewCell), findsNWidgets(3));
// Expect the first item to be the last viewed document
final firstDocumentWidget =
- tester.widget(find.byType(RecentViewTile).first) as RecentViewTile;
+ tester.widget(find.byType(SearchRecentViewCell).first)
+ as SearchRecentViewCell;
expect(firstDocumentWidget.view.name, secondDocument);
});
});
diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_field_settings_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_field_settings_test.dart
index 9b9434d3d7..a71110f1e0 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/database/database_field_settings_test.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_field_settings_test.dart
@@ -15,6 +15,7 @@ void main() {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
+ // create a database and add a linked database view
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid);
@@ -29,6 +30,11 @@ void main() {
await tester.tapHidePropertyButton();
tester.noFieldWithName('New field 1');
+ // create another field, New field 1 to be hidden still
+ await tester.tapNewPropertyButton();
+ await tester.dismissFieldEditor();
+ tester.noFieldWithName('New field 1');
+
// go back to inline database view, expect field to be shown
await tester.tapTabBarLinkedViewByViewName('Untitled');
tester.findFieldWithName('New field 1');
@@ -60,5 +66,40 @@ void main() {
await tester.tapDatabaseSortButton();
await tester.tapCreateSortByFieldType(FieldType.RichText, "New field 1");
});
+
+ testWidgets('field cell width', (tester) async {
+ await tester.initializeAppFlowy();
+ await tester.tapAnonymousSignInButton();
+
+ // create a database and add a linked database view
+ await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
+ await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid);
+
+ // create a field
+ await tester.scrollToRight(find.byType(GridPage));
+ await tester.tapNewPropertyButton();
+ await tester.renameField('New field 1');
+ await tester.dismissFieldEditor();
+
+ // check the width of the field
+ expect(tester.getFieldWidth('New field 1'), 150);
+
+ // change the width of the field
+ await tester.changeFieldWidth('New field 1', 200);
+ expect(tester.getFieldWidth('New field 1'), 205);
+
+ // create another field, New field 1 to be same width
+ await tester.tapNewPropertyButton();
+ await tester.dismissFieldEditor();
+ expect(tester.getFieldWidth('New field 1'), 205);
+
+ // go back to inline database view, expect New field 1 to be 150px
+ await tester.tapTabBarLinkedViewByViewName('Untitled');
+ expect(tester.getFieldWidth('New field 1'), 150);
+
+ // go back to linked database view, expect New field 1 to be 205px
+ await tester.tapTabBarLinkedViewByViewName('Grid');
+ expect(tester.getFieldWidth('New field 1'), 205);
+ });
});
}
diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_view_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_view_test.dart
index e35c9cc9d8..71656c1ea6 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/database/database_view_test.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_view_test.dart
@@ -1,5 +1,10 @@
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -73,5 +78,37 @@ void main() {
await tester.pumpAndSettle();
});
+
+ testWidgets('insert grid in column', (tester) async {
+ await tester.initializeAppFlowy();
+ await tester.tapAnonymousSignInButton();
+
+ /// create page and show slash menu
+ await tester.createNewPageWithNameUnderParent(name: 'test page');
+ await tester.editor.tapLineOfEditorAt(0);
+ await tester.editor.showSlashMenu();
+ await tester.pumpAndSettle();
+
+ /// create a column
+ await tester.editor.tapSlashMenuItemWithName(
+ LocaleKeys.document_slashMenu_name_twoColumns.tr(),
+ );
+ final actionList = find.byType(BlockActionList);
+ expect(actionList, findsNWidgets(2));
+ final position = tester.getCenter(actionList.last);
+
+ /// tap the second child of column
+ await tester.tapAt(position.copyWith(dx: position.dx + 50));
+
+ /// create a grid
+ await tester.editor.showSlashMenu();
+ await tester.pumpAndSettle();
+ await tester.editor.tapSlashMenuItemWithName(
+ LocaleKeys.document_slashMenu_name_grid.tr(),
+ );
+
+ final grid = find.byType(GridPageContent);
+ expect(grid, findsOneWidget);
+ });
});
}
diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_alignment_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_alignment_test.dart
index d95d907881..1a8a3fcda8 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/document/document_alignment_test.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_alignment_test.dart
@@ -27,8 +27,9 @@ void main() {
await tester.pumpAndSettle();
// click the align center
- await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_left_s);
- await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_center_s);
+ await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_alignment_m);
+ await tester
+ .tapButtonWithFlowySvgData(FlowySvgs.toolbar_text_align_center_m);
// expect to see the align center
final editorState = tester.editor.getCurrentEditorState();
@@ -36,13 +37,15 @@ void main() {
expect(first.attributes[blockComponentAlign], 'center');
// click the align right
- await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_center_s);
- await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_right_s);
+ await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_alignment_m);
+ await tester
+ .tapButtonWithFlowySvgData(FlowySvgs.toolbar_text_align_right_m);
expect(first.attributes[blockComponentAlign], 'right');
// click the align left
- await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_right_s);
- await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_left_s);
+ await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_alignment_m);
+ await tester
+ .tapButtonWithFlowySvgData(FlowySvgs.toolbar_text_align_left_m);
expect(first.attributes[blockComponentAlign], 'left');
});
@@ -75,7 +78,7 @@ void main() {
[
LogicalKeyboardKey.control,
LogicalKeyboardKey.shift,
- LogicalKeyboardKey.keyE,
+ LogicalKeyboardKey.keyC,
],
tester: tester,
withKeyUp: true,
diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart
index aa4f8252d5..d1e34edcb5 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart
@@ -1,10 +1,12 @@
+import 'dart:async';
import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
-import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
@@ -320,8 +322,14 @@ void main() {
(tester) async {
const url = 'https://appflowy.io';
await tester.pasteContent(plainText: url, (editorState) async {
+ final pasteAsMenu = find.byType(PasteAsMenu);
+ expect(pasteAsMenu, findsOneWidget);
+ final bookmarkButton = find.text(
+ LocaleKeys.document_plugins_linkPreview_typeSelection_bookmark.tr(),
+ );
+ await tester.tapButton(bookmarkButton);
// the second one is the paragraph node
- expect(editorState.document.root.children.length, 2);
+ expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkPreviewBlockKeys.url], url);
@@ -333,19 +341,20 @@ void main() {
await tester.hoverOnWidget(
find.byType(CustomLinkPreviewWidget),
onHover: () async {
- final convertToLinkButton = find.byWidgetPredicate((widget) {
- return widget is MenuBlockButton &&
- widget.tooltip ==
- LocaleKeys.document_plugins_urlPreview_convertToLink.tr();
- });
+ /// show menu
+ final menu = find.byType(CustomLinkPreviewMenu);
+ expect(menu, findsOneWidget);
+ await tester.tapButton(menu);
+
+ final convertToLinkButton = find.text(
+ LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl
+ .tr(),
+ );
expect(convertToLinkButton, findsOneWidget);
- await tester.tap(convertToLinkButton);
- await tester.pumpAndSettle();
+ await tester.tapButton(convertToLinkButton);
},
);
- await tester.pumpAndSettle();
-
final editorState = tester.editor.getCurrentEditorState();
final textNode = editorState.getNodeAtPath([0])!;
expect(textNode.type, ParagraphBlockKeys.type);
@@ -363,14 +372,19 @@ void main() {
(tester) async {
const url = 'https://appflowy.io';
await tester.pasteContent(plainText: url, (editorState) async {
+ final pasteAsMenu = find.byType(PasteAsMenu);
+ expect(pasteAsMenu, findsOneWidget);
+ final bookmarkButton = find.text(
+ LocaleKeys.document_plugins_linkPreview_typeSelection_bookmark.tr(),
+ );
+ await tester.tapButton(bookmarkButton);
// the second one is the paragraph node
- expect(editorState.document.root.children.length, 2);
+ expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkPreviewBlockKeys.url], url);
});
- await tester.editor.tapLineOfEditorAt(0);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed:
@@ -458,7 +472,7 @@ void main() {
});
testWidgets('paste the image url', (tester) async {
- const plainText = 'https://appflowy.io/1.jpg';
+ const plainText = 'http://example.com/1.jpg';
final image = await rootBundle.load('assets/test/images/sample.jpeg');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(plainText: plainText, image: ('jpeg', bytes),
@@ -469,16 +483,6 @@ void main() {
});
});
- testWidgets('paste image url without extension', (tester) async {
- const plainText =
- 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=david-marcu-78A265wPiO4-unsplash.jpg&w=640';
- await tester.pasteContent(plainText: plainText, (editorState) {
- final node = editorState.getNodeAtPath([0])!;
- expect(node.type, ImageBlockKeys.type);
- expect(node.attributes[ImageBlockKeys.url], isNotEmpty);
- });
- });
-
const testMarkdownText = '''
# I'm h1
## I'm h2
@@ -521,7 +525,7 @@ void main() {
extension on WidgetTester {
Future pasteContent(
- void Function(EditorState editorState) test, {
+ FutureOr Function(EditorState editorState) test, {
Future Function(EditorState editorState)? beforeTest,
String? plainText,
String? html,
@@ -558,6 +562,6 @@ extension on WidgetTester {
);
await pumpAndSettle(const Duration(milliseconds: 1000));
- test(editor.getCurrentEditorState());
+ await test(editor.getCurrentEditorState());
}
}
diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_create_and_delete_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_create_and_delete_test.dart
index 43320509ce..c2e00a4b48 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/document/document_create_and_delete_test.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_create_and_delete_test.dart
@@ -13,6 +13,8 @@ void main() {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
+ final finder = find.text(gettingStarted, findRichText: true);
+ await tester.pumpUntilFound(finder, timeout: const Duration(seconds: 2));
// create a new document
const pageName = 'Test Document';
diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_link_preview_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_link_preview_test.dart
new file mode 100644
index 0000000000..39f8bfd4f6
--- /dev/null
+++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_link_preview_test.dart
@@ -0,0 +1,453 @@
+import 'dart:io';
+
+import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_error_preview.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart';
+import 'package:appflowy/startup/startup.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+
+import '../../shared/util.dart';
+
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+ const avaliableLink = 'https://appflowy.io/',
+ unavailableLink = 'www.thereIsNoting.com';
+
+ Future preparePage(WidgetTester tester, {String? pageName}) async {
+ await tester.initializeAppFlowy();
+ await tester.tapAnonymousSignInButton();
+ await tester.createNewPageWithNameUnderParent(name: pageName);
+ await tester.editor.tapLineOfEditorAt(0);
+ }
+
+ Future pasteLink(WidgetTester tester, String link) async {
+ await getIt()
+ .setData(ClipboardServiceData(plainText: link));
+
+ /// paste the link
+ await tester.simulateKeyEvent(
+ LogicalKeyboardKey.keyV,
+ isControlPressed: Platform.isLinux || Platform.isWindows,
+ isMetaPressed: Platform.isMacOS,
+ );
+ await tester.pumpAndSettle(Duration(seconds: 1));
+ }
+
+ Future pasteAs(
+ WidgetTester tester,
+ String link,
+ PasteMenuType type, {
+ Duration waitTime = const Duration(milliseconds: 500),
+ }) async {
+ await pasteLink(tester, link);
+ final convertToMentionButton = find.text(type.title);
+ await tester.tapButton(convertToMentionButton);
+ await tester.pumpAndSettle(waitTime);
+ }
+
+ void checkUrl(Node node, String link) {
+ expect(node.type, ParagraphBlockKeys.type);
+ expect(node.delta!.toJson(), [
+ {
+ 'insert': link,
+ 'attributes': {'href': link},
+ }
+ ]);
+ }
+
+ void checkMention(Node node, String link) {
+ final delta = node.delta!;
+ final insert = (delta.first as TextInsert).text;
+ final attributes = delta.first.attributes;
+ expect(insert, MentionBlockKeys.mentionChar);
+ final mention =
+ attributes?[MentionBlockKeys.mention] as Map;
+ expect(mention[MentionBlockKeys.type], MentionType.externalLink.name);
+ expect(mention[MentionBlockKeys.url], avaliableLink);
+ }
+
+ void checkBookmark(Node node, String link) {
+ expect(node.type, LinkPreviewBlockKeys.type);
+ expect(node.attributes[LinkPreviewBlockKeys.url], link);
+ }
+
+ void checkEmbed(Node node, String link) {
+ expect(node.type, LinkPreviewBlockKeys.type);
+ expect(node.attributes[LinkEmbedKeys.previewType], LinkEmbedKeys.embed);
+ expect(node.attributes[LinkPreviewBlockKeys.url], link);
+ }
+
+ group('Paste as URL', () {
+ Future pasteAndTurnInto(
+ WidgetTester tester,
+ String link,
+ String title,
+ ) async {
+ await pasteLink(tester, link);
+ final convertToLinkButton = find
+ .text(LocaleKeys.document_plugins_linkPreview_typeSelection_URL.tr());
+ await tester.tapButton(convertToLinkButton);
+
+ /// hover link and turn into mention
+ await tester.hoverOnWidget(
+ find.byType(LinkHoverTrigger),
+ onHover: () async {
+ final turnintoButton = find.byFlowySvg(FlowySvgs.turninto_m);
+ await tester.tapButton(turnintoButton);
+ final convertToButton = find.text(title);
+ await tester.tapButton(convertToButton);
+ await tester.pumpAndSettle(Duration(seconds: 1));
+ },
+ );
+ }
+
+ testWidgets('paste a link', (tester) async {
+ final link = avaliableLink;
+ await preparePage(tester);
+ await pasteLink(tester, link);
+ final convertToLinkButton = find
+ .text(LocaleKeys.document_plugins_linkPreview_typeSelection_URL.tr());
+ await tester.tapButton(convertToLinkButton);
+ final node = tester.editor.getNodeAtPath([0]);
+ checkUrl(node, link);
+ });
+
+ testWidgets('paste a link and turn into mention', (tester) async {
+ final link = avaliableLink;
+ await preparePage(tester);
+ await pasteAndTurnInto(
+ tester,
+ link,
+ LinkConvertMenuCommand.toMention.title,
+ );
+
+ /// check metion values
+ final node = tester.editor.getNodeAtPath([0]);
+ checkMention(node, link);
+ });
+
+ testWidgets('paste a link and turn into bookmark', (tester) async {
+ final link = avaliableLink;
+ await preparePage(tester);
+ await pasteAndTurnInto(
+ tester,
+ link,
+ LinkConvertMenuCommand.toBookmark.title,
+ );
+
+ /// check metion values
+ final node = tester.editor.getNodeAtPath([0]);
+ checkBookmark(node, link);
+ });
+
+ testWidgets('paste a link and turn into embed', (tester) async {
+ final link = avaliableLink;
+ await preparePage(tester);
+ await pasteAndTurnInto(
+ tester,
+ link,
+ LinkConvertMenuCommand.toEmbed.title,
+ );
+
+ /// check metion values
+ final node = tester.editor.getNodeAtPath([0]);
+ checkEmbed(node, link);
+ });
+ });
+
+ group('Paste as Mention', () {
+ Future pasteAsMention(WidgetTester tester, String link) =>
+ pasteAs(tester, link, PasteMenuType.mention);
+
+ String getMentionLink(Node node) {
+ final insert = node.delta?.first as TextInsert?;
+ final mention = insert?.attributes?[MentionBlockKeys.mention]
+ as Map?;
+ return mention?[MentionBlockKeys.url] ?? '';
+ }
+
+ Future hoverMentionAndClick(
+ WidgetTester tester,
+ String command,
+ ) async {
+ final mentionLink = find.byType(MentionLinkBlock);
+ expect(mentionLink, findsOneWidget);
+ await tester.hoverOnWidget(
+ mentionLink,
+ onHover: () async {
+ final errorPreview = find.byType(MentionLinkErrorPreview);
+ expect(errorPreview, findsOneWidget);
+ final convertButton = find.byFlowySvg(FlowySvgs.turninto_m);
+ await tester.tapButton(convertButton);
+ final menuButton = find.text(command);
+ await tester.tapButton(menuButton);
+ },
+ );
+ }
+
+ testWidgets('paste a link as mention', (tester) async {
+ final link = avaliableLink;
+ await preparePage(tester);
+ await pasteAsMention(tester, link);
+ final node = tester.editor.getNodeAtPath([0]);
+ checkMention(node, link);
+ });
+
+ testWidgets('paste as mention and copy link', (tester) async {
+ final link = avaliableLink;
+ await preparePage(tester);
+ await pasteAsMention(tester, link);
+ final mentionLink = find.byType(MentionLinkBlock);
+ expect(mentionLink, findsOneWidget);
+ await tester.hoverOnWidget(
+ mentionLink,
+ onHover: () async {
+ final preview = find.byType(MentionLinkPreview);
+ if (!preview.hasFound) {
+ final copyButton = find.byFlowySvg(FlowySvgs.toolbar_link_m);
+ await tester.tapButton(copyButton);
+ } else {
+ final moreOptionButton = find.byFlowySvg(FlowySvgs.toolbar_more_m);
+ await tester.tapButton(moreOptionButton);
+ final copyButton =
+ find.text(MentionLinktMenuCommand.copyLink.title);
+ await tester.tapButton(copyButton);
+ }
+ },
+ );
+ final clipboardContent = await getIt().getData();
+ expect(clipboardContent.plainText, link);
+ });
+
+ testWidgets('paste as error mention and turninto url', (tester) async {
+ String link = unavailableLink;
+ await preparePage(tester);
+ await pasteAsMention(tester, link);
+ Node node = tester.editor.getNodeAtPath([0]);
+ link = getMentionLink(node);
+ await hoverMentionAndClick(
+ tester,
+ MentionLinktErrorMenuCommand.toURL.title,
+ );
+ node = tester.editor.getNodeAtPath([0]);
+ checkUrl(node, link);
+ });
+
+ testWidgets('paste as error mention and turninto embed', (tester) async {
+ String link = unavailableLink;
+ await preparePage(tester);
+ await pasteAsMention(tester, link);
+ Node node = tester.editor.getNodeAtPath([0]);
+ link = getMentionLink(node);
+ await hoverMentionAndClick(
+ tester,
+ MentionLinktErrorMenuCommand.toEmbed.title,
+ );
+ node = tester.editor.getNodeAtPath([0]);
+ checkEmbed(node, link);
+ });
+
+ testWidgets('paste as error mention and turninto bookmark', (tester) async {
+ String link = unavailableLink;
+ await preparePage(tester);
+ await pasteAsMention(tester, link);
+ Node node = tester.editor.getNodeAtPath([0]);
+ link = getMentionLink(node);
+ await hoverMentionAndClick(
+ tester,
+ MentionLinktErrorMenuCommand.toBookmark.title,
+ );
+ node = tester.editor.getNodeAtPath([0]);
+ checkBookmark(node, link);
+ });
+
+ testWidgets('paste as error mention and remove link', (tester) async {
+ String link = unavailableLink;
+ await preparePage(tester);
+ await pasteAsMention(tester, link);
+ Node node = tester.editor.getNodeAtPath([0]);
+ link = getMentionLink(node);
+ await hoverMentionAndClick(
+ tester,
+ MentionLinktErrorMenuCommand.removeLink.title,
+ );
+ node = tester.editor.getNodeAtPath([0]);
+ expect(node.type, ParagraphBlockKeys.type);
+ expect(node.delta!.toJson(), [
+ {'insert': link},
+ ]);
+ });
+ });
+
+ group('Paste as Bookmark', () {
+ Future pasteAsBookmark(WidgetTester tester, String link) =>
+ pasteAs(tester, link, PasteMenuType.bookmark);
+
+ Future hoverAndClick(
+ WidgetTester tester,
+ LinkPreviewMenuCommand command,
+ ) async {
+ final bookmark = find.byType(CustomLinkPreviewBlockComponent);
+ expect(bookmark, findsOneWidget);
+ await tester.hoverOnWidget(
+ bookmark,
+ onHover: () async {
+ final menuButton = find.byFlowySvg(FlowySvgs.toolbar_more_m);
+ await tester.tapButton(menuButton);
+ final commandButton = find.text(command.title);
+ await tester.tapButton(commandButton);
+ },
+ );
+ }
+
+ testWidgets('paste a link as bookmark', (tester) async {
+ final link = avaliableLink;
+ await preparePage(tester);
+ await pasteAsBookmark(tester, link);
+ final node = tester.editor.getNodeAtPath([0]);
+ checkBookmark(node, link);
+ });
+
+ testWidgets('paste a link as bookmark and convert to mention',
+ (tester) async {
+ final link = avaliableLink;
+ await preparePage(tester);
+ await pasteAsBookmark(tester, link);
+ await hoverAndClick(tester, LinkPreviewMenuCommand.convertToMention);
+ final node = tester.editor.getNodeAtPath([0]);
+ checkMention(node, link);
+ });
+
+ testWidgets('paste a link as bookmark and convert to url', (tester) async {
+ final link = avaliableLink;
+ await preparePage(tester);
+ await pasteAsBookmark(tester, link);
+ await hoverAndClick(tester, LinkPreviewMenuCommand.convertToUrl);
+ final node = tester.editor.getNodeAtPath([0]);
+ checkUrl(node, link);
+ });
+
+ testWidgets('paste a link as bookmark and convert to embed',
+ (tester) async {
+ final link = avaliableLink;
+ await preparePage(tester);
+ await pasteAsBookmark(tester, link);
+ await hoverAndClick(tester, LinkPreviewMenuCommand.convertToEmbed);
+ final node = tester.editor.getNodeAtPath([0]);
+ checkEmbed(node, link);
+ });
+
+ testWidgets('paste a link as bookmark and copy link', (tester) async {
+ final link = avaliableLink;
+ await preparePage(tester);
+ await pasteAsBookmark(tester, link);
+ await hoverAndClick(tester, LinkPreviewMenuCommand.copyLink);
+ final clipboardContent = await getIt().getData();
+ expect(clipboardContent.plainText, link);
+ });
+
+ testWidgets('paste a link as bookmark and replace link', (tester) async {
+ final link = avaliableLink;
+ await preparePage(tester);
+ await pasteAsBookmark(tester, link);
+ await hoverAndClick(tester, LinkPreviewMenuCommand.replace);
+ await tester.simulateKeyEvent(
+ LogicalKeyboardKey.keyA,
+ isControlPressed: Platform.isLinux || Platform.isWindows,
+ isMetaPressed: Platform.isMacOS,
+ );
+ await tester.simulateKeyEvent(LogicalKeyboardKey.delete);
+ await tester.enterText(find.byType(TextFormField), unavailableLink);
+ await tester.tapButton(find.text(LocaleKeys.button_replace.tr()));
+ final node = tester.editor.getNodeAtPath([0]);
+ checkBookmark(node, unavailableLink);
+ });
+
+ testWidgets('paste a link as bookmark and remove link', (tester) async {
+ final link = avaliableLink;
+ await preparePage(tester);
+ await pasteAsBookmark(tester, link);
+ await hoverAndClick(tester, LinkPreviewMenuCommand.removeLink);
+ final node = tester.editor.getNodeAtPath([0]);
+ expect(node.type, ParagraphBlockKeys.type);
+ expect(node.delta!.toJson(), [
+ {'insert': link},
+ ]);
+ });
+ });
+ group('Paste as Embed', () {
+ Future pasteAsEmbed(WidgetTester tester, String link) =>
+ pasteAs(tester, link, PasteMenuType.embed);
+
+ Future hoverAndConvert(
+ WidgetTester tester,
+ LinkEmbedConvertCommand command,
+ ) async {
+ final embed = find.byType(LinkEmbedBlockComponent);
+ expect(embed, findsOneWidget);
+ await tester.hoverOnWidget(
+ embed,
+ onHover: () async {
+ final menuButton = find.byFlowySvg(FlowySvgs.turninto_m);
+ await tester.tapButton(menuButton);
+ final commandButton = find.text(command.title);
+ await tester.tapButton(commandButton);
+ },
+ );
+ }
+
+ testWidgets('paste a link as embed', (tester) async {
+ final link = avaliableLink;
+ await preparePage(tester);
+ await pasteAsEmbed(tester, link);
+ final node = tester.editor.getNodeAtPath([0]);
+ checkEmbed(node, link);
+ });
+
+ testWidgets('paste a link as bookmark and convert to mention',
+ (tester) async {
+ final link = avaliableLink;
+ await preparePage(tester);
+ await pasteAsEmbed(tester, link);
+ await hoverAndConvert(tester, LinkEmbedConvertCommand.toMention);
+ final node = tester.editor.getNodeAtPath([0]);
+ checkMention(node, link);
+ });
+
+ testWidgets('paste a link as bookmark and convert to url', (tester) async {
+ final link = avaliableLink;
+ await preparePage(tester);
+ await pasteAsEmbed(tester, link);
+ await hoverAndConvert(tester, LinkEmbedConvertCommand.toURL);
+ final node = tester.editor.getNodeAtPath([0]);
+ checkUrl(node, link);
+ });
+
+ testWidgets('paste a link as bookmark and convert to bookmark',
+ (tester) async {
+ final link = avaliableLink;
+ await preparePage(tester);
+ await pasteAsEmbed(tester, link);
+ await hoverAndConvert(tester, LinkEmbedConvertCommand.toBookmark);
+ final node = tester.editor.getNodeAtPath([0]);
+ checkBookmark(node, link);
+ });
+ });
+}
diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_option_action_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_option_action_test.dart
index cbc634cf02..6ec12287a8 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/document/document_option_action_test.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_option_action_test.dart
@@ -76,13 +76,12 @@ void main() {
LocaleKeys.document_slashMenu_name_heading1.tr(): HeadingBlockKeys.type,
LocaleKeys.document_slashMenu_name_heading2.tr(): HeadingBlockKeys.type,
LocaleKeys.document_slashMenu_name_heading3.tr(): HeadingBlockKeys.type,
- LocaleKeys.document_slashMenu_name_bulletedList.tr():
+ LocaleKeys.editor_bulletedListShortForm.tr():
BulletedListBlockKeys.type,
- LocaleKeys.document_slashMenu_name_numberedList.tr():
+ LocaleKeys.editor_numberedListShortForm.tr():
NumberedListBlockKeys.type,
LocaleKeys.document_slashMenu_name_quote.tr(): QuoteBlockKeys.type,
- LocaleKeys.document_slashMenu_name_todoList.tr():
- TodoListBlockKeys.type,
+ LocaleKeys.editor_checkbox.tr(): TodoListBlockKeys.type,
LocaleKeys.document_slashMenu_name_callout.tr(): CalloutBlockKeys.type,
LocaleKeys.document_slashMenu_name_text.tr(): ParagraphBlockKeys.type,
};
@@ -117,13 +116,12 @@ void main() {
LocaleKeys.document_slashMenu_name_heading1.tr(): HeadingBlockKeys.type,
LocaleKeys.document_slashMenu_name_heading2.tr(): HeadingBlockKeys.type,
LocaleKeys.document_slashMenu_name_heading3.tr(): HeadingBlockKeys.type,
- LocaleKeys.document_slashMenu_name_bulletedList.tr():
+ LocaleKeys.editor_bulletedListShortForm.tr():
BulletedListBlockKeys.type,
- LocaleKeys.document_slashMenu_name_numberedList.tr():
+ LocaleKeys.editor_numberedListShortForm.tr():
NumberedListBlockKeys.type,
LocaleKeys.document_slashMenu_name_quote.tr(): QuoteBlockKeys.type,
- LocaleKeys.document_slashMenu_name_todoList.tr():
- TodoListBlockKeys.type,
+ LocaleKeys.editor_checkbox.tr(): TodoListBlockKeys.type,
LocaleKeys.document_slashMenu_name_callout.tr(): CalloutBlockKeys.type,
LocaleKeys.document_slashMenu_name_text.tr(): ParagraphBlockKeys.type,
};
diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_selection_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_selection_test.dart
index bd0fd18c50..de1cb880a5 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/document/document_selection_test.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_selection_test.dart
@@ -1,5 +1,6 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -47,5 +48,41 @@ void main() {
expect(editorState.selection!.start.offset, 0);
});
+
+ testWidgets('select and delete text', (tester) async {
+ await tester.initializeAppFlowy();
+ await tester.tapAnonymousSignInButton();
+
+ /// create a new document
+ await tester.createNewPageWithNameUnderParent();
+
+ /// input text
+ final editor = tester.editor;
+ final editorState = editor.getCurrentEditorState();
+
+ const inputText = 'Test for text selection and deletion';
+ final texts = inputText.split(' ');
+ await editor.tapLineOfEditorAt(0);
+ await tester.ime.insertText(inputText);
+
+ /// selecte and delete
+ int index = 0;
+ while (texts.isNotEmpty) {
+ final text = texts.removeAt(0);
+ await tester.editor.updateSelection(
+ Selection(
+ start: Position(path: [0], offset: index),
+ end: Position(path: [0], offset: index + text.length),
+ ),
+ );
+ await tester.simulateKeyEvent(LogicalKeyboardKey.delete);
+ index++;
+ }
+
+ /// excpete the text value is correct
+ final node = editorState.getNodeAtPath([0])!;
+ final nodeText = node.delta?.toPlainText() ?? '';
+ expect(nodeText, ' ' * (index - 1));
+ });
});
}
diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart
index a05545753e..bc0671834b 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart
@@ -13,6 +13,7 @@ import 'document_with_multi_image_block_test.dart'
as document_with_multi_image_block_test;
import 'document_with_simple_table_test.dart'
as document_with_simple_table_test;
+import 'document_link_preview_test.dart' as document_link_preview_test;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@@ -28,4 +29,5 @@ void main() {
document_find_menu_test.main();
document_toolbar_test.main();
document_with_simple_table_test.main();
+ document_link_preview_test.main();
}
diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart
index c3a086626f..f455cd479d 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart
@@ -1,5 +1,19 @@
+import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart';
+import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -8,24 +22,33 @@ import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+ Future selectText(WidgetTester tester, String text) async {
+ await tester.editor.updateSelection(
+ Selection.single(
+ path: [0],
+ startOffset: 0,
+ endOffset: text.length,
+ ),
+ );
+ }
+
+ Future prepareForToolbar(WidgetTester tester, String text) async {
+ await tester.initializeAppFlowy();
+ await tester.tapAnonymousSignInButton();
+
+ await tester.createNewPageWithNameUnderParent();
+
+ await tester.editor.tapLineOfEditorAt(0);
+ await tester.ime.insertText(text);
+ await selectText(tester, text);
+ }
+
group('document toolbar:', () {
testWidgets('font family', (tester) async {
- await tester.initializeAppFlowy();
- await tester.tapAnonymousSignInButton();
-
- await tester.createNewPageWithNameUnderParent();
-
- await tester.editor.tapLineOfEditorAt(0);
- const text = 'font family';
- await tester.ime.insertText(text);
- await tester.editor.updateSelection(
- Selection.single(
- path: [0],
- startOffset: 0,
- endOffset: text.length,
- ),
- );
+ await prepareForToolbar(tester, 'font family');
+ // tap more options button
+ await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_more_m);
// tap the font family button
final fontFamilyButton = find.byKey(kFontFamilyToolbarItemKey);
await tester.tapButton(fontFamilyButton);
@@ -46,5 +69,302 @@ void main() {
abel,
);
});
+
+ testWidgets('heading 1~3', (tester) async {
+ const text = 'heading';
+ await prepareForToolbar(tester, text);
+
+ Future testChangeHeading(
+ FlowySvgData svg,
+ String title,
+ int level,
+ ) async {
+ /// tap suggestions item
+ final suggestionsButton = find.byKey(kSuggestionsItemKey);
+ await tester.tapButton(suggestionsButton);
+
+ /// tap item
+ await tester.ensureVisible(find.byFlowySvg(svg));
+ await tester.tapButton(find.byFlowySvg(svg));
+
+ /// check the type of node is [HeadingBlockKeys.type]
+ await selectText(tester, text);
+ final editorState = tester.editor.getCurrentEditorState();
+ final selection = editorState.selection!;
+ final node = editorState.getNodeAtPath(selection.start.path)!,
+ nodeLevel = node.attributes[HeadingBlockKeys.level]!;
+ expect(node.type, HeadingBlockKeys.type);
+ expect(nodeLevel, level);
+
+ /// show toolbar again
+ await selectText(tester, text);
+
+ /// the text of suggestions item should be changed
+ expect(
+ find.descendant(of: suggestionsButton, matching: find.text(title)),
+ findsOneWidget,
+ );
+ }
+
+ await testChangeHeading(
+ FlowySvgs.type_h1_m,
+ LocaleKeys.document_toolbar_h1.tr(),
+ 1,
+ );
+
+ await testChangeHeading(
+ FlowySvgs.type_h2_m,
+ LocaleKeys.document_toolbar_h2.tr(),
+ 2,
+ );
+ await testChangeHeading(
+ FlowySvgs.type_h3_m,
+ LocaleKeys.document_toolbar_h3.tr(),
+ 3,
+ );
+ });
+
+ testWidgets('toggle 1~3', (tester) async {
+ const text = 'toggle';
+ await prepareForToolbar(tester, text);
+
+ Future testChangeToggle(
+ FlowySvgData svg,
+ String title,
+ int? level,
+ ) async {
+ /// tap suggestions item
+ final suggestionsButton = find.byKey(kSuggestionsItemKey);
+ await tester.tapButton(suggestionsButton);
+
+ /// tap item
+ await tester.ensureVisible(find.byFlowySvg(svg));
+ await tester.tapButton(find.byFlowySvg(svg));
+
+ /// check the type of node is [HeadingBlockKeys.type]
+ await selectText(tester, text);
+ final editorState = tester.editor.getCurrentEditorState();
+ final selection = editorState.selection!;
+ final node = editorState.getNodeAtPath(selection.start.path)!,
+ nodeLevel = node.attributes[ToggleListBlockKeys.level];
+ expect(node.type, ToggleListBlockKeys.type);
+ expect(nodeLevel, level);
+
+ /// show toolbar again
+ await selectText(tester, text);
+
+ /// the text of suggestions item should be changed
+ expect(
+ find.descendant(of: suggestionsButton, matching: find.text(title)),
+ findsOneWidget,
+ );
+ }
+
+ await testChangeToggle(
+ FlowySvgs.type_toggle_list_m,
+ LocaleKeys.editor_toggleListShortForm.tr(),
+ null,
+ );
+
+ await testChangeToggle(
+ FlowySvgs.type_toggle_h1_m,
+ LocaleKeys.editor_toggleHeading1ShortForm.tr(),
+ 1,
+ );
+
+ await testChangeToggle(
+ FlowySvgs.type_toggle_h2_m,
+ LocaleKeys.editor_toggleHeading2ShortForm.tr(),
+ 2,
+ );
+
+ await testChangeToggle(
+ FlowySvgs.type_toggle_h3_m,
+ LocaleKeys.editor_toggleHeading3ShortForm.tr(),
+ 3,
+ );
+ });
+
+ testWidgets('toolbar will not rebuild after click item', (tester) async {
+ const text = 'Test rebuilding';
+ await prepareForToolbar(tester, text);
+ Finder toolbar = find.byType(DesktopFloatingToolbar);
+ Element toolbarElement = toolbar.evaluate().first;
+ final elementHashcode = toolbarElement.hashCode;
+ final boldButton = find.byFlowySvg(FlowySvgs.toolbar_bold_m),
+ underlineButton = find.byFlowySvg(FlowySvgs.toolbar_underline_m),
+ italicButton = find.byFlowySvg(FlowySvgs.toolbar_inline_italic_m);
+
+ /// tap format buttons
+ await tester.tapButton(boldButton);
+ await tester.tapButton(underlineButton);
+ await tester.tapButton(italicButton);
+ toolbar = find.byType(DesktopFloatingToolbar);
+ toolbarElement = toolbar.evaluate().first;
+
+ /// check if the toolbar is not rebuilt
+ expect(elementHashcode, toolbarElement.hashCode);
+ final editorState = tester.editor.getCurrentEditorState();
+
+ /// check text formats
+ expect(
+ editorState
+ .getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.bold),
+ true,
+ );
+ expect(
+ editorState
+ .getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.italic),
+ true,
+ );
+ expect(
+ editorState
+ .getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.underline),
+ true,
+ );
+ });
+ });
+
+ group('document toolbar: link', () {
+ String? getLinkFromNode(Node node) {
+ for (final insert in node.delta!) {
+ final link = insert.attributes?.href;
+ if (link != null) return link;
+ }
+ return null;
+ }
+
+ bool isPageLink(Node node) {
+ for (final insert in node.delta!) {
+ final isPage = insert.attributes?.isPage;
+ if (isPage == true) return true;
+ }
+ return false;
+ }
+
+ String getNodeText(Node node) {
+ for (final insert in node.delta!) {
+ if (insert is TextInsert) return insert.text;
+ }
+ return '';
+ }
+
+ testWidgets('insert link and remove link', (tester) async {
+ const text = 'insert link', link = 'https://test.appflowy.cloud';
+ await prepareForToolbar(tester, text);
+
+ final toolbar = find.byType(DesktopFloatingToolbar);
+ expect(toolbar, findsOneWidget);
+
+ /// tap link button to show CreateLinkMenu
+ final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m);
+ await tester.tapButton(linkButton);
+ final createLinkMenu = find.byType(LinkCreateMenu);
+ expect(createLinkMenu, findsOneWidget);
+
+ /// test esc to close
+ await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
+ expect(toolbar, findsNothing);
+
+ /// show toolbar again
+ await tester.editor.tapLineOfEditorAt(0);
+ await selectText(tester, text);
+ await tester.tapButton(linkButton);
+
+ /// insert link
+ final textField = find.descendant(
+ of: createLinkMenu,
+ matching: find.byType(TextFormField),
+ );
+ await tester.enterText(textField, link);
+ await tester.pumpAndSettle();
+ await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
+ Node node = tester.editor.getNodeAtPath([0]);
+ expect(getLinkFromNode(node), link);
+ await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
+
+ /// hover link
+ await tester.hoverOnWidget(find.byType(LinkHoverTrigger));
+ final hoverMenu = find.byType(LinkHoverMenu);
+ expect(hoverMenu, findsOneWidget);
+
+ /// copy link
+ final copyButton = find.descendant(
+ of: hoverMenu,
+ matching: find.byFlowySvg(FlowySvgs.toolbar_link_m),
+ );
+ await tester.tapButton(copyButton);
+ final clipboardContent = await getIt().getData();
+ final plainText = clipboardContent.plainText;
+ expect(plainText, link);
+
+ /// remove link
+ await tester.hoverOnWidget(find.byType(LinkHoverTrigger));
+ await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_unlink_m));
+ node = tester.editor.getNodeAtPath([0]);
+ expect(getLinkFromNode(node), null);
+ });
+
+ testWidgets('insert link and edit link', (tester) async {
+ const text = 'edit link',
+ link = 'https://test.appflowy.cloud',
+ afterText = '$text after';
+ await prepareForToolbar(tester, text);
+
+ /// tap link button to show CreateLinkMenu
+ final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m);
+ await tester.tapButton(linkButton);
+
+ /// search for page and select it
+ final textField = find.descendant(
+ of: find.byType(LinkCreateMenu),
+ matching: find.byType(TextFormField),
+ );
+ await tester.enterText(textField, gettingStarted);
+ await tester.pumpAndSettle();
+ await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
+ await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
+
+ Node node = tester.editor.getNodeAtPath([0]);
+ expect(isPageLink(node), true);
+ expect(getLinkFromNode(node) == link, false);
+
+ /// hover link
+ await tester.hoverOnWidget(find.byType(LinkHoverTrigger));
+
+ /// click edit button to show LinkEditMenu
+ final editButton = find.byFlowySvg(FlowySvgs.toolbar_link_edit_m);
+ await tester.tapButton(editButton);
+ final linkEditMenu = find.byType(LinkEditMenu);
+ expect(linkEditMenu, findsOneWidget);
+
+ /// change the link text
+ final titleField = find.descendant(
+ of: linkEditMenu,
+ matching: find.byType(TextFormField),
+ );
+ await tester.enterText(titleField, afterText);
+ await tester.pumpAndSettle();
+ await tester.tapButton(
+ find.descendant(of: linkEditMenu, matching: find.text(gettingStarted)),
+ );
+ final linkField = find.ancestor(
+ of: find.text(LocaleKeys.document_toolbar_linkInputHint.tr()),
+ matching: find.byType(TextFormField),
+ );
+ await tester.enterText(linkField, link);
+ await tester.pumpAndSettle();
+ await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
+
+ /// apply the change
+ final applyButton =
+ find.text(LocaleKeys.settings_appearance_documentSettings_apply.tr());
+ await tester.tapButton(applyButton);
+
+ node = tester.editor.getNodeAtPath([0]);
+ expect(isPageLink(node), false);
+ expect(getLinkFromNode(node), link);
+ expect(getNodeText(node), afterText);
+ });
});
}
diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test.dart
index 45f688bd58..67e0149cd1 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test.dart
@@ -1,9 +1,11 @@
+import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -32,9 +34,15 @@ void main() {
Selection.single(path: [0], startOffset: 0, endOffset: formula.length),
);
+ // tap the more options button
+ final moreOptionButton = find.findFlowyTooltip(
+ LocaleKeys.document_toolbar_moreOptions.tr(),
+ );
+ await tester.tapButton(moreOptionButton);
+
// tap the inline math equation button
- final inlineMathEquationButton = find.findFlowyTooltip(
- LocaleKeys.document_plugins_createInlineMathEquation.tr(),
+ final inlineMathEquationButton = find.text(
+ LocaleKeys.document_toolbar_equation.tr(),
);
await tester.tapButton(inlineMathEquationButton);
@@ -77,10 +85,15 @@ void main() {
Selection.single(path: [0], startOffset: 0, endOffset: formula.length),
);
- // tap the inline math equation button
- var inlineMathEquationButton = find.findFlowyTooltip(
- LocaleKeys.document_plugins_createInlineMathEquation.tr(),
+ // tap the more options button
+ final moreOptionButton = find.findFlowyTooltip(
+ LocaleKeys.document_toolbar_moreOptions.tr(),
);
+ await tester.tapButton(moreOptionButton);
+
+ // tap the inline math equation button
+ final inlineMathEquationButton =
+ find.byFlowySvg(FlowySvgs.type_formula_m);
await tester.tapButton(inlineMathEquationButton);
// expect to see the math equation block
@@ -92,17 +105,7 @@ void main() {
Selection.single(path: [0], startOffset: 0, endOffset: 1),
);
- // expect to the see the inline math equation button is highlighted
- inlineMathEquationButton = find.descendant(
- of: find.findFlowyTooltip(
- LocaleKeys.document_plugins_createInlineMathEquation.tr(),
- ),
- matching: find.byType(SVGIconItemWidget),
- );
- expect(
- tester.widget(inlineMathEquationButton).isHighlight,
- isTrue,
- );
+ await tester.tapButton(moreOptionButton);
// cancel the format
await tester.tapButton(inlineMathEquationButton);
@@ -133,10 +136,15 @@ void main() {
Selection.single(path: [0], startOffset: 0, endOffset: formula.length),
);
- // tap the inline math equation button
- final inlineMathEquationButton = find.findFlowyTooltip(
- LocaleKeys.document_plugins_createInlineMathEquation.tr(),
+ // tap the more options button
+ final moreOptionButton = find.findFlowyTooltip(
+ LocaleKeys.document_toolbar_moreOptions.tr(),
);
+ await tester.tapButton(moreOptionButton);
+
+ // tap the inline math equation button
+ final inlineMathEquationButton =
+ find.byFlowySvg(FlowySvgs.type_formula_m);
await tester.tapButton(inlineMathEquationButton);
// expect to see the math equation block
@@ -163,5 +171,55 @@ void main() {
lessThan(5),
);
});
+
+ testWidgets('insert inline math equation by shortcut', (tester) async {
+ await tester.initializeAppFlowy();
+ await tester.tapAnonymousSignInButton();
+
+ // create a new document
+ await tester.createNewPageWithNameUnderParent(
+ name: 'insert inline math equation by shortcut',
+ );
+
+ // tap the first line of the document
+ await tester.editor.tapLineOfEditorAt(0);
+ // insert a inline page
+ const formula = 'E = MC ^ 2';
+ await tester.ime.insertText(formula);
+ await tester.editor.updateSelection(
+ Selection.single(path: [0], startOffset: 0, endOffset: formula.length),
+ );
+
+ // mock key event
+ await tester.simulateKeyEvent(
+ LogicalKeyboardKey.keyE,
+ isShiftPressed: true,
+ isControlPressed: true,
+ );
+
+ // expect to see the math equation block
+ final inlineMathEquation = find.byType(InlineMathEquation);
+ expect(inlineMathEquation, findsOneWidget);
+
+ await tester.editor.tapLineOfEditorAt(0);
+ const text = 'Hello World';
+ await tester.ime.insertText(text);
+
+ final inlineText = find.textContaining(text, findRichText: true);
+ expect(inlineText, findsOneWidget);
+
+ // the text should be in the same line with the math equation
+ final inlineMathEquationPosition = tester.getRect(inlineMathEquation);
+ final textPosition = tester.getRect(inlineText);
+ // allow 5px difference
+ expect(
+ (textPosition.top - inlineMathEquationPosition.top).abs(),
+ lessThan(5),
+ );
+ expect(
+ (textPosition.bottom - inlineMathEquationPosition.bottom).abs(),
+ lessThan(5),
+ );
+ });
});
}
diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_multi_image_block_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_multi_image_block_test.dart
index 68ad7db7e5..d8b0784a39 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_multi_image_block_test.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_multi_image_block_test.dart
@@ -48,7 +48,7 @@ void main() {
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_photoGallery.tr(),
- offset: 80,
+ offset: 100,
);
expect(find.byType(MultiImageBlockComponent), findsOneWidget);
expect(find.byType(MultiImagePlaceholder), findsOneWidget);
@@ -146,7 +146,7 @@ void main() {
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_photoGallery.tr(),
- offset: 80,
+ offset: 100,
);
expect(find.byType(MultiImageBlockComponent), findsOneWidget);
expect(find.byType(MultiImagePlaceholder), findsOneWidget);
diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_toggle_heading_block_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_toggle_heading_block_test.dart
index 8eb47fc15f..c4aa289855 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_toggle_heading_block_test.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_toggle_heading_block_test.dart
@@ -1,3 +1,4 @@
+import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@@ -85,16 +86,10 @@ void main() {
),
);
- await tester.tapButton(find.byType(HeadingPopup));
- await tester.pumpAndSettle();
-
- expect(
- find.byType(HeadingButton),
- findsNWidgets(3),
- );
+ await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_text_format_m));
// tap the H1 button
- await tester.tapButton(find.byType(HeadingButton).at(0));
+ await tester.tapButton(find.byFlowySvg(FlowySvgs.type_h1_m).at(0));
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
diff --git a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart
index dd40470ab9..2236f03960 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart
@@ -1,5 +1,3 @@
-import 'dart:io';
-
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
@@ -7,11 +5,9 @@ import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flowy_infra_ui/style_widget/text_field.dart';
-import 'package:flutter/services.dart';
+import 'package:flowy_svg/flowy_svg.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
-import 'package:path/path.dart' as p;
-import 'package:path_provider/path_provider.dart';
import '../../shared/base.dart';
import '../../shared/common_operations.dart';
@@ -215,17 +211,12 @@ void main() {
}
});
- testWidgets('Update page custom icon in title bar', (tester) async {
+ testWidgets('Update page custom image icon in title bar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
/// prepare local image
- final imagePath = await rootBundle.load('assets/test/images/sample.jpeg');
- final tempDirectory = await getTemporaryDirectory();
- final localImagePath = p.join(tempDirectory.path, 'sample.jpeg');
- final imageFile = File(localImagePath)
- ..writeAsBytesSync(imagePath.buffer.asUint8List());
- final iconData = EmojiIconData.custom(imageFile.path);
+ final iconData = await tester.prepareImageIcon();
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
@@ -259,4 +250,97 @@ void main() {
);
}
});
+
+ testWidgets('Update page custom svg icon in title bar', (tester) async {
+ await tester.initializeAppFlowy();
+ await tester.tapAnonymousSignInButton();
+
+ /// prepare local image
+ final iconData = await tester.prepareSvgIcon();
+
+ // create document, board, grid and calendar views
+ for (final value in ViewLayoutPB.values) {
+ if (value == ViewLayoutPB.Chat) {
+ continue;
+ }
+
+ await tester.createNewPageWithNameUnderParent(
+ name: value.name,
+ parentName: gettingStarted,
+ layout: value,
+ );
+
+ // update its icon
+ await tester.updatePageIconInTitleBarByName(
+ name: value.name,
+ layout: value,
+ icon: iconData,
+ );
+
+ tester.expectViewHasIcon(
+ value.name,
+ value,
+ iconData,
+ );
+
+ tester.expectViewTitleHasIcon(
+ value.name,
+ value,
+ iconData,
+ );
+ }
+ });
+
+ testWidgets('Update page custom svg icon in title bar by pasting a link',
+ (tester) async {
+ await tester.initializeAppFlowy();
+ await tester.tapAnonymousSignInButton();
+
+ /// prepare local image
+ const testIconLink =
+ 'https://beta.appflowy.cloud/api/file_storage/008e6f23-516b-4d8d-b1fe-2b75c51eee26/v1/blob/6bdf8dff%2D0e54%2D4d35%2D9981%2Dcde68bef1141/BGpLnRtb3AGBNgSJsceu70j83zevYKrMLzqsTIJcBeI=.svg';
+
+ /// create document, board, grid and calendar views
+ for (final value in ViewLayoutPB.values) {
+ if (value == ViewLayoutPB.Chat) {
+ continue;
+ }
+
+ await tester.createNewPageWithNameUnderParent(
+ name: value.name,
+ parentName: gettingStarted,
+ layout: value,
+ );
+
+ /// update its icon
+ await tester.updatePageIconInTitleBarByPasteALink(
+ name: value.name,
+ layout: value,
+ iconLink: testIconLink,
+ );
+
+ /// check if there is a svg in page
+ final pageName = tester.findPageName(
+ value.name,
+ layout: value,
+ );
+ final imageInPage = find.descendant(
+ of: pageName,
+ matching: find.byType(SvgPicture),
+ );
+ expect(imageInPage, findsOneWidget);
+
+ /// check if there is a svg in title
+ final imageInTitle = find.descendant(
+ of: find.byType(ViewTitleBar),
+ matching: find.byWidgetPredicate((w) {
+ if (w is! SvgPicture) return false;
+ final loader = w.bytesLoader;
+ if (loader is! SvgFileLoader) return false;
+ return loader.file.path.endsWith('.svg');
+ }),
+ );
+ expect(imageInTitle, findsOneWidget);
+ }
+ });
}
diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart
index 554a6eecbf..1a4e57078f 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart
@@ -1,8 +1,10 @@
import 'dart:io';
+import 'package:appflowy/plugins/emoji/emoji_handler.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/editor_component/service/editor.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -39,4 +41,110 @@ void main() {
expect(find.byType(EmojiSelectionMenu), findsOneWidget);
});
});
+
+ group('insert emoji by colon', () {
+ Future createNewDocumentAndShowEmojiList(
+ WidgetTester tester, {
+ String? search,
+ }) async {
+ await tester.initializeAppFlowy();
+ await tester.tapAnonymousSignInButton();
+ await tester.createNewPageWithNameUnderParent();
+ await tester.editor.tapLineOfEditorAt(0);
+ await tester.ime.insertText(':${search ?? 'a'}');
+ await tester.pumpAndSettle(Duration(seconds: 1));
+ }
+
+ testWidgets('insert with click', (tester) async {
+ await createNewDocumentAndShowEmojiList(tester);
+
+ /// emoji list is showing
+ final emojiHandler = find.byType(EmojiHandler);
+ expect(emojiHandler, findsOneWidget);
+ final emojiButtons =
+ find.descendant(of: emojiHandler, matching: find.byType(FlowyButton));
+ final firstTextFinder = find.descendant(
+ of: emojiButtons.first,
+ matching: find.byType(FlowyText),
+ );
+ final emojiText =
+ (firstTextFinder.evaluate().first.widget as FlowyText).text;
+
+ /// click first emoji item
+ await tester.tapButton(emojiButtons.first);
+ final firstNode =
+ tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
+
+ /// except the emoji is in document
+ expect(emojiText.contains(firstNode.delta!.toPlainText()), true);
+ });
+
+ testWidgets('insert with arrow and enter', (tester) async {
+ await createNewDocumentAndShowEmojiList(tester);
+
+ /// emoji list is showing
+ final emojiHandler = find.byType(EmojiHandler);
+ expect(emojiHandler, findsOneWidget);
+ final emojiButtons =
+ find.descendant(of: emojiHandler, matching: find.byType(FlowyButton));
+
+ /// tap arrow down and arrow up
+ await tester.simulateKeyEvent(LogicalKeyboardKey.arrowUp);
+ await tester.simulateKeyEvent(LogicalKeyboardKey.arrowDown);
+
+ final firstTextFinder = find.descendant(
+ of: emojiButtons.first,
+ matching: find.byType(FlowyText),
+ );
+ final emojiText =
+ (firstTextFinder.evaluate().first.widget as FlowyText).text;
+
+ /// tap enter
+ await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
+ final firstNode =
+ tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
+
+ /// except the emoji is in document
+ expect(emojiText.contains(firstNode.delta!.toPlainText()), true);
+ });
+
+ testWidgets('insert with searching', (tester) async {
+ await createNewDocumentAndShowEmojiList(tester, search: 's');
+
+ /// search for `smiling eyes`, IME is not working, use keyboard input
+ final searchText = [
+ LogicalKeyboardKey.keyM,
+ LogicalKeyboardKey.keyI,
+ LogicalKeyboardKey.keyL,
+ LogicalKeyboardKey.keyI,
+ LogicalKeyboardKey.keyN,
+ LogicalKeyboardKey.keyG,
+ LogicalKeyboardKey.space,
+ LogicalKeyboardKey.keyE,
+ LogicalKeyboardKey.keyY,
+ LogicalKeyboardKey.keyE,
+ LogicalKeyboardKey.keyS,
+ ];
+
+ for (final key in searchText) {
+ await tester.simulateKeyEvent(key);
+ }
+
+ /// tap enter
+ await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
+ final firstNode =
+ tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
+
+ /// except the emoji is in document
+ expect(firstNode.delta!.toPlainText().contains('😄'), true);
+ });
+
+ testWidgets('start searching with sapce', (tester) async {
+ await createNewDocumentAndShowEmojiList(tester, search: ' ');
+
+ /// emoji list is showing
+ final emojiHandler = find.byType(EmojiHandler);
+ expect(emojiHandler, findsNothing);
+ });
+ });
}
diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/share_markdown_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/share_markdown_test.dart
index 9a4fe30815..8c3c29ab77 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/share_markdown_test.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/share_markdown_test.dart
@@ -1,12 +1,16 @@
+import 'dart:convert';
import 'dart:io';
import 'package:appflowy/plugins/shared/share/share_button.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
+import 'package:archive/archive.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import '../../shared/mock/mock_file_picker.dart';
import '../../shared/util.dart';
+import '../document/document_with_database_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@@ -18,7 +22,7 @@ void main() {
// mock the file picker
final path = await mockSaveFilePath(
- p.join(context.applicationDataDirectory, 'test.md'),
+ p.join(context.applicationDataDirectory, 'test.zip'),
);
// click the share button and select markdown
await tester.tapShareButton();
@@ -28,10 +32,14 @@ void main() {
tester.expectToExportSuccess();
final file = File(path);
- final isExist = file.existsSync();
- expect(isExist, true);
- final markdown = file.readAsStringSync();
- expect(markdown, expectedMarkdown);
+ expect(file.existsSync(), true);
+ final archive = ZipDecoder().decodeBytes(file.readAsBytesSync());
+ for (final entry in archive) {
+ if (entry.isFile && entry.name.endsWith('.md')) {
+ final markdown = utf8.decode(entry.content);
+ expect(markdown, expectedMarkdown);
+ }
+ }
});
testWidgets(
@@ -57,7 +65,7 @@ void main() {
final path = await mockSaveFilePath(
p.join(
context.applicationDataDirectory,
- '${shareButtonState.view.name}.md',
+ '${shareButtonState.view.name}.zip',
),
);
@@ -69,10 +77,44 @@ void main() {
tester.expectToExportSuccess();
final file = File(path);
- final isExist = file.existsSync();
- expect(isExist, true);
+ expect(file.existsSync(), true);
+ final archive = ZipDecoder().decodeBytes(file.readAsBytesSync());
+ for (final entry in archive) {
+ if (entry.isFile && entry.name.endsWith('.md')) {
+ final markdown = utf8.decode(entry.content);
+ expect(markdown, expectedMarkdown);
+ }
+ }
},
);
+
+ testWidgets('share the markdown with database', (tester) async {
+ final context = await tester.initializeAppFlowy();
+ await tester.tapAnonymousSignInButton();
+ await insertLinkedDatabase(tester, ViewLayoutPB.Grid);
+
+ // mock the file picker
+ final path = await mockSaveFilePath(
+ p.join(context.applicationDataDirectory, 'test.zip'),
+ );
+ // click the share button and select markdown
+ await tester.tapShareButton();
+ await tester.tapMarkdownButton();
+
+ // expect to see the success dialog
+ tester.expectToExportSuccess();
+
+ final file = File(path);
+ expect(file.existsSync(), true);
+ final archive = ZipDecoder().decodeBytes(file.readAsBytesSync());
+ bool hasCsvFile = false;
+ for (final entry in archive) {
+ if (entry.isFile && entry.name.endsWith('.csv')) {
+ hasCsvFile = true;
+ }
+ }
+ expect(hasCsvFile, true);
+ });
});
}
diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/uncategorized_test_runner_1.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/uncategorized_test_runner_1.dart
index f7d94e8b4a..836cfe4ccd 100644
--- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/uncategorized_test_runner_1.dart
+++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/uncategorized_test_runner_1.dart
@@ -13,7 +13,6 @@ void main() {
hotkeys_test.main();
emoji_shortcut_test.main();
hotkeys_test.main();
- emoji_shortcut_test.main();
share_markdown_test.main();
import_files_test.main();
zoom_in_out_test.main();
diff --git a/frontend/appflowy_flutter/integration_test/mobile/document/at_menu_test.dart b/frontend/appflowy_flutter/integration_test/mobile/document/at_menu_test.dart
new file mode 100644
index 0000000000..2b348d3a2e
--- /dev/null
+++ b/frontend/appflowy_flutter/integration_test/mobile/document/at_menu_test.dart
@@ -0,0 +1,56 @@
+import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart';
+import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+
+import '../../shared/util.dart';
+
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+ const title = 'Test At Menu';
+
+ group('at menu', () {
+ testWidgets('show at menu', (tester) async {
+ await tester.launchInAnonymousMode();
+ await tester.createPageAndShowAtMenu(title);
+ final menuWidget = find.byType(MobileInlineActionsMenu);
+ expect(menuWidget, findsOneWidget);
+ });
+
+ testWidgets('search by at menu', (tester) async {
+ await tester.launchInAnonymousMode();
+ await tester.createPageAndShowAtMenu(title);
+ const searchText = gettingStarted;
+ await tester.ime.insertText(searchText);
+ final actionWidgets = find.byType(MobileInlineActionsWidget);
+ expect(actionWidgets, findsNWidgets(2));
+ });
+
+ testWidgets('tap at menu', (tester) async {
+ await tester.launchInAnonymousMode();
+ await tester.createPageAndShowAtMenu(title);
+ const searchText = gettingStarted;
+ await tester.ime.insertText(searchText);
+ final actionWidgets = find.byType(MobileInlineActionsWidget);
+ await tester.tap(actionWidgets.last);
+ expect(find.byType(MentionPageBlock), findsOneWidget);
+ });
+
+ testWidgets('create subpage with at menu', (tester) async {
+ await tester.launchInAnonymousMode();
+ await tester.createNewDocumentOnMobile(title);
+ await tester.editor.tapLineOfEditorAt(0);
+ const subpageName = 'Subpage';
+ await tester.ime.insertText('[[$subpageName');
+ await tester.pumpAndSettle();
+ final actionWidgets = find.byType(MobileInlineActionsWidget);
+ await tester.tapButton(actionWidgets.first);
+ final firstNode =
+ tester.editor.getCurrentEditorState().getNodeAtPath([0]);
+ assert(firstNode != null);
+ expect(firstNode!.delta?.toPlainText().contains('['), false);
+ });
+ });
+}
diff --git a/frontend/appflowy_flutter/integration_test/mobile/document/document_test_runner.dart b/frontend/appflowy_flutter/integration_test/mobile/document/document_test_runner.dart
index e19896b310..90d5ca6d0d 100644
--- a/frontend/appflowy_flutter/integration_test/mobile/document/document_test_runner.dart
+++ b/frontend/appflowy_flutter/integration_test/mobile/document/document_test_runner.dart
@@ -1,8 +1,11 @@
import 'package:integration_test/integration_test.dart';
+import 'at_menu_test.dart' as at_menu;
+import 'at_menu_test.dart' as at_menu_test;
import 'page_style_test.dart' as page_style_test;
import 'plus_menu_test.dart' as plus_menu_test;
import 'simple_table_test.dart' as simple_table_test;
+import 'slash_menu_test.dart' as slash_menu;
import 'title_test.dart' as title_test;
import 'toolbar_test.dart' as toolbar_test;
@@ -13,6 +16,9 @@ void main() {
title_test.main();
page_style_test.main();
plus_menu_test.main();
+ at_menu_test.main();
simple_table_test.main();
toolbar_test.main();
+ slash_menu.main();
+ at_menu.main();
}
diff --git a/frontend/appflowy_flutter/integration_test/mobile/document/icon_test.dart b/frontend/appflowy_flutter/integration_test/mobile/document/icon_test.dart
index 1b94c65436..da7c7e92e7 100644
--- a/frontend/appflowy_flutter/integration_test/mobile/document/icon_test.dart
+++ b/frontend/appflowy_flutter/integration_test/mobile/document/icon_test.dart
@@ -1,15 +1,12 @@
-import 'dart:io';
-
+import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_icon.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
-import 'package:flutter/services.dart';
+import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
-import 'package:path/path.dart' as p;
-import 'package:path_provider/path_provider.dart';
import '../../shared/emoji.dart';
import '../../shared/util.dart';
@@ -18,16 +15,11 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('document title:', () {
- testWidgets('update page custom icon in title bar', (tester) async {
+ testWidgets('update page custom image icon in title bar', (tester) async {
await tester.launchInAnonymousMode();
/// prepare local image
- final imagePath = await rootBundle.load('assets/test/images/sample.jpeg');
- final tempDirectory = await getTemporaryDirectory();
- final localImagePath = p.join(tempDirectory.path, 'sample.jpeg');
- final imageFile = File(localImagePath)
- ..writeAsBytesSync(imagePath.buffer.asUint8List());
- final iconData = EmojiIconData.custom(imageFile.path);
+ final iconData = await tester.prepareImageIcon();
/// create an empty page
await tester
@@ -50,16 +42,63 @@ void main() {
/// check result
final documentPage = find.byType(MobileDocumentScreen);
- final rawEmojiIconWidget = find
+ final rawEmojiIconFinder = find
.descendant(
of: documentPage,
matching: find.byType(RawEmojiIconWidget),
)
- .evaluate()
- .first
- .widget as RawEmojiIconWidget;
+ .last;
+ final rawEmojiIconWidget =
+ rawEmojiIconFinder.evaluate().first.widget as RawEmojiIconWidget;
final iconDataInWidget = rawEmojiIconWidget.emoji;
expect(iconDataInWidget.type, FlowyIconType.custom);
+ final imageFinder =
+ find.descendant(of: rawEmojiIconFinder, matching: find.byType(Image));
+ expect(imageFinder, findsOneWidget);
+ });
+
+ testWidgets('update page custom svg icon in title bar', (tester) async {
+ await tester.launchInAnonymousMode();
+
+ /// prepare local image
+ final iconData = await tester.prepareSvgIcon();
+
+ /// create an empty page
+ await tester
+ .tapButton(find.byKey(BottomNavigationBarItemType.add.valueKey));
+
+ /// show Page style page
+ await tester.tapButton(find.byType(MobileViewPageLayoutButton));
+ final pageStyleIcon = find.byType(PageStyleIcon);
+ final iconInPageStyleIcon = find.descendant(
+ of: pageStyleIcon,
+ matching: find.byType(RawEmojiIconWidget),
+ );
+ expect(iconInPageStyleIcon, findsNothing);
+
+ /// show icon picker
+ await tester.tapButton(pageStyleIcon);
+
+ /// upload custom icon
+ await tester.pickImage(iconData);
+
+ /// check result
+ final documentPage = find.byType(MobileDocumentScreen);
+ final rawEmojiIconFinder = find
+ .descendant(
+ of: documentPage,
+ matching: find.byType(RawEmojiIconWidget),
+ )
+ .last;
+ final rawEmojiIconWidget =
+ rawEmojiIconFinder.evaluate().first.widget as RawEmojiIconWidget;
+ final iconDataInWidget = rawEmojiIconWidget.emoji;
+ expect(iconDataInWidget.type, FlowyIconType.custom);
+ final svgFinder = find.descendant(
+ of: rawEmojiIconFinder,
+ matching: find.byType(SvgPicture),
+ );
+ expect(svgFinder, findsOneWidget);
});
});
}
diff --git a/frontend/appflowy_flutter/integration_test/mobile/document/plus_menu_test.dart b/frontend/appflowy_flutter/integration_test/mobile/document/plus_menu_test.dart
index bdd84f9098..b54c543f7e 100644
--- a/frontend/appflowy_flutter/integration_test/mobile/document/plus_menu_test.dart
+++ b/frontend/appflowy_flutter/integration_test/mobile/document/plus_menu_test.dart
@@ -1,6 +1,9 @@
import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart';
+import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -85,5 +88,32 @@ void main() {
equals(Selection.collapsed(Position(path: [2]))),
);
});
+
+ const title = 'Test Plus Menu';
+ testWidgets('show plus menu', (tester) async {
+ await tester.launchInAnonymousMode();
+ await tester.createPageAndShowPlusMenu(title);
+ final menuWidget = find.byType(MobileInlineActionsMenu);
+ expect(menuWidget, findsOneWidget);
+ });
+
+ testWidgets('search by plus menu', (tester) async {
+ await tester.launchInAnonymousMode();
+ await tester.createPageAndShowPlusMenu(title);
+ const searchText = gettingStarted;
+ await tester.ime.insertText(searchText);
+ final actionWidgets = find.byType(MobileInlineActionsWidget);
+ expect(actionWidgets, findsNWidgets(2));
+ });
+
+ testWidgets('tap plus menu', (tester) async {
+ await tester.launchInAnonymousMode();
+ await tester.createPageAndShowPlusMenu(title);
+ const searchText = gettingStarted;
+ await tester.ime.insertText(searchText);
+ final actionWidgets = find.byType(MobileInlineActionsWidget);
+ await tester.tap(actionWidgets.last);
+ expect(find.byType(MentionPageBlock), findsOneWidget);
+ });
});
}
diff --git a/frontend/appflowy_flutter/integration_test/mobile/document/slash_menu_test.dart b/frontend/appflowy_flutter/integration_test/mobile/document/slash_menu_test.dart
new file mode 100644
index 0000000000..11031d2b71
--- /dev/null
+++ b/frontend/appflowy_flutter/integration_test/mobile/document/slash_menu_test.dart
@@ -0,0 +1,84 @@
+import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item.dart';
+import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item_widget.dart';
+import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_widget.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/mobile_items.dart';
+import 'package:flutter/cupertino.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+
+import '../../shared/util.dart';
+
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+ const title = 'Test Slash Menu';
+
+ group('slash menu', () {
+ testWidgets('show slash menu', (tester) async {
+ await tester.launchInAnonymousMode();
+ await tester.createPageAndShowSlashMenu(title);
+ final menuWidget = find.byType(MobileSelectionMenuWidget);
+ expect(menuWidget, findsOneWidget);
+ final items =
+ (menuWidget.evaluate().first.widget as MobileSelectionMenuWidget)
+ .items;
+ int i = 0;
+ for (final item in items) {
+ final localItem = mobileItems[i];
+ expect(item.name, localItem.name);
+ i++;
+ }
+ });
+
+ testWidgets('search by slash menu', (tester) async {
+ await tester.launchInAnonymousMode();
+ await tester.createPageAndShowSlashMenu(title);
+ const searchText = 'Heading';
+ await tester.ime.insertText(searchText);
+ final itemWidgets = find.byType(MobileSelectionMenuItemWidget);
+ int number = 0;
+ for (final item in mobileItems) {
+ if (item is MobileSelectionMenuItem) {
+ for (final childItem in item.children) {
+ if (childItem.name
+ .toLowerCase()
+ .contains(searchText.toLowerCase())) {
+ number++;
+ }
+ }
+ } else {
+ if (item.name.toLowerCase().contains(searchText.toLowerCase())) {
+ number++;
+ }
+ }
+ }
+ expect(itemWidgets, findsNWidgets(number));
+ });
+
+ testWidgets('tap to show submenu', (tester) async {
+ await tester.launchInAnonymousMode();
+ await tester.createNewDocumentOnMobile(title);
+ await tester.editor.tapLineOfEditorAt(0);
+ final listview = find.descendant(
+ of: find.byType(MobileSelectionMenuWidget),
+ matching: find.byType(ListView),
+ );
+ for (final item in mobileItems) {
+ if (item is! MobileSelectionMenuItem) continue;
+ await tester.editor.showSlashMenu();
+ await tester.scrollUntilVisible(
+ find.text(item.name),
+ 50,
+ scrollable: listview,
+ duration: const Duration(milliseconds: 250),
+ );
+ await tester.tap(find.text(item.name));
+ final childrenLength = ((listview.evaluate().first.widget as ListView)
+ .childrenDelegate as SliverChildListDelegate)
+ .children
+ .length;
+ expect(childrenLength, item.children.length);
+ }
+ });
+ });
+}
diff --git a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart
index 76d2d82b4a..d7a505d152 100644
--- a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart
+++ b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart
@@ -50,6 +50,8 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
+import 'package:path/path.dart' as p;
+import 'package:path_provider/path_provider.dart';
import 'package:universal_platform/universal_platform.dart';
import 'emoji.dart';
@@ -65,12 +67,10 @@ extension CommonOperations on WidgetTester {
} else {
// cloud version
final anonymousButton = find.byType(SignInAnonymousButtonV2);
- await tapButton(anonymousButton);
+ await tapButton(anonymousButton, warnIfMissed: true);
}
- if (Platform.isWindows) {
- await pumpAndSettle(const Duration(milliseconds: 200));
- }
+ await pumpAndSettle(const Duration(milliseconds: 200));
}
Future tapContinousAnotherWay() async {
@@ -615,7 +615,7 @@ extension CommonOperations on WidgetTester {
);
final distanceY = getCenter(to).dy - getCenter(from).dx;
await drag(from, Offset(0, distanceY));
- await pumpAndSettle();
+ await pumpAndSettle(const Duration(seconds: 1));
}
// tap the button with [FlowySvgData]
@@ -677,6 +677,25 @@ extension CommonOperations on WidgetTester {
await pumpAndSettle();
}
+ Future updatePageIconInTitleBarByPasteALink({
+ required String name,
+ required ViewLayoutPB layout,
+ required String iconLink,
+ }) async {
+ await openPage(
+ name,
+ layout: layout,
+ );
+ final title = find.descendant(
+ of: find.byType(ViewTitleBar),
+ matching: find.text(name),
+ );
+ await tapButton(title);
+ await tapButton(find.byType(EmojiPickerButton));
+ await pasteImageLinkAsIcon(iconLink);
+ await pumpAndSettle();
+ }
+
Future openNotificationHub({int tabIndex = 0}) async {
final finder = find.descendant(
of: find.byType(NotificationButton),
@@ -935,6 +954,45 @@ extension CommonOperations on WidgetTester {
),
);
}
+
+ Future prepareImageIcon() async {
+ final imagePath = await rootBundle.load('assets/test/images/sample.jpeg');
+ final tempDirectory = await getTemporaryDirectory();
+ final localImagePath = p.join(tempDirectory.path, 'sample.jpeg');
+ final imageFile = File(localImagePath)
+ ..writeAsBytesSync(imagePath.buffer.asUint8List());
+ return EmojiIconData.custom(imageFile.path);
+ }
+
+ Future prepareSvgIcon() async {
+ final imagePath = await rootBundle.load('assets/test/images/sample.svg');
+ final tempDirectory = await getTemporaryDirectory();
+ final localImagePath = p.join(tempDirectory.path, 'sample.svg');
+ final imageFile = File(localImagePath)
+ ..writeAsBytesSync(imagePath.buffer.asUint8List());
+ return EmojiIconData.custom(imageFile.path);
+ }
+
+ /// create new page and show slash menu
+ Future createPageAndShowSlashMenu(String title) async {
+ await createNewDocumentOnMobile(title);
+ await editor.tapLineOfEditorAt(0);
+ await editor.showSlashMenu();
+ }
+
+ /// create new page and show at menu
+ Future createPageAndShowAtMenu(String title) async {
+ await createNewDocumentOnMobile(title);
+ await editor.tapLineOfEditorAt(0);
+ await editor.showAtMenu();
+ }
+
+ /// create new page and show plus menu
+ Future createPageAndShowPlusMenu(String title) async {
+ await createNewDocumentOnMobile(title);
+ await editor.tapLineOfEditorAt(0);
+ await editor.showPlusMenu();
+ }
}
extension SettingsFinder on CommonFinders {
diff --git a/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart b/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart
index 676c96f042..970965f294 100644
--- a/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart
+++ b/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart
@@ -1,17 +1,9 @@
import 'dart:io';
-import 'package:appflowy/plugins/database/application/field/filter_entities.dart';
-import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart';
-import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart';
-import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
-import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
-import 'package:flutter/gestures.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
-
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/calculations/calculation_type_ext.dart';
+import 'package:appflowy/plugins/database/application/field/filter_entities.dart';
import 'package:appflowy/plugins/database/board/presentation/board_page.dart';
import 'package:appflowy/plugins/database/board/presentation/widgets/board_column_header.dart';
import 'package:appflowy/plugins/database/calendar/application/calendar_bloc.dart';
@@ -27,10 +19,11 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checklist.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/choicechip.dart';
+import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart';
+import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart';
-import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/create_filter_list.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/disclosure_button.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/footer/grid_footer.dart';
@@ -44,6 +37,7 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/filt
import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/sort_button.dart';
import 'package:appflowy/plugins/database/tab_bar/desktop/tab_bar_add_button.dart';
import 'package:appflowy/plugins/database/tab_bar/desktop/tab_bar_header.dart';
+import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/checkbox.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart';
@@ -76,6 +70,8 @@ import 'package:appflowy/plugins/database/widgets/setting/database_setting_actio
import 'package:appflowy/plugins/database/widgets/setting/database_settings_list.dart';
import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart';
import 'package:appflowy/plugins/database/widgets/setting/setting_property_list.dart';
+import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
+import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/util/field_type_extension.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/clear_date_button.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_type_option_button.dart';
@@ -90,6 +86,9 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/text_input.dart';
import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
// Non-exported member of the table_calendar library
@@ -943,6 +942,31 @@ extension AppFlowyDatabaseTest on WidgetTester {
await pumpAndSettle(const Duration(milliseconds: 200));
}
+ Future changeFieldWidth(String fieldName, double width) async {
+ final field = find.byWidgetPredicate(
+ (widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName,
+ );
+ await hoverOnWidget(
+ field,
+ onHover: () async {
+ final dragHandle = find.descendant(
+ of: field,
+ matching: find.byType(DragToExpandLine),
+ );
+ await drag(dragHandle, Offset(width - getSize(field).width, 0));
+ await pumpAndSettle(const Duration(milliseconds: 200));
+ },
+ );
+ }
+
+ double getFieldWidth(String fieldName) {
+ final field = find.byWidgetPredicate(
+ (widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName,
+ );
+
+ return getSize(field).width;
+ }
+
Future findDateEditor(dynamic matcher) async {
final finder = find.byType(DateCellEditor);
expect(finder, matcher);
@@ -1464,6 +1488,7 @@ extension AppFlowyDatabaseTest on WidgetTester {
);
await tapButton(button);
+ await tapButtonWithName(LocaleKeys.button_delete.tr());
}
Future dragDropRescheduleCalendarEvent() async {
@@ -1571,7 +1596,7 @@ extension AppFlowyDatabaseTest on WidgetTester {
of: textField,
matching: find.byWidgetPredicate(
(widget) =>
- widget is FlowySvg && widget.svg == FlowySvgs.close_filled_m,
+ widget is FlowySvg && widget.svg == FlowySvgs.close_filled_s,
),
),
);
diff --git a/frontend/appflowy_flutter/integration_test/shared/document_test_operations.dart b/frontend/appflowy_flutter/integration_test/shared/document_test_operations.dart
index 491ac9432c..398a3f9657 100644
--- a/frontend/appflowy_flutter/integration_test/shared/document_test_operations.dart
+++ b/frontend/appflowy_flutter/integration_test/shared/document_test_operations.dart
@@ -307,9 +307,11 @@ class EditorOperations {
Future openTurnIntoMenu(Path path) async {
await hoverAndClickOptionMenuButton(path);
await tester.tapButton(
- find.findTextInFlowyText(
- LocaleKeys.document_plugins_optionAction_turnInto.tr(),
- ),
+ find
+ .findTextInFlowyText(
+ LocaleKeys.document_plugins_optionAction_turnInto.tr(),
+ )
+ .first,
);
await tester.pumpUntilFound(find.byType(TurnIntoOptionMenu));
}
diff --git a/frontend/appflowy_flutter/integration_test/shared/emoji.dart b/frontend/appflowy_flutter/integration_test/shared/emoji.dart
index 0f45098b23..cccd00a3f6 100644
--- a/frontend/appflowy_flutter/integration_test/shared/emoji.dart
+++ b/frontend/appflowy_flutter/integration_test/shared/emoji.dart
@@ -1,20 +1,24 @@
import 'dart:convert';
+import 'dart:io';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_color_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_uploader.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
+import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart';
-import 'package:cross_file/cross_file.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flowy_infra_ui/style_widget/primary_rounded_button.dart';
import 'package:flowy_svg/flowy_svg.dart';
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
import 'package:flutter_test/flutter_test.dart';
import 'base.dart';
+import 'common_operations.dart';
extension EmojiTestExtension on WidgetTester {
Future tapEmoji(String emoji) async {
@@ -90,7 +94,7 @@ extension EmojiTestExtension on WidgetTester {
final dropTargetWidget = dropTarget.evaluate().first.widget as DropTarget;
dropTargetWidget.onDragDone?.call(
DropDoneDetails(
- files: [XFile(icon.emoji)],
+ files: [DropItemFile(icon.emoji)],
localPosition: Offset.zero,
globalPosition: Offset.zero,
),
@@ -104,4 +108,37 @@ extension EmojiTestExtension on WidgetTester {
);
await tapButton(confirmButton);
}
+
+ Future pasteImageLinkAsIcon(String link) async {
+ final pickTab = find.byType(PickerTab);
+ expect(pickTab, findsOneWidget);
+ await pumpAndSettle();
+
+ /// switch to custom tab
+ final iconTab = find.descendant(
+ of: pickTab,
+ matching: find.text(PickerTabType.custom.tr),
+ );
+ expect(iconTab, findsOneWidget);
+ await tapButton(iconTab);
+
+ // mock the clipboard
+ await getIt()
+ .setData(ClipboardServiceData(plainText: link));
+
+ // paste the link
+ await simulateKeyEvent(
+ LogicalKeyboardKey.keyV,
+ isControlPressed: Platform.isLinux || Platform.isWindows,
+ isMetaPressed: Platform.isMacOS,
+ );
+ await pumpAndSettle(const Duration(seconds: 5));
+
+ /// confirm to upload
+ final confirmButton = find.descendant(
+ of: find.byType(IconUploader),
+ matching: find.byType(PrimaryRoundedButton),
+ );
+ await tapButton(confirmButton);
+ }
}
diff --git a/frontend/appflowy_flutter/integration_test/shared/expectation.dart b/frontend/appflowy_flutter/integration_test/shared/expectation.dart
index 7e9fe4bc38..3b9ef0d75c 100644
--- a/frontend/appflowy_flutter/integration_test/shared/expectation.dart
+++ b/frontend/appflowy_flutter/integration_test/shared/expectation.dart
@@ -8,6 +8,7 @@ import 'package:appflowy/plugins/document/presentation/banner.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
+import 'package:appflowy/shared/appflowy_network_svg.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
@@ -252,16 +253,19 @@ extension Expectation on WidgetTester {
);
expect(icon, findsOneWidget);
} else if (type == FlowyIconType.custom) {
+ final isSvg = data.emoji.endsWith('.svg');
if (isURL(data.emoji)) {
final image = find.descendant(
of: pageName,
- matching: find.byType(FlowyNetworkImage),
+ matching: isSvg
+ ? find.byType(FlowyNetworkSvg)
+ : find.byType(FlowyNetworkImage),
);
expect(image, findsOneWidget);
} else {
final image = find.descendant(
of: pageName,
- matching: find.byType(Image),
+ matching: isSvg ? find.byType(SvgPicture) : find.byType(Image),
);
expect(image, findsOneWidget);
}
@@ -290,16 +294,26 @@ extension Expectation on WidgetTester {
);
expect(icon, findsOneWidget);
} else if (type == FlowyIconType.custom) {
+ final isSvg = data.emoji.endsWith('.svg');
if (isURL(data.emoji)) {
final image = find.descendant(
of: find.byType(ViewTitleBar),
- matching: find.byType(FlowyNetworkImage),
+ matching: isSvg
+ ? find.byType(FlowyNetworkSvg)
+ : find.byType(FlowyNetworkImage),
);
expect(image, findsOneWidget);
} else {
final image = find.descendant(
of: find.byType(ViewTitleBar),
- matching: find.byType(Image),
+ matching: isSvg
+ ? find.byWidgetPredicate((w) {
+ if (w is! SvgPicture) return false;
+ final loader = w.bytesLoader;
+ if (loader is! SvgFileLoader) return false;
+ return loader.file.path.endsWith('.svg');
+ })
+ : find.byType(Image),
);
expect(image, findsOneWidget);
}
diff --git a/frontend/appflowy_flutter/integration_test/shared/mock/mock_openai_repository.dart b/frontend/appflowy_flutter/integration_test/shared/mock/mock_openai_repository.dart
deleted file mode 100644
index ec1891dea8..0000000000
--- a/frontend/appflowy_flutter/integration_test/shared/mock/mock_openai_repository.dart
+++ /dev/null
@@ -1,81 +0,0 @@
-import 'dart:async';
-import 'dart:convert';
-
-import 'package:appflowy/ai/service/error.dart';
-import 'package:appflowy/ai/service/openai_client.dart';
-import 'package:appflowy/ai/service/text_completion.dart';
-import 'package:http/http.dart' as http;
-import 'package:mocktail/mocktail.dart';
-
-class MyMockClient extends Mock implements http.Client {
- @override
- Future send(http.BaseRequest request) async {
- final requestType = request.method;
- final requestUri = request.url;
-
- if (requestType == 'POST' &&
- requestUri == OpenAIRequestType.textCompletion.uri) {
- final responseHeaders = {
- 'content-type': 'text/event-stream',
- };
- final responseBody = Stream.fromIterable([
- utf8.encode(
- '{ "choices": [{"text": "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula ", "index": 0, "logprobs": null, "finish_reason": null}]}',
- ),
- utf8.encode('\n'),
- utf8.encode('[DONE]'),
- ]);
-
- // Return a mocked response with the expected data
- return http.StreamedResponse(responseBody, 200, headers: responseHeaders);
- }
-
- // Return an error response for any other request
- return http.StreamedResponse(const Stream.empty(), 404);
- }
-}
-
-class MockOpenAIRepository extends HttpOpenAIRepository {
- MockOpenAIRepository() : super(apiKey: 'dummyKey', client: MyMockClient());
-
- @override
- Future getStreamedCompletions({
- required String prompt,
- required Future Function() onStart,
- required Future Function(TextCompletionResponse response) onProcess,
- required Future Function() onEnd,
- required void Function(AIError error) onError,
- String? suffix,
- int maxTokens = 2048,
- double temperature = 0.3,
- bool useAction = false,
- }) async {
- final request = http.Request('POST', OpenAIRequestType.textCompletion.uri);
- final response = await client.send(request);
-
- String previousSyntax = '';
- if (response.statusCode == 200) {
- await for (final chunk in response.stream
- .transform(const Utf8Decoder())
- .transform(const LineSplitter())) {
- await onStart();
- final data = chunk.trim().split('data: ');
- if (data[0] != '[DONE]') {
- final response = TextCompletionResponse.fromJson(
- json.decode(data[0]),
- );
- if (response.choices.isNotEmpty) {
- final text = response.choices.first.text;
- if (text == previousSyntax && text == '\n') {
- continue;
- }
- await onProcess(response);
- previousSyntax = response.choices.first.text;
- }
- } else {
- await onEnd();
- }
- }
- }
- }
-}
diff --git a/frontend/appflowy_flutter/integration_test/shared/settings.dart b/frontend/appflowy_flutter/integration_test/shared/settings.dart
index aade7bb4c9..bfc5efedde 100644
--- a/frontend/appflowy_flutter/integration_test/shared/settings.dart
+++ b/frontend/appflowy_flutter/integration_test/shared/settings.dart
@@ -79,7 +79,7 @@ extension AppFlowySettings on WidgetTester {
// Enable editing username
final editUsernameFinder = find.descendant(
of: find.byType(AccountUserProfile),
- matching: find.byFlowySvg(FlowySvgs.edit_s),
+ matching: find.byFlowySvg(FlowySvgs.toolbar_link_edit_m),
);
await tap(editUsernameFinder, warnIfMissed: false);
await pumpAndSettle();
diff --git a/frontend/appflowy_flutter/ios/Podfile.lock b/frontend/appflowy_flutter/ios/Podfile.lock
index e4e87805cd..4b7ed5d639 100644
--- a/frontend/appflowy_flutter/ios/Podfile.lock
+++ b/frontend/appflowy_flutter/ios/Podfile.lock
@@ -1,5 +1,5 @@
PODS:
- - app_links (0.0.1):
+ - app_links (0.0.2):
- Flutter
- appflowy_backend (0.0.1):
- Flutter
@@ -66,6 +66,8 @@ PODS:
- permission_handler_apple (9.3.0):
- Flutter
- ReachabilitySwift (5.0.0)
+ - saver_gallery (0.0.1):
+ - Flutter
- SDWebImage (5.14.2):
- SDWebImage/Core (= 5.14.2)
- SDWebImage/Core (5.14.2)
@@ -79,7 +81,7 @@ PODS:
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- - sqflite (0.0.3):
+ - sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- super_native_extensions (0.0.1):
@@ -90,6 +92,7 @@ PODS:
- Flutter
- webview_flutter_wkwebview (0.0.1):
- Flutter
+ - FlutterMacOS
DEPENDENCIES:
- app_links (from `.symlinks/plugins/app_links/ios`)
@@ -108,13 +111,14 @@ DEPENDENCIES:
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
+ - saver_gallery (from `.symlinks/plugins/saver_gallery/ios`)
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- - sqflite (from `.symlinks/plugins/sqflite/darwin`)
+ - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
+ - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
SPEC REPOS:
trunk:
@@ -159,23 +163,25 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
+ saver_gallery:
+ :path: ".symlinks/plugins/saver_gallery/ios"
sentry_flutter:
:path: ".symlinks/plugins/sentry_flutter/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
- sqflite:
- :path: ".symlinks/plugins/sqflite/darwin"
+ sqflite_darwin:
+ :path: ".symlinks/plugins/sqflite_darwin/darwin"
super_native_extensions:
:path: ".symlinks/plugins/super_native_extensions/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
webview_flutter_wkwebview:
- :path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
+ :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS:
- app_links: c5161ac5ab5383ad046884568b4b91cb52df5d91
+ app_links: 3da4c36b46cac3bf24eb897f1a6ce80bda109874
appflowy_backend: 78f6a053f756e6bc29bcc5a2106cbe77b756e97a
connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf
device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896
@@ -186,7 +192,7 @@ SPEC CHECKSUMS:
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
fluttertoast: 76fea30fcf04176325f6864c87306927bd7d2038
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
- integration_test: d5929033778cc4991a187e4e1a85396fa4f59b3a
+ integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
keyboard_height_plugin: ef70a8181b29f27670e9e2450814ca6b6dc05b05
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
@@ -194,17 +200,18 @@ SPEC CHECKSUMS:
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
+ saver_gallery: af2d0c762dafda254e0ad025ef0dabd6506cd490
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
- sqflite: c35dad70033b8862124f8337cc994a809fcd9fa3
+ sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
- webview_flutter_wkwebview: 45a041c7831641076618876de3ba75c712860c6b
+ webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca
diff --git a/frontend/appflowy_flutter/ios/Runner/AppDelegate.swift b/frontend/appflowy_flutter/ios/Runner/AppDelegate.swift
index 70693e4a8c..b636303481 100644
--- a/frontend/appflowy_flutter/ios/Runner/AppDelegate.swift
+++ b/frontend/appflowy_flutter/ios/Runner/AppDelegate.swift
@@ -1,7 +1,7 @@
import UIKit
import Flutter
-@UIApplicationMain
+@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
diff --git a/frontend/appflowy_flutter/ios/Runner/Info.plist b/frontend/appflowy_flutter/ios/Runner/Info.plist
index d1afb2a9db..5d6a52bd2e 100644
--- a/frontend/appflowy_flutter/ios/Runner/Info.plist
+++ b/frontend/appflowy_flutter/ios/Runner/Info.plist
@@ -1,75 +1,78 @@
-
- CADisableMinimumFrameDurationOnPhone
-
- CFBundleDevelopmentRegion
- $(DEVELOPMENT_LANGUAGE)
- CFBundleExecutable
- $(EXECUTABLE_NAME)
- CFBundleIdentifier
- $(PRODUCT_BUNDLE_IDENTIFIER)
- CFBundleInfoDictionaryVersion
- 6.0
- CFBundleLocalizations
-
- en
-
- CFBundleName
- AppFlowy
- CFBundlePackageType
- APPL
- CFBundleShortVersionString
- $(FLUTTER_BUILD_NAME)
- CFBundleSignature
- ????
- CFBundleURLTypes
-
-
- CFBundleURLName
-
- CFBundleURLSchemes
-
- appflowy-flutter
-
-
-
- CFBundleVersion
- $(FLUTTER_BUILD_NUMBER)
- FLTEnableImpeller
-
- LSRequiresIPhoneOS
-
- NSAppTransportSecurity
- NSAllowsArbitraryLoads
-
+ CADisableMinimumFrameDurationOnPhone
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleLocalizations
+
+ en
+
+ CFBundleName
+ AppFlowy
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+
+ CFBundleURLSchemes
+
+ appflowy-flutter
+
+
+
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ FLTEnableImpeller
+
+ LSRequiresIPhoneOS
+
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
+ NSPhotoLibraryUsageDescription
+ AppFlowy needs access to your photos to let you add images to your documents
+ NSPhotoLibraryAddUsageDescription
+ AppFlowy needs access to your photos to let you add images to your photo library
+ UIApplicationSupportsIndirectInputEvents
+
+ NSCameraUsageDescription
+ AppFlowy needs access to your camera to let you add images to your documents from
+ camera
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+
+ UISupportsDocumentBrowser
+
+ UIViewControllerBasedStatusBarAppearance
+
- NSPhotoLibraryUsageDescription
- AppFlowy needs access to your photos to let you add images to your documents
- UIApplicationSupportsIndirectInputEvents
-
- NSCameraUsageDescription
- AppFlowy needs access to your camera to let you add images to your documents from camera
- UILaunchStoryboardName
- LaunchScreen
- UIMainStoryboardFile
- Main
- UISupportedInterfaceOrientations
-
- UIInterfaceOrientationPortrait
-
- UISupportedInterfaceOrientations~ipad
-
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
-
- UISupportsDocumentBrowser
-
- UIViewControllerBasedStatusBarAppearance
-
-
-
+
\ No newline at end of file
diff --git a/frontend/appflowy_flutter/lib/ai/ai.dart b/frontend/appflowy_flutter/lib/ai/ai.dart
index a6fa49ef68..9bfeeb4e00 100644
--- a/frontend/appflowy_flutter/lib/ai/ai.dart
+++ b/frontend/appflowy_flutter/lib/ai/ai.dart
@@ -1,6 +1,12 @@
+export 'service/ai_entities.dart';
+export 'service/ai_prompt_input_bloc.dart';
+export 'service/appflowy_ai_service.dart';
+export 'service/error.dart';
+export 'service/ai_model_state_notifier.dart';
+export 'service/select_model_bloc.dart';
export 'widgets/loading_indicator.dart';
export 'widgets/prompt_input/action_buttons.dart';
-export 'widgets/prompt_input/desktop_input_text_field.dart';
+export 'widgets/prompt_input/desktop_prompt_text_field.dart';
export 'widgets/prompt_input/file_attachment_list.dart';
export 'widgets/prompt_input/layout_define.dart';
export 'widgets/prompt_input/mention_page_bottom_sheet.dart';
@@ -9,4 +15,5 @@ export 'widgets/prompt_input/mentioned_page_text_span.dart';
export 'widgets/prompt_input/predefined_format_buttons.dart';
export 'widgets/prompt_input/select_sources_bottom_sheet.dart';
export 'widgets/prompt_input/select_sources_menu.dart';
+export 'widgets/prompt_input/select_model_menu.dart';
export 'widgets/prompt_input/send_button.dart';
diff --git a/frontend/appflowy_flutter/lib/ai/service/ai_client.dart b/frontend/appflowy_flutter/lib/ai/service/ai_client.dart
deleted file mode 100644
index a5c40c0569..0000000000
--- a/frontend/appflowy_flutter/lib/ai/service/ai_client.dart
+++ /dev/null
@@ -1,34 +0,0 @@
-import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
-import 'package:appflowy_result/appflowy_result.dart';
-
-import 'error.dart';
-import 'text_completion.dart';
-
-abstract class AIRepository {
- Future getStreamedCompletions({
- required String prompt,
- required Future Function() onStart,
- required Future Function(TextCompletionResponse response) onProcess,
- required Future Function() onEnd,
- required void Function(AIError error) onError,
- String? suffix,
- int maxTokens = 2048,
- double temperature = 0.3,
- bool useAction = false,
- });
-
- Future streamCompletion({
- String? objectId,
- required String text,
- required CompletionTypePB completionType,
- required Future Function() onStart,
- required Future Function(String text) onProcess,
- required Future Function() onEnd,
- required void Function(AIError error) onError,
- });
-
- Future, AIError>> generateImage({
- required String prompt,
- int n = 1,
- });
-}
diff --git a/frontend/appflowy_flutter/lib/ai/service/ai_entities.dart b/frontend/appflowy_flutter/lib/ai/service/ai_entities.dart
new file mode 100644
index 0000000000..b08fadb7f8
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/ai/service/ai_entities.dart
@@ -0,0 +1,107 @@
+import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:equatable/equatable.dart';
+
+class AIStreamEventPrefix {
+ static const data = 'data:';
+ static const error = 'error:';
+ static const metadata = 'metadata:';
+ static const start = 'start:';
+ static const finish = 'finish:';
+ static const comment = 'comment:';
+ static const aiResponseLimit = 'AI_RESPONSE_LIMIT';
+ static const aiImageResponseLimit = 'AI_IMAGE_RESPONSE_LIMIT';
+ static const aiMaxRequired = 'AI_MAX_REQUIRED:';
+ static const localAINotReady = 'LOCAL_AI_NOT_READY';
+ static const localAIDisabled = 'LOCAL_AI_DISABLED';
+}
+
+enum AiType {
+ cloud,
+ local;
+
+ bool get isCloud => this == cloud;
+ bool get isLocal => this == local;
+}
+
+class PredefinedFormat extends Equatable {
+ const PredefinedFormat({
+ required this.imageFormat,
+ required this.textFormat,
+ });
+
+ final ImageFormat imageFormat;
+ final TextFormat? textFormat;
+
+ PredefinedFormatPB toPB() {
+ return PredefinedFormatPB(
+ imageFormat: switch (imageFormat) {
+ ImageFormat.text => ResponseImageFormatPB.TextOnly,
+ ImageFormat.image => ResponseImageFormatPB.ImageOnly,
+ ImageFormat.textAndImage => ResponseImageFormatPB.TextAndImage,
+ },
+ textFormat: switch (textFormat) {
+ TextFormat.paragraph => ResponseTextFormatPB.Paragraph,
+ TextFormat.bulletList => ResponseTextFormatPB.BulletedList,
+ TextFormat.numberedList => ResponseTextFormatPB.NumberedList,
+ TextFormat.table => ResponseTextFormatPB.Table,
+ _ => null,
+ },
+ );
+ }
+
+ @override
+ List get props => [imageFormat, textFormat];
+}
+
+enum ImageFormat {
+ text,
+ image,
+ textAndImage;
+
+ bool get hasText => this == text || this == textAndImage;
+
+ FlowySvgData get icon {
+ return switch (this) {
+ ImageFormat.text => FlowySvgs.ai_text_s,
+ ImageFormat.image => FlowySvgs.ai_image_s,
+ ImageFormat.textAndImage => FlowySvgs.ai_text_image_s,
+ };
+ }
+
+ String get i18n {
+ return switch (this) {
+ ImageFormat.text => LocaleKeys.chat_changeFormat_textOnly.tr(),
+ ImageFormat.image => LocaleKeys.chat_changeFormat_imageOnly.tr(),
+ ImageFormat.textAndImage =>
+ LocaleKeys.chat_changeFormat_textAndImage.tr(),
+ };
+ }
+}
+
+enum TextFormat {
+ paragraph,
+ bulletList,
+ numberedList,
+ table;
+
+ FlowySvgData get icon {
+ return switch (this) {
+ TextFormat.paragraph => FlowySvgs.ai_paragraph_s,
+ TextFormat.bulletList => FlowySvgs.ai_list_s,
+ TextFormat.numberedList => FlowySvgs.ai_number_list_s,
+ TextFormat.table => FlowySvgs.ai_table_s,
+ };
+ }
+
+ String get i18n {
+ return switch (this) {
+ TextFormat.paragraph => LocaleKeys.chat_changeFormat_text.tr(),
+ TextFormat.bulletList => LocaleKeys.chat_changeFormat_bullet.tr(),
+ TextFormat.numberedList => LocaleKeys.chat_changeFormat_number.tr(),
+ TextFormat.table => LocaleKeys.chat_changeFormat_table.tr(),
+ };
+ }
+}
diff --git a/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart b/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart
new file mode 100644
index 0000000000..0bcc41da9b
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart
@@ -0,0 +1,181 @@
+import 'package:appflowy/ai/ai.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/plugins/ai_chat/application/ai_model_switch_listener.dart';
+import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
+import 'package:appflowy_backend/dispatch/dispatch.dart';
+import 'package:appflowy_backend/log.dart';
+import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
+import 'package:appflowy_result/appflowy_result.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:protobuf/protobuf.dart';
+import 'package:universal_platform/universal_platform.dart';
+
+typedef OnModelStateChangedCallback = void Function(AiType, bool, String);
+typedef OnAvailableModelsChangedCallback = void Function(
+ List,
+ AIModelPB?,
+);
+
+class AIModelStateNotifier {
+ AIModelStateNotifier({required this.objectId})
+ : _localAIListener =
+ UniversalPlatform.isDesktop ? LocalAIStateListener() : null,
+ _aiModelSwitchListener = AIModelSwitchListener(objectId: objectId) {
+ _startListening();
+ _init();
+ }
+
+ final String objectId;
+ final LocalAIStateListener? _localAIListener;
+ final AIModelSwitchListener _aiModelSwitchListener;
+ LocalAIPB? _localAIState;
+ AvailableModelsPB? _availableModels;
+
+ // callbacks
+ final List _stateChangedCallbacks = [];
+ final List
+ _availableModelsChangedCallbacks = [];
+
+ void _startListening() {
+ if (UniversalPlatform.isDesktop) {
+ _localAIListener?.start(
+ stateCallback: (state) async {
+ _localAIState = state;
+ _notifyStateChanged();
+
+ if (state.state == RunningStatePB.Running ||
+ state.state == RunningStatePB.Stopped) {
+ await _loadAvailableModels();
+ _notifyAvailableModelsChanged();
+ }
+ },
+ );
+ }
+
+ _aiModelSwitchListener.start(
+ onUpdateSelectedModel: (model) async {
+ final updatedModels = _availableModels?.deepCopy()
+ ?..selectedModel = model;
+ _availableModels = updatedModels;
+ _notifyAvailableModelsChanged();
+
+ if (model.isLocal && UniversalPlatform.isDesktop) {
+ await _loadLocalAiState();
+ }
+ _notifyStateChanged();
+ },
+ );
+ }
+
+ void _init() async {
+ await Future.wait([_loadLocalAiState(), _loadAvailableModels()]);
+ _notifyStateChanged();
+ _notifyAvailableModelsChanged();
+ }
+
+ void addListener({
+ OnModelStateChangedCallback? onStateChanged,
+ OnAvailableModelsChangedCallback? onAvailableModelsChanged,
+ }) {
+ if (onStateChanged != null) {
+ _stateChangedCallbacks.add(onStateChanged);
+ }
+ if (onAvailableModelsChanged != null) {
+ _availableModelsChangedCallbacks.add(onAvailableModelsChanged);
+ }
+ }
+
+ void removeListener({
+ OnModelStateChangedCallback? onStateChanged,
+ OnAvailableModelsChangedCallback? onAvailableModelsChanged,
+ }) {
+ if (onStateChanged != null) {
+ _stateChangedCallbacks.remove(onStateChanged);
+ }
+ if (onAvailableModelsChanged != null) {
+ _availableModelsChangedCallbacks.remove(onAvailableModelsChanged);
+ }
+ }
+
+ Future dispose() async {
+ _stateChangedCallbacks.clear();
+ _availableModelsChangedCallbacks.clear();
+ await _localAIListener?.stop();
+ await _aiModelSwitchListener.stop();
+ }
+
+ (AiType, String, bool) getState() {
+ if (UniversalPlatform.isMobile) {
+ return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
+ }
+
+ final availableModels = _availableModels;
+ final localAiState = _localAIState;
+
+ if (availableModels == null) {
+ return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
+ }
+ if (localAiState == null) {
+ Log.warn("Cannot get local AI state");
+ return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
+ }
+
+ if (!availableModels.selectedModel.isLocal) {
+ return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
+ }
+
+ final editable = localAiState.state == RunningStatePB.Running;
+ final hintText = editable
+ ? LocaleKeys.chat_inputLocalAIMessageHint.tr()
+ : LocaleKeys.settings_aiPage_keys_localAIInitializing.tr();
+
+ return (AiType.local, hintText, editable);
+ }
+
+ (List, AIModelPB?) getAvailableModels() {
+ final availableModels = _availableModels;
+ if (availableModels == null) {
+ return ([], null);
+ }
+ return (availableModels.models, availableModels.selectedModel);
+ }
+
+ void _notifyAvailableModelsChanged() {
+ final (models, selectedModel) = getAvailableModels();
+ for (final callback in _availableModelsChangedCallbacks) {
+ callback(models, selectedModel);
+ }
+ }
+
+ void _notifyStateChanged() {
+ final (type, hintText, isEditable) = getState();
+ for (final callback in _stateChangedCallbacks) {
+ callback(type, isEditable, hintText);
+ }
+ }
+
+ Future _loadAvailableModels() {
+ final payload = AvailableModelsQueryPB(source: objectId);
+ return AIEventGetAvailableModels(payload).send().fold(
+ (models) => _availableModels = models,
+ (err) => Log.error("Failed to get available models: $err"),
+ );
+ }
+
+ Future _loadLocalAiState() {
+ return AIEventGetLocalAIState().send().fold(
+ (localAIState) => _localAIState = localAIState,
+ (error) => Log.error("Failed to get local AI state: $error"),
+ );
+ }
+}
+
+extension AiModelExtension on AIModelPB {
+ bool get isDefault {
+ return name == "Auto";
+ }
+
+ String get i18n {
+ return isDefault ? LocaleKeys.chat_switchModel_autoModel.tr() : name;
+ }
+}
diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_prompt_input_bloc.dart b/frontend/appflowy_flutter/lib/ai/service/ai_prompt_input_bloc.dart
similarity index 54%
rename from frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_prompt_input_bloc.dart
rename to frontend/appflowy_flutter/lib/ai/service/ai_prompt_input_bloc.dart
index fdbcbb4cc0..95854ab047 100644
--- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_prompt_input_bloc.dart
+++ b/frontend/appflowy_flutter/lib/ai/service/ai_prompt_input_bloc.dart
@@ -1,31 +1,31 @@
import 'dart:async';
+import 'package:appflowy/ai/service/ai_model_state_notifier.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
-import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
-import 'package:appflowy_backend/dispatch/dispatch.dart';
-import 'package:appflowy_backend/log.dart';
-import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
-import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
+import 'ai_entities.dart';
+
part 'ai_prompt_input_bloc.freezed.dart';
class AIPromptInputBloc extends Bloc {
- AIPromptInputBloc()
- : _listener = LocalLLMListener(),
- super(AIPromptInputState.initial()) {
+ AIPromptInputBloc({
+ required String objectId,
+ required PredefinedFormat? predefinedFormat,
+ }) : aiModelStateNotifier = AIModelStateNotifier(objectId: objectId),
+ super(AIPromptInputState.initial(predefinedFormat)) {
_dispatch();
_startListening();
_init();
}
- final LocalLLMListener _listener;
+ final AIModelStateNotifier aiModelStateNotifier;
@override
Future close() async {
- await _listener.stop();
+ await aiModelStateNotifier.dispose();
return super.close();
}
@@ -33,38 +33,37 @@ class AIPromptInputBloc extends Bloc {
on(
(event, emit) {
event.when(
- updateChatState: (LocalAIChatPB chatState) {
- // Only user enable chat with file and the plugin is already running
- final supportChatWithFile = chatState.fileEnabled &&
- chatState.pluginState.state == RunningStatePB.Running;
-
- final aiType = chatState.pluginState.state == RunningStatePB.Running
- ? AIType.localAI
- : AIType.appflowyAI;
+ updateAIState: (aiType, editable, hintText) {
emit(
state.copyWith(
aiType: aiType,
- supportChatWithFile: supportChatWithFile,
- chatState: chatState,
+ editable: editable,
+ hintText: hintText,
),
);
},
- updatePluginState: (LocalAIPluginStatePB chatState) {
- final fileEnabled = state.chatState?.fileEnabled ?? false;
- final supportChatWithFile =
- fileEnabled && chatState.state == RunningStatePB.Running;
-
- final aiType = chatState.state == RunningStatePB.Running
- ? AIType.localAI
- : AIType.appflowyAI;
-
+ toggleShowPredefinedFormat: () {
+ final showPredefinedFormats = !state.showPredefinedFormats;
+ final predefinedFormat =
+ showPredefinedFormats && state.predefinedFormat == null
+ ? PredefinedFormat(
+ imageFormat: ImageFormat.text,
+ textFormat: TextFormat.paragraph,
+ )
+ : null;
emit(
state.copyWith(
- supportChatWithFile: supportChatWithFile,
- aiType: aiType,
+ showPredefinedFormats: showPredefinedFormats,
+ predefinedFormat: predefinedFormat,
),
);
},
+ updatePredefinedFormat: (format) {
+ if (!state.showPredefinedFormats) {
+ return;
+ }
+ emit(state.copyWith(predefinedFormat: format));
+ },
attachFile: (filePath, fileName) {
final newFile = ChatFile.fromFilePath(filePath);
if (newFile != null) {
@@ -105,29 +104,16 @@ class AIPromptInputBloc extends Bloc {
}
void _startListening() {
- _listener.start(
- stateCallback: (pluginState) {
- if (!isClosed) {
- add(AIPromptInputEvent.updatePluginState(pluginState));
- }
- },
- chatStateCallback: (chatState) {
- if (!isClosed) {
- add(AIPromptInputEvent.updateChatState(chatState));
- }
+ aiModelStateNotifier.addListener(
+ onStateChanged: (aiType, editable, hintText) {
+ add(AIPromptInputEvent.updateAIState(aiType, editable, hintText));
},
);
}
void _init() {
- AIEventGetLocalAIChatState().send().fold(
- (chatState) {
- if (!isClosed) {
- add(AIPromptInputEvent.updateChatState(chatState));
- }
- },
- Log.error,
- );
+ final (aiType, hintText, isEditable) = aiModelStateNotifier.getState();
+ add(AIPromptInputEvent.updateAIState(aiType, isEditable, hintText));
}
Map consumeMetadata() {
@@ -146,12 +132,17 @@ class AIPromptInputBloc extends Bloc {
@freezed
class AIPromptInputEvent with _$AIPromptInputEvent {
- const factory AIPromptInputEvent.updateChatState(
- LocalAIChatPB chatState,
- ) = _UpdateChatState;
- const factory AIPromptInputEvent.updatePluginState(
- LocalAIPluginStatePB chatState,
- ) = _UpdatePluginState;
+ const factory AIPromptInputEvent.updateAIState(
+ AiType aiType,
+ bool editable,
+ String hintText,
+ ) = _UpdateAIState;
+
+ const factory AIPromptInputEvent.toggleShowPredefinedFormat() =
+ _ToggleShowPredefinedFormat;
+ const factory AIPromptInputEvent.updatePredefinedFormat(
+ PredefinedFormat format,
+ ) = _UpdatePredefinedFormat;
const factory AIPromptInputEvent.attachFile(
String filePath,
String fileName,
@@ -165,25 +156,25 @@ class AIPromptInputEvent with _$AIPromptInputEvent {
@freezed
class AIPromptInputState with _$AIPromptInputState {
const factory AIPromptInputState({
- required AIType aiType,
+ required AiType aiType,
required bool supportChatWithFile,
- required LocalAIChatPB? chatState,
+ required bool showPredefinedFormats,
+ required PredefinedFormat? predefinedFormat,
required List attachedFiles,
required List mentionedPages,
+ required bool editable,
+ required String hintText,
}) = _AIPromptInputState;
- factory AIPromptInputState.initial() => const AIPromptInputState(
- aiType: AIType.appflowyAI,
+ factory AIPromptInputState.initial(PredefinedFormat? format) =>
+ AIPromptInputState(
+ aiType: AiType.cloud,
supportChatWithFile: false,
- chatState: null,
+ showPredefinedFormats: format != null,
+ predefinedFormat: format,
attachedFiles: [],
mentionedPages: [],
+ editable: true,
+ hintText: '',
);
}
-
-enum AIType {
- appflowyAI,
- localAI;
-
- bool get isLocalAI => this == localAI;
-}
diff --git a/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart b/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart
index 560d6d3ef1..39487652f8 100644
--- a/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart
+++ b/frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart
@@ -3,184 +3,202 @@ import 'dart:ffi';
import 'dart:isolate';
import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart';
+import 'package:appflowy/shared/list_extension.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
+import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:fixnum/fixnum.dart' as fixnum;
-import 'ai_client.dart';
+import 'ai_entities.dart';
import 'error.dart';
-import 'text_completion.dart';
-enum AskAIAction {
- summarize,
- fixSpelling,
- improveWriting,
- makeItLonger;
+enum LocalAIStreamingState {
+ notReady,
+ disabled,
+}
- String get toInstruction => switch (this) {
- summarize => 'Tl;dr',
- fixSpelling => 'Correct this to standard English:',
- improveWriting => 'Rewrite this in your own words:',
- makeItLonger => 'Make this text longer:',
- };
-
- String prompt(String input) => switch (this) {
- summarize => '$input\n\n$toInstruction',
- _ => "$toInstruction\n\n$input",
- };
-
- static AskAIAction from(int index) => switch (index) {
- 0 => summarize,
- 1 => fixSpelling,
- 2 => improveWriting,
- 3 => makeItLonger,
- _ => fixSpelling
- };
-
- String get name => switch (this) {
- summarize => LocaleKeys.document_plugins_smartEditSummarize.tr(),
- fixSpelling => LocaleKeys.document_plugins_smartEditFixSpelling.tr(),
- improveWriting =>
- LocaleKeys.document_plugins_smartEditImproveWriting.tr(),
- makeItLonger => LocaleKeys.document_plugins_smartEditMakeLonger.tr(),
- };
+abstract class AIRepository {
+ Future streamCompletion({
+ String? objectId,
+ required String text,
+ PredefinedFormat? format,
+ List sourceIds = const [],
+ List history = const [],
+ required CompletionTypePB completionType,
+ required Future Function() onStart,
+ required Future Function(String text) processMessage,
+ required Future Function(String text) processAssistMessage,
+ required Future Function() onEnd,
+ required void Function(AIError error) onError,
+ required void Function(LocalAIStreamingState state)
+ onLocalAIStreamingStateChange,
+ });
}
class AppFlowyAIService implements AIRepository {
@override
- Future, AIError>> generateImage({
- required String prompt,
- int n = 1,
- }) {
- throw UnimplementedError();
- }
-
- @override
- Future getStreamedCompletions({
- required String prompt,
- required Future Function() onStart,
- required Future Function(TextCompletionResponse response) onProcess,
- required Future Function() onEnd,
- required void Function(AIError error) onError,
- String? suffix,
- int maxTokens = 2048,
- double temperature = 0.3,
- bool useAction = false,
- }) {
- throw UnimplementedError();
- }
-
- @override
- Future streamCompletion({
+ Future<(String, CompletionStream)?> streamCompletion({
String? objectId,
required String text,
+ PredefinedFormat? format,
+ List sourceIds = const [],
+ List history = const [],
required CompletionTypePB completionType,
required Future Function() onStart,
- required Future Function(String text) onProcess,
+ required Future Function(String text) processMessage,
+ required Future Function(String text) processAssistMessage,
required Future Function() onEnd,
required void Function(AIError error) onError,
+ required void Function(LocalAIStreamingState state)
+ onLocalAIStreamingStateChange,
}) async {
- final stream = CompletionStream(
- onStart,
- onProcess,
- onEnd,
- onError,
+ final stream = AppFlowyCompletionStream(
+ onStart: onStart,
+ processMessage: processMessage,
+ processAssistMessage: processAssistMessage,
+ processError: onError,
+ onLocalAIStreamingStateChange: onLocalAIStreamingStateChange,
+ onEnd: onEnd,
);
- final List ragIds = [];
- if (objectId != null) {
- ragIds.add(objectId);
- }
+
+ final records = history.map((record) => record.toPB()).toList();
final payload = CompleteTextPB(
text: text,
completionType: completionType,
+ format: format?.toPB(),
streamPort: fixnum.Int64(stream.nativePort),
- objectId: objectId ?? "",
- ragIds: ragIds,
+ objectId: objectId ?? '',
+ ragIds: [
+ if (objectId != null) objectId,
+ ...sourceIds,
+ ].unique(),
+ history: records,
);
- // ignore: unawaited_futures
- AIEventCompleteText(payload).send();
- return stream;
- }
-}
-
-CompletionTypePB completionTypeFromInt(AskAIAction action) {
- switch (action) {
- case AskAIAction.summarize:
- return CompletionTypePB.MakeShorter;
- case AskAIAction.fixSpelling:
- return CompletionTypePB.SpellingAndGrammar;
- case AskAIAction.improveWriting:
- return CompletionTypePB.ImproveWriting;
- case AskAIAction.makeItLonger:
- return CompletionTypePB.MakeLonger;
- }
-}
-
-class CompletionStream {
- CompletionStream(
- Future Function() onStart,
- Future Function(String text) onProcess,
- Future Function() onEnd,
- void Function(AIError error) onError,
- ) {
- _port.handler = _controller.add;
- _subscription = _controller.stream.listen(
- (event) async {
- if (event == "AI_RESPONSE_LIMIT") {
- onError(
- AIError(
- message: LocaleKeys.sideBar_aiResponseLimit.tr(),
- code: AIErrorCode.aiResponseLimitExceeded,
- ),
- );
- }
-
- if (event == "AI_IMAGE_RESPONSE_LIMIT") {
- onError(
- AIError(
- message: LocaleKeys.sideBar_aiImageResponseLimit.tr(),
- code: AIErrorCode.aiImageResponseLimitExceeded,
- ),
- );
- }
-
- if (event.startsWith("start:")) {
- await onStart();
- }
-
- if (event.startsWith("data:")) {
- await onProcess(event.substring(5));
- }
-
- if (event.startsWith("finish:")) {
- await onEnd();
- }
-
- if (event.startsWith("error:")) {
- onError(AIError(message: event.substring(6)));
- }
+ return AIEventCompleteText(payload).send().fold(
+ (task) => (task.taskId, stream),
+ (error) {
+ Log.error(error);
+ return null;
},
);
}
+}
+
+abstract class CompletionStream {
+ CompletionStream({
+ required this.onStart,
+ required this.processMessage,
+ required this.processAssistMessage,
+ required this.processError,
+ required this.onLocalAIStreamingStateChange,
+ required this.onEnd,
+ });
+
+ final Future Function() onStart;
+ final Future Function(String text) processMessage;
+ final Future Function(String text) processAssistMessage;
+ final void Function(AIError error) processError;
+ final void Function(LocalAIStreamingState state)
+ onLocalAIStreamingStateChange;
+ final Future Function() onEnd;
+}
+
+class AppFlowyCompletionStream extends CompletionStream {
+ AppFlowyCompletionStream({
+ required super.onStart,
+ required super.processMessage,
+ required super.processAssistMessage,
+ required super.processError,
+ required super.onEnd,
+ required super.onLocalAIStreamingStateChange,
+ }) {
+ _startListening();
+ }
final RawReceivePort _port = RawReceivePort();
final StreamController _controller = StreamController.broadcast();
late StreamSubscription _subscription;
int get nativePort => _port.sendPort.nativePort;
+ void _startListening() {
+ _port.handler = _controller.add;
+ _subscription = _controller.stream.listen(
+ (event) async {
+ await _handleEvent(event);
+ },
+ );
+ }
+
Future dispose() async {
await _controller.close();
await _subscription.cancel();
_port.close();
}
- StreamSubscription listen(
- void Function(String event)? onData,
- ) {
- return _controller.stream.listen(onData);
+ Future _handleEvent(String event) async {
+ // Check simple matches first
+ if (event == AIStreamEventPrefix.aiResponseLimit) {
+ processError(
+ AIError(
+ message: LocaleKeys.ai_textLimitReachedDescription.tr(),
+ code: AIErrorCode.aiResponseLimitExceeded,
+ ),
+ );
+ return;
+ }
+
+ if (event == AIStreamEventPrefix.aiImageResponseLimit) {
+ processError(
+ AIError(
+ message: LocaleKeys.ai_imageLimitReachedDescription.tr(),
+ code: AIErrorCode.aiImageResponseLimitExceeded,
+ ),
+ );
+ return;
+ }
+
+ // Otherwise, parse out prefix:content
+ if (event.startsWith(AIStreamEventPrefix.aiMaxRequired)) {
+ processError(
+ AIError(
+ message: event.substring(AIStreamEventPrefix.aiMaxRequired.length),
+ code: AIErrorCode.other,
+ ),
+ );
+ } else if (event.startsWith(AIStreamEventPrefix.start)) {
+ await onStart();
+ } else if (event.startsWith(AIStreamEventPrefix.data)) {
+ await processMessage(
+ event.substring(AIStreamEventPrefix.data.length),
+ );
+ } else if (event.startsWith(AIStreamEventPrefix.comment)) {
+ await processAssistMessage(
+ event.substring(AIStreamEventPrefix.comment.length),
+ );
+ } else if (event.startsWith(AIStreamEventPrefix.finish)) {
+ await onEnd();
+ } else if (event.startsWith(AIStreamEventPrefix.localAIDisabled)) {
+ onLocalAIStreamingStateChange(
+ LocalAIStreamingState.disabled,
+ );
+ } else if (event.startsWith(AIStreamEventPrefix.localAINotReady)) {
+ onLocalAIStreamingStateChange(
+ LocalAIStreamingState.notReady,
+ );
+ } else if (event.startsWith(AIStreamEventPrefix.error)) {
+ processError(
+ AIError(
+ message: event.substring(AIStreamEventPrefix.error.length),
+ code: AIErrorCode.other,
+ ),
+ );
+ } else {
+ Log.debug('Unknown AI event: $event');
+ }
}
}
diff --git a/frontend/appflowy_flutter/lib/ai/service/error.dart b/frontend/appflowy_flutter/lib/ai/service/error.dart
index 866a383e0b..0c98e83172 100644
--- a/frontend/appflowy_flutter/lib/ai/service/error.dart
+++ b/frontend/appflowy_flutter/lib/ai/service/error.dart
@@ -7,7 +7,7 @@ part 'error.g.dart';
class AIError with _$AIError {
const factory AIError({
required String message,
- @Default(AIErrorCode.other) AIErrorCode code,
+ required AIErrorCode code,
}) = _AIError;
factory AIError.fromJson(Map json) =>
diff --git a/frontend/appflowy_flutter/lib/ai/service/openai_client.dart b/frontend/appflowy_flutter/lib/ai/service/openai_client.dart
deleted file mode 100644
index f29024a914..0000000000
--- a/frontend/appflowy_flutter/lib/ai/service/openai_client.dart
+++ /dev/null
@@ -1,173 +0,0 @@
-import 'dart:async';
-import 'dart:convert';
-
-import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbenum.dart';
-import 'package:appflowy_result/appflowy_result.dart';
-import 'package:http/http.dart' as http;
-
-import 'ai_client.dart';
-import 'error.dart';
-import 'text_completion.dart';
-
-enum OpenAIRequestType {
- textCompletion,
- textEdit,
- imageGenerations;
-
- Uri get uri {
- switch (this) {
- case OpenAIRequestType.textCompletion:
- return Uri.parse('https://api.openai.com/v1/completions');
- case OpenAIRequestType.textEdit:
- return Uri.parse('https://api.openai.com/v1/chat/completions');
- case OpenAIRequestType.imageGenerations:
- return Uri.parse('https://api.openai.com/v1/images/generations');
- }
- }
-}
-
-class HttpOpenAIRepository implements AIRepository {
- const HttpOpenAIRepository({
- required this.client,
- required this.apiKey,
- });
-
- final http.Client client;
- final String apiKey;
-
- Map get headers => {
- 'Authorization': 'Bearer $apiKey',
- 'Content-Type': 'application/json',
- };
-
- @override
- Future getStreamedCompletions({
- required String prompt,
- required Future Function() onStart,
- required Future Function(TextCompletionResponse response) onProcess,
- required Future Function() onEnd,
- required void Function(AIError error) onError,
- String? suffix,
- int maxTokens = 2048,
- double temperature = 0.3,
- bool useAction = false,
- }) async {
- final parameters = {
- 'model': 'gpt-3.5-turbo-instruct',
- 'prompt': prompt,
- 'suffix': suffix,
- 'max_tokens': maxTokens,
- 'temperature': temperature,
- 'stream': true,
- };
-
- final request = http.Request('POST', OpenAIRequestType.textCompletion.uri);
- request.headers.addAll(headers);
- request.body = jsonEncode(parameters);
-
- final response = await client.send(request);
-
- // NEED TO REFACTOR.
- // WHY OPENAI USE TWO LINES TO INDICATE THE START OF THE STREAMING RESPONSE?
- // AND WHY OPENAI USE [DONE] TO INDICATE THE END OF THE STREAMING RESPONSE?
- int syntax = 0;
- var previousSyntax = '';
- if (response.statusCode == 200) {
- await for (final chunk in response.stream
- .transform(const Utf8Decoder())
- .transform(const LineSplitter())) {
- syntax += 1;
- if (!useAction) {
- if (syntax == 3) {
- await onStart();
- continue;
- } else if (syntax < 3) {
- continue;
- }
- } else {
- if (syntax == 2) {
- await onStart();
- continue;
- } else if (syntax < 2) {
- continue;
- }
- }
- final data = chunk.trim().split('data: ');
- if (data.length > 1) {
- if (data[1] != '[DONE]') {
- final response = TextCompletionResponse.fromJson(
- json.decode(data[1]),
- );
- if (response.choices.isNotEmpty) {
- final text = response.choices.first.text;
- if (text == previousSyntax && text == '\n') {
- continue;
- }
- await onProcess(response);
- previousSyntax = response.choices.first.text;
- }
- } else {
- await onEnd();
- }
- }
- }
- } else {
- final body = await response.stream.bytesToString();
- onError(
- AIError.fromJson(json.decode(body)['error']),
- );
- }
- return;
- }
-
- @override
- Future, AIError>> generateImage({
- required String prompt,
- int n = 1,
- }) async {
- final parameters = {
- 'prompt': prompt,
- 'n': n,
- 'size': '512x512',
- };
-
- try {
- final response = await client.post(
- OpenAIRequestType.imageGenerations.uri,
- headers: headers,
- body: json.encode(parameters),
- );
-
- if (response.statusCode == 200) {
- final data = json.decode(
- utf8.decode(response.bodyBytes),
- )['data'] as List;
- final urls = data
- .map((e) => e.values)
- .expand((e) => e)
- .map((e) => e.toString())
- .toList();
- return FlowyResult.success(urls);
- } else {
- return FlowyResult.failure(
- AIError.fromJson(json.decode(response.body)['error']),
- );
- }
- } catch (error) {
- return FlowyResult.failure(AIError(message: error.toString()));
- }
- }
-
- @override
- Future streamCompletion({
- String? objectId,
- required String text,
- required CompletionTypePB completionType,
- required Future Function() onStart,
- required Future Function(String text) onProcess,
- required Future Function() onEnd,
- required void Function(AIError error) onError,
- }) {
- throw UnimplementedError();
- }
-}
diff --git a/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart b/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart
new file mode 100644
index 0000000000..7ad52b9ec4
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart
@@ -0,0 +1,92 @@
+import 'dart:async';
+
+import 'package:appflowy/ai/service/ai_model_state_notifier.dart';
+import 'package:appflowy_backend/dispatch/dispatch.dart';
+import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbserver.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'select_model_bloc.freezed.dart';
+
+class SelectModelBloc extends Bloc {
+ SelectModelBloc({
+ required AIModelStateNotifier aiModelStateNotifier,
+ }) : _aiModelStateNotifier = aiModelStateNotifier,
+ super(SelectModelState.initial(aiModelStateNotifier)) {
+ on(
+ (event, emit) {
+ event.when(
+ selectModel: (model) {
+ AIEventUpdateSelectedModel(
+ UpdateSelectedModelPB(
+ source: _aiModelStateNotifier.objectId,
+ selectedModel: model,
+ ),
+ ).send();
+
+ emit(state.copyWith(selectedModel: model));
+ },
+ didLoadModels: (models, selectedModel) {
+ emit(
+ SelectModelState(
+ models: models,
+ selectedModel: selectedModel,
+ ),
+ );
+ },
+ );
+ },
+ );
+
+ _aiModelStateNotifier.addListener(
+ onAvailableModelsChanged: _onAvailableModelsChanged,
+ );
+ }
+
+ final AIModelStateNotifier _aiModelStateNotifier;
+
+ @override
+ Future close() async {
+ _aiModelStateNotifier.removeListener(
+ onAvailableModelsChanged: _onAvailableModelsChanged,
+ );
+ await super.close();
+ }
+
+ void _onAvailableModelsChanged(
+ List models,
+ AIModelPB? selectedModel,
+ ) {
+ if (!isClosed) {
+ add(SelectModelEvent.didLoadModels(models, selectedModel));
+ }
+ }
+}
+
+@freezed
+class SelectModelEvent with _$SelectModelEvent {
+ const factory SelectModelEvent.selectModel(
+ AIModelPB model,
+ ) = _SelectModel;
+
+ const factory SelectModelEvent.didLoadModels(
+ List models,
+ AIModelPB? selectedModel,
+ ) = _DidLoadModels;
+}
+
+@freezed
+class SelectModelState with _$SelectModelState {
+ const factory SelectModelState({
+ required List models,
+ required AIModelPB? selectedModel,
+ }) = _SelectModelState;
+
+ factory SelectModelState.initial(AIModelStateNotifier notifier) {
+ final (models, selectedModel) = notifier.getAvailableModels();
+ return SelectModelState(
+ models: models,
+ selectedModel: selectedModel,
+ );
+ }
+}
diff --git a/frontend/appflowy_flutter/lib/ai/service/text_completion.dart b/frontend/appflowy_flutter/lib/ai/service/text_completion.dart
deleted file mode 100644
index 4c22325588..0000000000
--- a/frontend/appflowy_flutter/lib/ai/service/text_completion.dart
+++ /dev/null
@@ -1,27 +0,0 @@
-import 'package:freezed_annotation/freezed_annotation.dart';
-
-part 'text_completion.freezed.dart';
-part 'text_completion.g.dart';
-
-@freezed
-class TextCompletionChoice with _$TextCompletionChoice {
- factory TextCompletionChoice({
- required String text,
- required int index,
- // ignore: invalid_annotation_target
- @JsonKey(name: 'finish_reason') String? finishReason,
- }) = _TextCompletionChoice;
-
- factory TextCompletionChoice.fromJson(Map json) =>
- _$TextCompletionChoiceFromJson(json);
-}
-
-@freezed
-class TextCompletionResponse with _$TextCompletionResponse {
- const factory TextCompletionResponse({
- required List choices,
- }) = _TextCompletionResponse;
-
- factory TextCompletionResponse.fromJson(Map json) =>
- _$TextCompletionResponseFromJson(json);
-}
diff --git a/frontend/appflowy_flutter/lib/ai/widgets/loading_indicator.dart b/frontend/appflowy_flutter/lib/ai/widgets/loading_indicator.dart
index f1c5cf0cfb..3a9c96b255 100644
--- a/frontend/appflowy_flutter/lib/ai/widgets/loading_indicator.dart
+++ b/frontend/appflowy_flutter/lib/ai/widgets/loading_indicator.dart
@@ -16,8 +16,7 @@ class AILoadingIndicator extends StatelessWidget {
@override
Widget build(BuildContext context) {
final slice = Duration(milliseconds: duration.inMilliseconds ~/ 5);
- return Padding(
- padding: const EdgeInsets.only(top: 8.0),
+ return SelectionContainer.disabled(
child: SizedBox(
height: 20,
child: SeparatedRow(
diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_input_text_field.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_input_text_field.dart
deleted file mode 100644
index ed95da1509..0000000000
--- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_input_text_field.dart
+++ /dev/null
@@ -1,53 +0,0 @@
-import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart';
-import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart';
-import 'package:extended_text_field/extended_text_field.dart';
-import 'package:flutter/material.dart';
-
-import 'mentioned_page_text_span.dart';
-
-class PromptInputTextField extends StatelessWidget {
- const PromptInputTextField({
- super.key,
- required this.cubit,
- required this.textController,
- required this.textFieldFocusNode,
- required this.contentPadding,
- this.hintText = "",
- });
-
- final ChatInputControlCubit cubit;
- final TextEditingController textController;
- final FocusNode textFieldFocusNode;
- final EdgeInsetsGeometry contentPadding;
- final String hintText;
-
- @override
- Widget build(BuildContext context) {
- return ExtendedTextField(
- controller: textController,
- focusNode: textFieldFocusNode,
- decoration: InputDecoration(
- border: InputBorder.none,
- enabledBorder: InputBorder.none,
- focusedBorder: InputBorder.none,
- contentPadding: contentPadding,
- hintText: hintText,
- hintStyle: AIChatUILayout.inputHintTextStyle(context),
- isCollapsed: true,
- isDense: true,
- ),
- keyboardType: TextInputType.multiline,
- textCapitalization: TextCapitalization.sentences,
- minLines: 1,
- maxLines: null,
- style: Theme.of(context).textTheme.bodyMedium,
- specialTextSpanBuilder: PromptInputTextSpanBuilder(
- inputControlCubit: cubit,
- specialTextStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
- color: Theme.of(context).colorScheme.primary,
- fontWeight: FontWeight.w600,
- ),
- ),
- );
- }
-}
diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/desktop_chat_input.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart
similarity index 57%
rename from frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/desktop_chat_input.dart
rename to frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart
index 375fb79e12..a2676f2c15 100644
--- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/desktop_chat_input.dart
+++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_text_field.dart
@@ -1,53 +1,54 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
-import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart';
-import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart';
+import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart';
import 'package:appflowy/startup/startup.dart';
+import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
+import 'package:extended_text_field/extended_text_field.dart';
import 'package:flowy_infra/file_picker/file_picker_service.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
-import '../layout_define.dart';
-
-class DesktopChatInput extends StatefulWidget {
- const DesktopChatInput({
+class DesktopPromptInput extends StatefulWidget {
+ const DesktopPromptInput({
super.key,
- required this.chatId,
required this.isStreaming,
+ required this.textController,
required this.onStopStreaming,
required this.onSubmitted,
+ required this.selectedSourcesNotifier,
required this.onUpdateSelectedSources,
+ this.hideDecoration = false,
+ this.hideFormats = false,
+ this.extraBottomActionButton,
});
- final String chatId;
final bool isStreaming;
+ final TextEditingController textController;
final void Function() onStopStreaming;
final void Function(String, PredefinedFormat?, Map)
onSubmitted;
+ final ValueNotifier> selectedSourcesNotifier;
final void Function(List) onUpdateSelectedSources;
+ final bool hideDecoration;
+ final bool hideFormats;
+ final Widget? extraBottomActionButton;
@override
- State createState() => _DesktopChatInputState();
+ State createState() => _DesktopPromptInputState();
}
-class _DesktopChatInputState extends State {
+class _DesktopPromptInputState extends State {
final textFieldKey = GlobalKey();
final layerLink = LayerLink();
final overlayController = OverlayPortalController();
final inputControlCubit = ChatInputControlCubit();
final focusNode = FocusNode();
- final textController = TextEditingController();
- bool showPredefinedFormatSection = true;
- PredefinedFormat predefinedFormat = const PredefinedFormat(
- imageFormat: ImageFormat.text,
- textFormat: TextFormat.bulletList,
- );
late SendButtonState sendButtonState;
bool isComposing = false;
@@ -55,16 +56,19 @@ class _DesktopChatInputState extends State {
void initState() {
super.initState();
- textController.addListener(handleTextControllerChanged);
-
- // refresh border color on focus change and hide menu when lost focus
- focusNode.addListener(
- () => setState(() {
- if (!focusNode.hasFocus) {
- cancelMentionPage();
- }
- }),
- );
+ widget.textController.addListener(handleTextControllerChanged);
+ focusNode
+ ..addListener(
+ () {
+ if (!widget.hideDecoration) {
+ setState(() {}); // refresh border color
+ }
+ if (!focusNode.hasFocus) {
+ cancelMentionPage(); // hide menu when lost focus
+ }
+ },
+ )
+ ..onKeyEvent = handleKeyEvent;
updateSendButtonState();
@@ -82,7 +86,7 @@ class _DesktopChatInputState extends State {
@override
void dispose() {
focusNode.dispose();
- textController.dispose();
+ widget.textController.removeListener(handleTextControllerChanged);
inputControlCubit.close();
super.dispose();
}
@@ -107,20 +111,12 @@ class _DesktopChatInputState extends State {
overlayChildBuilder: (context) {
return PromptInputMentionPageMenu(
anchor: PromptInputAnchor(textFieldKey, layerLink),
- textController: textController,
+ textController: widget.textController,
onPageSelected: handlePageSelected,
);
},
child: DecoratedBox(
- decoration: BoxDecoration(
- border: Border.all(
- color: focusNode.hasFocus
- ? Theme.of(context).colorScheme.primary
- : Theme.of(context).colorScheme.outline,
- width: focusNode.hasFocus ? 1.5 : 1.0,
- ),
- borderRadius: const BorderRadius.all(Radius.circular(12.0)),
- ),
+ decoration: decoration(context),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -132,7 +128,6 @@ class _DesktopChatInputState extends State {
),
child: TextFieldTapRegion(
child: PromptInputFile(
- chatId: widget.chatId,
onDeleted: (file) => context
.read()
.add(AIPromptInputEvent.removeFile(file)),
@@ -140,53 +135,65 @@ class _DesktopChatInputState extends State {
),
),
const VSpace(4.0),
- Stack(
- children: [
- Container(
- constraints: getTextFieldConstraints(),
- child: inputTextField(),
- ),
- if (showPredefinedFormatSection)
- Positioned.fill(
- bottom: null,
- child: TextFieldTapRegion(
- child: Padding(
- padding:
- const EdgeInsetsDirectional.only(start: 8.0),
- child: ChangeFormatBar(
- predefinedFormat: predefinedFormat,
- spacing: 4.0,
- onSelectPredefinedFormat: (format) {
- setState(() => predefinedFormat = format);
- },
+ BlocBuilder(
+ builder: (context, state) {
+ return Stack(
+ children: [
+ ConstrainedBox(
+ constraints: getTextFieldConstraints(
+ state.showPredefinedFormats && !widget.hideFormats,
+ ),
+ child: inputTextField(),
+ ),
+ if (state.showPredefinedFormats && !widget.hideFormats)
+ Positioned.fill(
+ bottom: null,
+ child: TextFieldTapRegion(
+ child: Padding(
+ padding: const EdgeInsetsDirectional.only(
+ start: 8.0,
+ ),
+ child: ChangeFormatBar(
+ showImageFormats: state.aiType.isCloud,
+ predefinedFormat: state.predefinedFormat,
+ spacing: 4.0,
+ onSelectPredefinedFormat: (format) =>
+ context.read().add(
+ AIPromptInputEvent
+ .updatePredefinedFormat(format),
+ ),
+ ),
+ ),
+ ),
+ ),
+ Positioned.fill(
+ top: null,
+ child: TextFieldTapRegion(
+ child: _PromptBottomActions(
+ showPredefinedFormatBar:
+ state.showPredefinedFormats,
+ showPredefinedFormatButton: !widget.hideFormats,
+ onTogglePredefinedFormatSection: () =>
+ context.read().add(
+ AIPromptInputEvent
+ .toggleShowPredefinedFormat(),
+ ),
+ onStartMention: startMentionPageFromButton,
+ sendButtonState: sendButtonState,
+ onSendPressed: handleSend,
+ onStopStreaming: widget.onStopStreaming,
+ selectedSourcesNotifier:
+ widget.selectedSourcesNotifier,
+ onUpdateSelectedSources:
+ widget.onUpdateSelectedSources,
+ extraBottomActionButton:
+ widget.extraBottomActionButton,
),
),
),
- ),
- Positioned.fill(
- top: null,
- child: TextFieldTapRegion(
- child: _PromptBottomActions(
- textController: textController,
- overlayController: overlayController,
- focusNode: focusNode,
- showPredefinedFormats: showPredefinedFormatSection,
- predefinedFormat: predefinedFormat,
- onTogglePredefinedFormatSection: () {
- setState(() {
- showPredefinedFormatSection =
- !showPredefinedFormatSection;
- });
- },
- sendButtonState: sendButtonState,
- onSendPressed: handleSend,
- onStopStreaming: widget.onStopStreaming,
- onUpdateSelectedSources:
- widget.onUpdateSelectedSources,
- ),
- ),
- ),
- ],
+ ],
+ );
+ },
),
],
),
@@ -196,6 +203,40 @@ class _DesktopChatInputState extends State {
);
}
+ BoxDecoration decoration(BuildContext context) {
+ if (widget.hideDecoration) {
+ return BoxDecoration();
+ }
+ return BoxDecoration(
+ color: Theme.of(context).colorScheme.surface,
+ border: Border.all(
+ color: focusNode.hasFocus
+ ? Theme.of(context).colorScheme.primary
+ : Theme.of(context).colorScheme.outline,
+ width: focusNode.hasFocus ? 1.5 : 1.0,
+ ),
+ borderRadius: const BorderRadius.all(Radius.circular(12.0)),
+ );
+ }
+
+ void startMentionPageFromButton() {
+ if (overlayController.isShowing) {
+ return;
+ }
+ if (!focusNode.hasFocus) {
+ focusNode.requestFocus();
+ }
+ widget.textController.text += '@';
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (context.mounted) {
+ context
+ .read()
+ .startSearching(widget.textController.value);
+ overlayController.show();
+ }
+ });
+ }
+
void cancelMentionPage() {
if (overlayController.isShowing) {
inputControlCubit.reset();
@@ -206,7 +247,7 @@ class _DesktopChatInputState extends State {
void updateSendButtonState() {
if (widget.isStreaming) {
sendButtonState = SendButtonState.streaming;
- } else if (textController.text.trim().isEmpty) {
+ } else if (widget.textController.text.trim().isEmpty) {
sendButtonState = SendButtonState.disabled;
} else {
sendButtonState = SendButtonState.enabled;
@@ -218,9 +259,9 @@ class _DesktopChatInputState extends State {
return;
}
final trimmedText = inputControlCubit.formatIntputText(
- textController.text.trim(),
+ widget.textController.text.trim(),
);
- textController.clear();
+ widget.textController.clear();
if (trimmedText.isEmpty) {
return;
}
@@ -228,9 +269,13 @@ class _DesktopChatInputState extends State {
// get the attached files and mentioned pages
final metadata = context.read().consumeMetadata();
+ final bloc = context.read();
+ final showPredefinedFormats = bloc.state.showPredefinedFormats;
+ final predefinedFormat = bloc.state.predefinedFormat;
+
widget.onSubmitted(
trimmedText,
- showPredefinedFormatSection ? predefinedFormat : null,
+ showPredefinedFormats ? predefinedFormat : null,
metadata,
);
}
@@ -239,17 +284,17 @@ class _DesktopChatInputState extends State {
setState(() {
// update whether send button is clickable
updateSendButtonState();
- isComposing = !textController.value.composing.isCollapsed;
+ isComposing = !widget.textController.value.composing.isCollapsed;
});
if (isComposing) {
return;
}
- // handle text and selection changes ONLY when mentioning a page
-
// disable mention
return;
+
+ // handle text and selection changes ONLY when mentioning a page
// ignore: dead_code
if (!overlayController.isShowing ||
inputControlCubit.filterStartPosition == -1) {
@@ -257,6 +302,7 @@ class _DesktopChatInputState extends State {
}
// handle cases where mention a page is cancelled
+ final textController = widget.textController;
final textSelection = textController.value.selection;
final isSelectingMultipleCharacters = !textSelection.isCollapsed;
final isCaretBeforeStartOfRange =
@@ -303,22 +349,27 @@ class _DesktopChatInputState extends State {
}
KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) {
- if (event.character == '@') {
- WidgetsBinding.instance.addPostFrameCallback((_) {
- inputControlCubit.startSearching(textController.value);
- overlayController.show();
- });
+ // if (event.character == '@') {
+ // WidgetsBinding.instance.addPostFrameCallback((_) {
+ // inputControlCubit.startSearching(widget.textController.value);
+ // overlayController.show();
+ // });
+ // }
+ if (event is KeyDownEvent &&
+ event.logicalKey == LogicalKeyboardKey.escape) {
+ node.unfocus();
+ return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
void handlePageSelected(ViewPB view) {
- final newText = textController.text.replaceRange(
+ final newText = widget.textController.text.replaceRange(
inputControlCubit.filterStartPosition,
inputControlCubit.filterEndPosition,
view.id,
);
- textController.value = TextEditingValue(
+ widget.textController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset: inputControlCubit.filterStartPosition + view.id.length,
@@ -330,18 +381,6 @@ class _DesktopChatInputState extends State {
overlayController.hide();
}
- BoxConstraints getTextFieldConstraints() {
- double minHeight = DesktopAIPromptSizes.textFieldMinHeight +
- DesktopAIPromptSizes.actionBarSendButtonSize +
- DesktopAIChatSizes.inputActionBarMargin.vertical;
- double maxHeight = 300;
- if (showPredefinedFormatSection) {
- minHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight;
- maxHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight;
- }
- return BoxConstraints(minHeight: minHeight, maxHeight: maxHeight);
- }
-
Widget inputTextField() {
return Shortcuts(
shortcuts: buildShortcuts(),
@@ -351,17 +390,27 @@ class _DesktopChatInputState extends State {
link: layerLink,
child: BlocBuilder(
builder: (context, state) {
- return PromptInputTextField(
+ Widget textField = PromptInputTextField(
key: textFieldKey,
+ editable: state.editable,
cubit: inputControlCubit,
- textController: textController,
+ textController: widget.textController,
textFieldFocusNode: focusNode,
- contentPadding: calculateContentPadding(),
- hintText: switch (state.aiType) {
- AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(),
- AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr()
- },
+ contentPadding:
+ calculateContentPadding(state.showPredefinedFormats),
+ hintText: state.hintText,
);
+
+ if (!state.editable) {
+ textField = FlowyTooltip(
+ message: LocaleKeys
+ .settings_aiPage_keys_localAINotReadyTextFieldPrompt
+ .tr(),
+ child: textField,
+ );
+ }
+
+ return textField;
},
),
),
@@ -369,8 +418,20 @@ class _DesktopChatInputState extends State {
);
}
- EdgeInsetsGeometry calculateContentPadding() {
- final top = showPredefinedFormatSection
+ BoxConstraints getTextFieldConstraints(bool showPredefinedFormats) {
+ double minHeight = DesktopAIPromptSizes.textFieldMinHeight +
+ DesktopAIPromptSizes.actionBarSendButtonSize +
+ DesktopAIChatSizes.inputActionBarMargin.vertical;
+ double maxHeight = 300;
+ if (showPredefinedFormats) {
+ minHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight;
+ maxHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight;
+ }
+ return BoxConstraints(minHeight: minHeight, maxHeight: maxHeight);
+ }
+
+ EdgeInsetsGeometry calculateContentPadding(bool showPredefinedFormats) {
+ final top = showPredefinedFormats
? DesktopAIPromptSizes.predefinedFormatButtonHeight
: 0.0;
final bottom = DesktopAIPromptSizes.actionBarSendButtonSize +
@@ -451,30 +512,89 @@ class _FocusNextItemIntent extends Intent {
const _FocusNextItemIntent();
}
-class _PromptBottomActions extends StatelessWidget {
- const _PromptBottomActions({
+class PromptInputTextField extends StatelessWidget {
+ const PromptInputTextField({
+ super.key,
+ required this.editable,
+ required this.cubit,
required this.textController,
- required this.overlayController,
- required this.focusNode,
- required this.sendButtonState,
- required this.predefinedFormat,
- required this.onTogglePredefinedFormatSection,
- required this.showPredefinedFormats,
- required this.onSendPressed,
- required this.onStopStreaming,
- required this.onUpdateSelectedSources,
+ required this.textFieldFocusNode,
+ required this.contentPadding,
+ this.hintText = "",
});
+ final ChatInputControlCubit cubit;
final TextEditingController textController;
- final OverlayPortalController overlayController;
- final FocusNode focusNode;
- final bool showPredefinedFormats;
- final PredefinedFormat predefinedFormat;
+ final FocusNode textFieldFocusNode;
+ final EdgeInsetsGeometry contentPadding;
+ final bool editable;
+ final String hintText;
+
+ @override
+ Widget build(BuildContext context) {
+ return ExtendedTextField(
+ controller: textController,
+ focusNode: textFieldFocusNode,
+ readOnly: !editable,
+ enabled: editable,
+ decoration: InputDecoration(
+ border: InputBorder.none,
+ enabledBorder: InputBorder.none,
+ focusedBorder: InputBorder.none,
+ contentPadding: contentPadding,
+ hintText: hintText,
+ hintStyle: inputHintTextStyle(context),
+ isCollapsed: true,
+ isDense: true,
+ ),
+ keyboardType: TextInputType.multiline,
+ textCapitalization: TextCapitalization.sentences,
+ minLines: 1,
+ maxLines: null,
+ style: Theme.of(context).textTheme.bodyMedium,
+ specialTextSpanBuilder: PromptInputTextSpanBuilder(
+ inputControlCubit: cubit,
+ specialTextStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: Theme.of(context).colorScheme.primary,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ );
+ }
+
+ TextStyle? inputHintTextStyle(BuildContext context) {
+ return Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: Theme.of(context).isLightMode
+ ? const Color(0xFFBDC2C8)
+ : const Color(0xFF3C3E51),
+ );
+ }
+}
+
+class _PromptBottomActions extends StatelessWidget {
+ const _PromptBottomActions({
+ required this.sendButtonState,
+ required this.showPredefinedFormatBar,
+ required this.showPredefinedFormatButton,
+ required this.onTogglePredefinedFormatSection,
+ required this.onStartMention,
+ required this.onSendPressed,
+ required this.onStopStreaming,
+ required this.selectedSourcesNotifier,
+ required this.onUpdateSelectedSources,
+ this.extraBottomActionButton,
+ });
+
+ final bool showPredefinedFormatBar;
+ final bool showPredefinedFormatButton;
final void Function() onTogglePredefinedFormatSection;
+ final void Function() onStartMention;
final SendButtonState sendButtonState;
final void Function() onSendPressed;
final void Function() onStopStreaming;
+ final ValueNotifier> selectedSourcesNotifier;
final void Function(List) onUpdateSelectedSources;
+ final Widget? extraBottomActionButton;
@override
Widget build(BuildContext context) {
@@ -483,18 +603,27 @@ class _PromptBottomActions extends StatelessWidget {
margin: DesktopAIChatSizes.inputActionBarMargin,
child: BlocBuilder(
builder: (context, state) {
- if (state.chatState == null) {
- return Align(
- alignment: AlignmentDirectional.centerEnd,
- child: _sendButton(),
- );
- }
return Row(
children: [
- _predefinedFormatButton(),
+ if (showPredefinedFormatButton) ...[
+ _predefinedFormatButton(),
+ const HSpace(
+ DesktopAIChatSizes.inputActionBarButtonSpacing,
+ ),
+ ],
+ SelectModelMenu(
+ aiModelStateNotifier:
+ context.read().aiModelStateNotifier,
+ ),
const Spacer(),
- if (state.aiType == AIType.appflowyAI) ...[
- _selectSourcesButton(context),
+ if (state.aiType.isCloud) ...[
+ _selectSourcesButton(),
+ const HSpace(
+ DesktopAIChatSizes.inputActionBarButtonSpacing,
+ ),
+ ],
+ if (extraBottomActionButton != null) ...[
+ extraBottomActionButton!,
const HSpace(
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
@@ -519,15 +648,15 @@ class _PromptBottomActions extends StatelessWidget {
Widget _predefinedFormatButton() {
return PromptInputDesktopToggleFormatButton(
- showFormatBar: showPredefinedFormats,
- predefinedFormat: predefinedFormat,
+ showFormatBar: showPredefinedFormatBar,
onTap: onTogglePredefinedFormatSection,
);
}
- Widget _selectSourcesButton(BuildContext context) {
+ Widget _selectSourcesButton() {
return PromptInputDesktopSelectSourcesButton(
onUpdateSelectedSources: onUpdateSelectedSources,
+ selectedSourcesNotifier: selectedSourcesNotifier,
);
}
@@ -535,21 +664,7 @@ class _PromptBottomActions extends StatelessWidget {
// return PromptInputMentionButton(
// iconSize: DesktopAIPromptSizes.actionBarIconSize,
// buttonSize: DesktopAIPromptSizes.actionBarButtonSize,
- // onTap: () {
- // if (overlayController.isShowing) {
- // return;
- // }
- // if (!focusNode.hasFocus) {
- // focusNode.requestFocus();
- // }
- // textController.text += '@';
- // Future.delayed(Duration.zero, () {
- // context
- // .read()
- // .startSearching(textController.value);
- // overlayController.show();
- // });
- // },
+ // onTap: onStartMention,
// );
// }
diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/file_attachment_list.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/file_attachment_list.dart
index fddde4dc69..cd68205506 100644
--- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/file_attachment_list.dart
+++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/file_attachment_list.dart
@@ -1,5 +1,5 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
-import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart';
+import 'package:appflowy/ai/service/ai_prompt_input_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_file_bloc.dart';
import 'package:flowy_infra/theme_extension.dart';
@@ -13,11 +13,9 @@ import 'layout_define.dart';
class PromptInputFile extends StatelessWidget {
const PromptInputFile({
super.key,
- required this.chatId,
required this.onDeleted,
});
- final String chatId;
final void Function(ChatFile) onDeleted;
@override
@@ -37,7 +35,6 @@ class PromptInputFile extends StatelessWidget {
),
itemCount: files.length,
itemBuilder: (context, index) => ChatFilePreview(
- chatId: chatId,
file: files[index],
onDeleted: () => onDeleted(files[index]),
),
@@ -49,13 +46,11 @@ class PromptInputFile extends StatelessWidget {
class ChatFilePreview extends StatefulWidget {
const ChatFilePreview({
- required this.chatId,
required this.file,
required this.onDeleted,
super.key,
});
- final String chatId;
final ChatFile file;
final VoidCallback onDeleted;
diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/mention_page_menu.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/mention_page_menu.dart
index 69fecad613..ae2dbe5f26 100644
--- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/mention_page_menu.dart
+++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/mention_page_menu.dart
@@ -49,7 +49,9 @@ class _PromptInputMentionPageMenuState
void initState() {
super.initState();
Future.delayed(Duration.zero, () {
- context.read().refreshViews();
+ if (mounted) {
+ context.read().refreshViews();
+ }
});
}
diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/predefined_format_buttons.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/predefined_format_buttons.dart
index fe3b07cc13..403b978905 100644
--- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/predefined_format_buttons.dart
+++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/predefined_format_buttons.dart
@@ -1,24 +1,22 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
-import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:universal_platform/universal_platform.dart';
+import '../../service/ai_entities.dart';
import 'layout_define.dart';
class PromptInputDesktopToggleFormatButton extends StatelessWidget {
const PromptInputDesktopToggleFormatButton({
super.key,
required this.showFormatBar,
- required this.predefinedFormat,
required this.onTap,
});
final bool showFormatBar;
- final PredefinedFormat predefinedFormat;
final VoidCallback onTap;
@override
@@ -50,26 +48,31 @@ class ChangeFormatBar extends StatelessWidget {
required this.predefinedFormat,
required this.spacing,
required this.onSelectPredefinedFormat,
+ this.showImageFormats = true,
});
final PredefinedFormat? predefinedFormat;
final double spacing;
final void Function(PredefinedFormat) onSelectPredefinedFormat;
+ final bool showImageFormats;
@override
Widget build(BuildContext context) {
+ final showTextFormats = predefinedFormat?.imageFormat.hasText ?? true;
return SizedBox(
height: DesktopAIPromptSizes.predefinedFormatButtonHeight,
child: SeparatedRow(
mainAxisSize: MainAxisSize.min,
separatorBuilder: () => HSpace(spacing),
children: [
- _buildFormatButton(context, ImageFormat.text),
- _buildFormatButton(context, ImageFormat.textAndImage),
- _buildFormatButton(context, ImageFormat.image),
- if (predefinedFormat?.imageFormat.hasText ?? true) ...[
- _buildDivider(),
- _buildTextFormatButton(context, TextFormat.auto),
+ if (showImageFormats) ...[
+ _buildFormatButton(context, ImageFormat.text),
+ _buildFormatButton(context, ImageFormat.textAndImage),
+ _buildFormatButton(context, ImageFormat.image),
+ ],
+ if (showImageFormats && showTextFormats) _buildDivider(),
+ if (showTextFormats) ...[
+ _buildTextFormatButton(context, TextFormat.paragraph),
_buildTextFormatButton(context, TextFormat.bulletList),
_buildTextFormatButton(context, TextFormat.numberedList),
_buildTextFormatButton(context, TextFormat.table),
@@ -88,7 +91,8 @@ class ChangeFormatBar extends StatelessWidget {
return;
}
if (format.hasText) {
- final textFormat = predefinedFormat?.textFormat ?? TextFormat.auto;
+ final textFormat =
+ predefinedFormat?.textFormat ?? TextFormat.paragraph;
onSelectPredefinedFormat(
PredefinedFormat(imageFormat: format, textFormat: textFormat),
);
@@ -100,6 +104,7 @@ class ChangeFormatBar extends StatelessWidget {
},
child: FlowyTooltip(
message: format.i18n,
+ preferBelow: false,
child: SizedBox.square(
dimension: _buttonSize,
child: FlowyHover(
@@ -146,6 +151,7 @@ class ChangeFormatBar extends StatelessWidget {
},
child: FlowyTooltip(
message: format.i18n,
+ preferBelow: false,
child: SizedBox.square(
dimension: _buttonSize,
child: FlowyHover(
diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart
new file mode 100644
index 0000000000..a611d84310
--- /dev/null
+++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart
@@ -0,0 +1,264 @@
+import 'package:appflowy/ai/ai.dart';
+import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flowy_infra_ui/style_widget/hover.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class SelectModelMenu extends StatefulWidget {
+ const SelectModelMenu({
+ super.key,
+ required this.aiModelStateNotifier,
+ });
+
+ final AIModelStateNotifier aiModelStateNotifier;
+
+ @override
+ State createState() => _SelectModelMenuState();
+}
+
+class _SelectModelMenuState extends State {
+ final popoverController = PopoverController();
+
+ @override
+ Widget build(BuildContext context) {
+ return BlocProvider(
+ create: (context) => SelectModelBloc(
+ aiModelStateNotifier: widget.aiModelStateNotifier,
+ ),
+ child: BlocBuilder(
+ builder: (context, state) {
+ return AppFlowyPopover(
+ offset: Offset(-12.0, 0.0),
+ constraints: BoxConstraints(maxWidth: 250, maxHeight: 600),
+ direction: PopoverDirection.topWithLeftAligned,
+ margin: EdgeInsets.zero,
+ controller: popoverController,
+ popupBuilder: (popoverContext) {
+ return SelectModelPopoverContent(
+ models: state.models,
+ selectedModel: state.selectedModel,
+ onSelectModel: (model) {
+ if (model != state.selectedModel) {
+ context
+ .read()
+ .add(SelectModelEvent.selectModel(model));
+ }
+ popoverController.close();
+ },
+ );
+ },
+ child: _CurrentModelButton(
+ model: state.selectedModel,
+ onTap: () {
+ if (state.selectedModel != null) {
+ popoverController.show();
+ }
+ },
+ ),
+ );
+ },
+ ),
+ );
+ }
+}
+
+class SelectModelPopoverContent extends StatelessWidget {
+ const SelectModelPopoverContent({
+ super.key,
+ required this.models,
+ required this.selectedModel,
+ this.onSelectModel,
+ });
+
+ final List models;
+ final AIModelPB? selectedModel;
+ final void Function(AIModelPB)? onSelectModel;
+
+ @override
+ Widget build(BuildContext context) {
+ if (models.isEmpty) {
+ return const SizedBox.shrink();
+ }
+
+ // separate models into local and cloud models
+ final localModels = models.where((model) => model.isLocal).toList();
+ final cloudModels = models.where((model) => !model.isLocal).toList();
+
+ return Padding(
+ padding: const EdgeInsets.all(8.0),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (localModels.isNotEmpty) ...[
+ _ModelSectionHeader(
+ title: LocaleKeys.chat_switchModel_localModel.tr(),
+ ),
+ const VSpace(4.0),
+ ],
+ ...localModels.map(
+ (model) => _ModelItem(
+ model: model,
+ isSelected: model == selectedModel,
+ onTap: () => onSelectModel?.call(model),
+ ),
+ ),
+ if (cloudModels.isNotEmpty && localModels.isNotEmpty) ...[
+ const VSpace(8.0),
+ _ModelSectionHeader(
+ title: LocaleKeys.chat_switchModel_cloudModel.tr(),
+ ),
+ const VSpace(4.0),
+ ],
+ ...cloudModels.map(
+ (model) => _ModelItem(
+ model: model,
+ isSelected: model == selectedModel,
+ onTap: () => onSelectModel?.call(model),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _ModelSectionHeader extends StatelessWidget {
+ const _ModelSectionHeader({
+ required this.title,
+ });
+
+ final String title;
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.only(top: 4, bottom: 2),
+ child: FlowyText(
+ title,
+ fontSize: 12,
+ figmaLineHeight: 16,
+ color: Theme.of(context).hintColor,
+ fontWeight: FontWeight.w500,
+ ),
+ );
+ }
+}
+
+class _ModelItem extends StatelessWidget {
+ const _ModelItem({
+ required this.model,
+ required this.isSelected,
+ required this.onTap,
+ });
+
+ final AIModelPB model;
+ final bool isSelected;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ return ConstrainedBox(
+ constraints: const BoxConstraints(minHeight: 32),
+ child: FlowyButton(
+ onTap: onTap,
+ margin: EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0),
+ text: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ FlowyText(
+ model.i18n,
+ figmaLineHeight: 20,
+ overflow: TextOverflow.ellipsis,
+ ),
+ if (model.desc.isNotEmpty)
+ FlowyText(
+ model.desc,
+ fontSize: 12,
+ figmaLineHeight: 16,
+ color: Theme.of(context).hintColor,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ],
+ ),
+ rightIcon: isSelected
+ ? FlowySvg(
+ FlowySvgs.check_s,
+ size: const Size.square(20),
+ color: Theme.of(context).colorScheme.primary,
+ )
+ : null,
+ ),
+ );
+ }
+}
+
+class _CurrentModelButton extends StatelessWidget {
+ const _CurrentModelButton({
+ required this.model,
+ required this.onTap,
+ });
+
+ final AIModelPB? model;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ return FlowyTooltip(
+ message: LocaleKeys.chat_switchModel_label.tr(),
+ child: GestureDetector(
+ onTap: onTap,
+ behavior: HitTestBehavior.opaque,
+ child: SizedBox(
+ height: DesktopAIPromptSizes.actionBarButtonSize,
+ child: AnimatedSize(
+ duration: const Duration(milliseconds: 50),
+ curve: Curves.easeInOut,
+ alignment: AlignmentDirectional.centerStart,
+ child: FlowyHover(
+ style: const HoverStyle(
+ borderRadius: BorderRadius.all(Radius.circular(8)),
+ ),
+ child: Padding(
+ padding: const EdgeInsetsDirectional.all(4.0),
+ child: Row(
+ children: [
+ Padding(
+ // TODO: remove this after change icon to 20px
+ padding: EdgeInsets.all(2),
+ child: FlowySvg(
+ FlowySvgs.ai_sparks_s,
+ color: Theme.of(context).hintColor,
+ size: Size.square(16),
+ ),
+ ),
+ if (model != null && !model!.isDefault)
+ Padding(
+ padding: EdgeInsetsDirectional.only(end: 2.0),
+ child: FlowyText(
+ model!.i18n,
+ fontSize: 12,
+ figmaLineHeight: 16,
+ color: Theme.of(context).hintColor,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ FlowySvg(
+ FlowySvgs.ai_source_drop_down_s,
+ color: Theme.of(context).hintColor,
+ size: const Size.square(8),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_bottom_sheet.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_bottom_sheet.dart
index b69a2210f2..1f1b2ddf4c 100644
--- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_bottom_sheet.dart
+++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_bottom_sheet.dart
@@ -2,7 +2,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/flowy_search_text_field.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
-import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
import 'package:appflowy/plugins/base/drag_handler.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
@@ -19,9 +18,11 @@ import 'select_sources_menu.dart';
class PromptInputMobileSelectSourcesButton extends StatefulWidget {
const PromptInputMobileSelectSourcesButton({
super.key,
+ required this.selectedSourcesNotifier,
required this.onUpdateSelectedSources,
});
+ final ValueNotifier> selectedSourcesNotifier;
final void Function(List) onUpdateSelectedSources;
@override
@@ -36,15 +37,15 @@ class _PromptInputMobileSelectSourcesButtonState
@override
void initState() {
super.initState();
+ widget.selectedSourcesNotifier.addListener(onSelectedSourcesChanged);
WidgetsBinding.instance.addPostFrameCallback((_) {
- cubit.updateSelectedSources(
- context.read().state.selectedSourceIds,
- );
+ onSelectedSourcesChanged();
});
}
@override
void dispose() {
+ widget.selectedSourcesNotifier.removeListener(onSelectedSourcesChanged);
cubit.close();
super.dispose();
}
@@ -70,56 +71,49 @@ class _PromptInputMobileSelectSourcesButtonState
],
child: BlocBuilder(
builder: (context, state) {
- return BlocListener(
- listener: (context, state) {
- cubit
- ..updateSelectedSources(state.selectedSourceIds)
- ..updateSelectedStatus();
- },
- child: FlowyButton(
- margin: const EdgeInsetsDirectional.fromSTEB(4, 6, 2, 6),
- expandText: false,
- text: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- FlowySvg(
- FlowySvgs.ai_page_s,
- color: Theme.of(context).iconTheme.color,
- size: const Size.square(20.0),
- ),
- FlowySvg(
- FlowySvgs.ai_source_drop_down_s,
- color: Theme.of(context).hintColor,
- size: const Size.square(10),
- ),
- ],
- ),
- onTap: () async {
- context
- .read()
- .refreshSources(state.spaces, state.currentSpace);
- await showMobileBottomSheet(
- context,
- backgroundColor: Theme.of(context).colorScheme.surface,
- maxChildSize: 0.98,
- enableDraggableScrollable: true,
- scrollableWidgetBuilder: (_, scrollController) {
- return Expanded(
- child: BlocProvider.value(
- value: cubit,
- child: _MobileSelectSourcesSheetBody(
- scrollController: scrollController,
- ),
- ),
- );
- },
- builder: (context) => const SizedBox.shrink(),
- );
- if (context.mounted) {
- widget.onUpdateSelectedSources(cubit.selectedSourceIds);
- }
- },
+ return FlowyButton(
+ margin: const EdgeInsetsDirectional.fromSTEB(4, 6, 2, 6),
+ expandText: false,
+ text: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ FlowySvg(
+ FlowySvgs.ai_page_s,
+ color: Theme.of(context).iconTheme.color,
+ size: const Size.square(20.0),
+ ),
+ FlowySvg(
+ FlowySvgs.ai_source_drop_down_s,
+ color: Theme.of(context).hintColor,
+ size: const Size.square(10),
+ ),
+ ],
),
+ onTap: () async {
+ context
+ .read()
+ .refreshSources(state.spaces, state.currentSpace);
+ await showMobileBottomSheet(
+ context,
+ backgroundColor: Theme.of(context).colorScheme.surface,
+ maxChildSize: 0.98,
+ enableDraggableScrollable: true,
+ scrollableWidgetBuilder: (_, scrollController) {
+ return Expanded(
+ child: BlocProvider.value(
+ value: cubit,
+ child: _MobileSelectSourcesSheetBody(
+ scrollController: scrollController,
+ ),
+ ),
+ );
+ },
+ builder: (context) => const SizedBox.shrink(),
+ );
+ if (context.mounted) {
+ widget.onUpdateSelectedSources(cubit.selectedSourceIds);
+ }
+ },
);
},
),
@@ -127,6 +121,12 @@ class _PromptInputMobileSelectSourcesButtonState
},
);
}
+
+ void onSelectedSourcesChanged() {
+ cubit
+ ..updateSelectedSources(widget.selectedSourcesNotifier.value)
+ ..updateSelectedStatus();
+ }
}
class _MobileSelectSourcesSheetBody extends StatefulWidget {
diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_menu.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_menu.dart
index df521256d8..51357e6a0b 100644
--- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_menu.dart
+++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_menu.dart
@@ -2,8 +2,8 @@ import 'dart:math';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
-import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
+import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
@@ -24,9 +24,11 @@ import 'mention_page_menu.dart';
class PromptInputDesktopSelectSourcesButton extends StatefulWidget {
const PromptInputDesktopSelectSourcesButton({
super.key,
+ required this.selectedSourcesNotifier,
required this.onUpdateSelectedSources,
});
+ final ValueNotifier> selectedSourcesNotifier;
final void Function(List) onUpdateSelectedSources;
@override
@@ -42,15 +44,15 @@ class _PromptInputDesktopSelectSourcesButtonState
@override
void initState() {
super.initState();
+ widget.selectedSourcesNotifier.addListener(onSelectedSourcesChanged);
WidgetsBinding.instance.addPostFrameCallback((_) {
- cubit.updateSelectedSources(
- context.read().state.selectedSourceIds,
- );
+ onSelectedSourcesChanged();
});
}
@override
void dispose() {
+ widget.selectedSourcesNotifier.removeListener(onSelectedSourcesChanged);
cubit.close();
super.dispose();
}
@@ -76,51 +78,53 @@ class _PromptInputDesktopSelectSourcesButtonState
],
child: BlocBuilder(
builder: (context, state) {
- return BlocListener(
- listener: (context, state) {
- cubit
- ..updateSelectedSources(state.selectedSourceIds)
- ..updateSelectedStatus();
+ return AppFlowyPopover(
+ constraints: BoxConstraints.loose(const Size(320, 380)),
+ offset: const Offset(0.0, -10.0),
+ direction: PopoverDirection.topWithCenterAligned,
+ margin: EdgeInsets.zero,
+ controller: popoverController,
+ onOpen: () {
+ context
+ .read()
+ .refreshSources(state.spaces, state.currentSpace);
},
- child: AppFlowyPopover(
- constraints: BoxConstraints.loose(const Size(320, 380)),
- offset: const Offset(0.0, -10.0),
- direction: PopoverDirection.topWithCenterAligned,
- margin: EdgeInsets.zero,
- controller: popoverController,
- onOpen: () {
- context
- .read()
- .refreshSources(state.spaces, state.currentSpace);
- },
- onClose: () {
- widget.onUpdateSelectedSources(cubit.selectedSourceIds);
- context
- .read()
- .refreshSources(state.spaces, state.currentSpace);
- },
- popupBuilder: (_) {
- return BlocProvider.value(
- value: context.read(),
- child: const _PopoverContent(),
- );
- },
- child: _IndicatorButton(
- onTap: () => popoverController.show(),
- ),
+ onClose: () {
+ widget.onUpdateSelectedSources(cubit.selectedSourceIds);
+ context
+ .read()
+ .refreshSources(state.spaces, state.currentSpace);
+ },
+ popupBuilder: (_) {
+ return BlocProvider.value(
+ value: context.read(),
+ child: const _PopoverContent(),
+ );
+ },
+ child: _IndicatorButton(
+ selectedSourcesNotifier: widget.selectedSourcesNotifier,
+ onTap: () => popoverController.show(),
),
);
},
),
);
}
+
+ void onSelectedSourcesChanged() {
+ cubit
+ ..updateSelectedSources(widget.selectedSourcesNotifier.value)
+ ..updateSelectedStatus();
+ }
}
class _IndicatorButton extends StatelessWidget {
const _IndicatorButton({
+ required this.selectedSourcesNotifier,
required this.onTap,
});
+ final ValueNotifier> selectedSourcesNotifier;
final VoidCallback onTap;
@override
@@ -141,14 +145,22 @@ class _IndicatorButton extends StatelessWidget {
children: [
FlowySvg(
FlowySvgs.ai_page_s,
- color: Theme.of(context).iconTheme.color,
+ color: Theme.of(context).hintColor,
),
const HSpace(2.0),
- BlocBuilder(
- builder: (context, state) {
+ ValueListenableBuilder(
+ valueListenable: selectedSourcesNotifier,
+ builder: (context, selectedSourceIds, _) {
+ final documentId =
+ context.read()?.documentId;
+ final label = documentId != null &&
+ selectedSourceIds.length == 1 &&
+ selectedSourceIds[0] == documentId
+ ? LocaleKeys.chat_currentPage.tr()
+ : selectedSourceIds.length.toString();
return FlowyText(
- state.selectedSourceIds.length.toString(),
- fontSize: 14,
+ label,
+ fontSize: 12,
figmaLineHeight: 16,
color: Theme.of(context).hintColor,
);
@@ -158,7 +170,7 @@ class _IndicatorButton extends StatelessWidget {
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
- size: const Size.square(10),
+ size: const Size.square(8),
),
],
),
diff --git a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/send_button.dart b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/send_button.dart
index 20def4ca5e..cca6e65f63 100644
--- a/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/send_button.dart
+++ b/frontend/appflowy_flutter/lib/ai/widgets/prompt_input/send_button.dart
@@ -1,4 +1,6 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:universal_platform/universal_platform.dart';
@@ -23,6 +25,23 @@ class PromptInputSendButton extends StatelessWidget {
Widget build(BuildContext context) {
return FlowyIconButton(
width: _buttonSize,
+ richTooltipText: switch (state) {
+ SendButtonState.streaming => TextSpan(
+ children: [
+ TextSpan(
+ text: '${LocaleKeys.chat_stopTooltip.tr()} ',
+ style: context.tooltipTextStyle(),
+ ),
+ TextSpan(
+ text: 'ESC',
+ style: context
+ .tooltipTextStyle()
+ ?.copyWith(color: Theme.of(context).hintColor),
+ ),
+ ],
+ ),
+ _ => null,
+ },
icon: switch (state) {
SendButtonState.enabled => FlowySvg(
FlowySvgs.ai_send_filled_s,
diff --git a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart
index b9f59d6434..aefd5e5d36 100644
--- a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart
+++ b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart
@@ -115,4 +115,9 @@ class KVKeys {
///
/// The value is a json string of [RecentIcons]
static const String recentIcons = 'kRecentIcons';
+
+ /// The key for saving compact mode ids for node or databse view
+ ///
+ /// The value is a json list of id
+ static const String compactModeIds = 'compactModeIds';
}
diff --git a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart
index 2b0bc7b345..0502e79604 100644
--- a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart
+++ b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart
@@ -10,6 +10,7 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:open_filex/open_filex.dart';
import 'package:string_validator/string_validator.dart';
+import 'package:universal_platform/universal_platform.dart';
import 'package:url_launcher/url_launcher.dart' as launcher;
typedef OnFailureCallback = void Function(Uri uri);
@@ -38,17 +39,24 @@ Future afLaunchUri(
);
}
+ // on Linux, add http scheme to the url if it is not present
+ if (UniversalPlatform.isLinux && !isURL(url, {'require_protocol': true})) {
+ uri = Uri.parse('https://$url');
+ }
+
// try to launch the uri directly
- bool result;
- try {
- result = await launcher.launchUrl(
- uri,
- mode: mode,
- webOnlyWindowName: webOnlyWindowName,
- );
- } on PlatformException catch (e) {
- Log.error('Failed to open uri: $e');
- return false;
+ bool result = await launcher.canLaunchUrl(uri);
+ if (result) {
+ try {
+ result = await launcher.launchUrl(
+ uri,
+ mode: mode,
+ webOnlyWindowName: webOnlyWindowName,
+ );
+ } on PlatformException catch (e) {
+ Log.error('Failed to open uri: $e');
+ return false;
+ }
}
// if the uri is not a valid url, try to launch it with http scheme
@@ -127,7 +135,6 @@ Future _afLaunchLocalUri(
};
if (context != null && context.mounted) {
showToastNotification(
- context,
message: message,
type: result.type == ResultType.done
? ToastificationType.success
diff --git a/frontend/appflowy_flutter/lib/env/cloud_env.dart b/frontend/appflowy_flutter/lib/env/cloud_env.dart
index 8f4d195eb7..15f3ada42e 100644
--- a/frontend/appflowy_flutter/lib/env/cloud_env.dart
+++ b/frontend/appflowy_flutter/lib/env/cloud_env.dart
@@ -100,6 +100,10 @@ bool get isAuthEnabled {
return false;
}
+bool get isLocalAuthEnabled {
+ return currentCloudType().isLocal;
+}
+
/// Determines if AppFlowy Cloud is enabled.
bool get isAppFlowyCloudEnabled {
return currentCloudType().isAppFlowyCloudEnabled;
@@ -180,7 +184,7 @@ Future useLocalServer() async {
await _setAuthenticatorType(AuthenticatorType.local);
}
-/// Use getIt() to get the shared environment.
+// Use getIt() to get the shared environment.
class AppFlowyCloudSharedEnv {
AppFlowyCloudSharedEnv({
required AuthenticatorType authenticatorType,
@@ -247,6 +251,7 @@ Future configurationFromUri(
// In development mode, the app is configured to access the AppFlowy cloud server directly through specific ports.
// This setup bypasses the need for Nginx, meaning that the AppFlowy cloud should be running without an Nginx server
// in the development environment.
+ // If you modify following code, please update the corresponding documentation in the appflowy billing.
if (authenticatorType == AuthenticatorType.appflowyCloudDevelop) {
return AppFlowyCloudConfiguration(
base_url: "$baseUrl:8000",
diff --git a/frontend/appflowy_flutter/lib/flutter/af_dropdown_menu.dart b/frontend/appflowy_flutter/lib/flutter/af_dropdown_menu.dart
index 1cc846339b..157be012b1 100644
--- a/frontend/appflowy_flutter/lib/flutter/af_dropdown_menu.dart
+++ b/frontend/appflowy_flutter/lib/flutter/af_dropdown_menu.dart
@@ -16,6 +16,8 @@ const double _kMinimumWidth = 112.0;
const double _kDefaultHorizontalPadding = 12.0;
+typedef CompareFunction = bool Function(T? left, T? right);
+
// Navigation shortcuts to move the selected menu items up or down.
final Map _kMenuTraversalShortcuts =
{
@@ -86,6 +88,7 @@ class AFDropdownMenu extends StatefulWidget {
this.requestFocusOnTap,
this.expandedInsets,
this.searchCallback,
+ this.selectOptionCompare,
required this.dropdownMenuEntries,
});
@@ -267,6 +270,11 @@ class AFDropdownMenu extends StatefulWidget {
/// which contains the contents of the text input field.
final SearchCallback? searchCallback;
+ /// Defines the compare function for the menu items.
+ ///
+ /// Defaults to null. If this is null, the menu items will be sorted by the label.
+ final CompareFunction? selectOptionCompare;
+
@override
State> createState() => _AFDropdownMenuState();
}
@@ -301,7 +309,16 @@ class _AFDropdownMenuState extends State> {
filteredEntries.any((DropdownMenuEntry entry) => entry.enabled);
final int index = filteredEntries.indexWhere(
- (DropdownMenuEntry entry) => entry.value == widget.initialSelection,
+ (DropdownMenuEntry entry) {
+ if (widget.selectOptionCompare != null) {
+ return widget.selectOptionCompare!(
+ entry.value,
+ widget.initialSelection,
+ );
+ } else {
+ return entry.value == widget.initialSelection;
+ }
+ },
);
if (index != -1) {
_textEditingController.value = TextEditingValue(
@@ -502,11 +519,11 @@ class _AFDropdownMenuState extends State> {
// Simulate the focused state because the text field should always be focused
// during traversal. If the menu item has a custom foreground color, the "focused"
- // color will also change to foregroundColor.withOpacity(0.12).
+ // color will also change to foregroundColor.withValues(alpha: 0.12).
effectiveStyle = entry.enabled && i == focusedIndex
? effectiveStyle.copyWith(
backgroundColor: WidgetStatePropertyAll(
- focusedBackgroundColor.withOpacity(0.12),
+ focusedBackgroundColor.withValues(alpha: 0.12),
),
)
: effectiveStyle;
diff --git a/frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart b/frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart
index 1480cc02e9..0527316860 100644
--- a/frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart
+++ b/frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart
@@ -19,14 +19,13 @@ class UserProfileBloc extends Bloc {
Future _initialize(Emitter emit) async {
emit(const UserProfileState.loading());
-
- final workspaceOrFailure =
+ final latestOrFailure =
await FolderEventGetCurrentWorkspaceSetting().send();
final userOrFailure = await getIt().getUser();
- final workspaceSetting = workspaceOrFailure.fold(
- (workspaceSettingPB) => workspaceSettingPB,
+ final latest = latestOrFailure.fold(
+ (latestPB) => latestPB,
(error) => null,
);
@@ -35,13 +34,13 @@ class UserProfileBloc extends Bloc {
(error) => null,
);
- if (workspaceSetting == null || userProfile == null) {
+ if (latest == null || userProfile == null) {
return emit(const UserProfileState.workspaceFailure());
}
emit(
UserProfileState.success(
- workspaceSettings: workspaceSetting,
+ workspaceSettings: latest,
userProfile: userProfile,
),
);
@@ -59,7 +58,7 @@ class UserProfileState with _$UserProfileState {
const factory UserProfileState.loading() = _Loading;
const factory UserProfileState.workspaceFailure() = _WorkspaceFailure;
const factory UserProfileState.success({
- required WorkspaceSettingPB workspaceSettings,
+ required WorkspaceLatestPB workspaceSettings,
required UserProfilePB userProfile,
}) = _Success;
}
diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart
index 0535ec76b8..318b06394a 100644
--- a/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart
@@ -1,3 +1,4 @@
+import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/base/mobile_view_page_bloc.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
@@ -7,6 +8,7 @@ import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/plugins/document/presentation/document_collaborators.dart';
+import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
@@ -18,6 +20,9 @@ import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
+import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
+import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
+import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -91,7 +96,7 @@ class _MobileViewPageState extends State {
final body = _buildBody(context, state);
if (view == null) {
- return _buildApp(context, null, body);
+ return SizedBox.shrink();
}
return MultiBlocProvider(
@@ -122,6 +127,11 @@ class _MobileViewPageState extends State {
create: (_) => DocumentPageStyleBloc(view: view)
..add(const DocumentPageStyleEvent.initial()),
),
+ if (view.layout.isDocumentView || view.layout.isDatabaseView)
+ BlocProvider(
+ create: (_) => ViewLockStatusBloc(view: view)
+ ..add(const ViewLockStatusEvent.initial()),
+ ),
],
child: Builder(
builder: (context) {
@@ -152,6 +162,7 @@ class _MobileViewPageState extends State {
title: title,
appBarOpacity: _appBarOpacity,
actions: actions,
+ view: view,
)
: FlowyAppBar(title: title, actions: actions);
final body = isDocument
@@ -222,6 +233,8 @@ class _MobileViewPageState extends State {
final isImmersiveMode =
context.read().state.isImmersiveMode;
+ final isLocked =
+ context.read()?.state.isLocked ?? false;
final actions = [];
if (FeatureFlag.syncDocument.isOn) {
@@ -240,7 +253,7 @@ class _MobileViewPageState extends State {
}
}
- if (view.layout.isDocumentView) {
+ if (view.layout.isDocumentView && !isLocked) {
actions.addAll([
MobileViewPageLayoutButton(
view: view,
@@ -270,25 +283,137 @@ class _MobileViewPageState extends State {
Widget _buildTitle(BuildContext context, ViewPB? view) {
final icon = view?.icon;
- return Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- if (icon != null && icon.value.isNotEmpty) ...[
- RawEmojiIconWidget(
- emoji: icon.toEmojiIconData(),
- emojiSize: 15,
+ return ValueListenableBuilder(
+ valueListenable: _appBarOpacity,
+ builder: (_, value, child) {
+ if (value < 0.99) {
+ return Padding(
+ padding: const EdgeInsets.only(left: 6.0),
+ child: _buildLockStatus(context, view),
+ );
+ }
+
+ final name =
+ widget.fixedTitle ?? view?.nameOrDefault ?? widget.title ?? '';
+
+ return Opacity(
+ opacity: value,
+ child: Row(
+ children: [
+ if (icon != null && icon.value.isNotEmpty) ...[
+ RawEmojiIconWidget(
+ emoji: icon.toEmojiIconData(),
+ emojiSize: 15,
+ ),
+ const HSpace(4),
+ ],
+ Flexible(
+ child: FlowyText.medium(
+ name,
+ fontSize: 15.0,
+ overflow: TextOverflow.ellipsis,
+ figmaLineHeight: 18.0,
+ ),
+ ),
+ const HSpace(4.0),
+ _buildLockStatusIcon(context, view),
+ ],
),
- const HSpace(4),
- ],
- Expanded(
- child: FlowyText.medium(
- widget.fixedTitle ?? view?.name ?? widget.title ?? '',
- fontSize: 15.0,
- overflow: TextOverflow.ellipsis,
- figmaLineHeight: 18.0,
- ),
- ),
- ],
+ );
+ },
+ );
+ }
+
+ Widget _buildLockStatus(BuildContext context, ViewPB? view) {
+ if (view == null || view.layout == ViewLayoutPB.Chat) {
+ return const SizedBox.shrink();
+ }
+
+ return BlocConsumer(
+ listenWhen: (previous, current) =>
+ previous.isLoadingLockStatus == current.isLoadingLockStatus &&
+ current.isLoadingLockStatus == false,
+ listener: (context, state) {
+ if (state.isLocked) {
+ showToastNotification(
+ message: LocaleKeys.lockPage_pageLockedToast.tr(),
+ );
+
+ EditorNotification.exitEditing().post();
+ }
+ },
+ builder: (context, state) {
+ if (state.isLocked) {
+ return LockedPageStatus();
+ } else if (!state.isLocked && state.lockCounter > 0) {
+ return ReLockedPageStatus();
+ }
+ return const SizedBox.shrink();
+ },
+ );
+ }
+
+ Widget _buildLockStatusIcon(BuildContext context, ViewPB? view) {
+ if (view == null || view.layout == ViewLayoutPB.Chat) {
+ return const SizedBox.shrink();
+ }
+
+ return BlocConsumer(
+ listenWhen: (previous, current) =>
+ previous.isLoadingLockStatus == current.isLoadingLockStatus &&
+ current.isLoadingLockStatus == false,
+ listener: (context, state) {
+ if (state.isLocked) {
+ showToastNotification(
+ message: LocaleKeys.lockPage_pageLockedToast.tr(),
+ );
+ }
+ },
+ builder: (context, state) {
+ if (state.isLocked) {
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: () {
+ context.read().add(
+ const ViewLockStatusEvent.unlock(),
+ );
+ },
+ child: Padding(
+ padding: const EdgeInsets.only(
+ top: 4.0,
+ right: 8,
+ bottom: 4.0,
+ ),
+ child: FlowySvg(
+ FlowySvgs.lock_page_fill_s,
+ blendMode: null,
+ ),
+ ),
+ );
+ } else if (!state.isLocked && state.lockCounter > 0) {
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: () {
+ context.read().add(
+ const ViewLockStatusEvent.lock(),
+ );
+ },
+ child: Padding(
+ padding: const EdgeInsets.only(
+ top: 4.0,
+ right: 8,
+ bottom: 4.0,
+ ),
+ child: FlowySvg(
+ FlowySvgs.unlock_page_s,
+ color: Color(0xFF8F959E),
+ blendMode: null,
+ ),
+ ),
+ );
+ }
+ return const SizedBox.shrink();
+ },
);
}
diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/app_bar_buttons.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/app_bar_buttons.dart
index b01f72c371..a91fbf577b 100644
--- a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/app_bar_buttons.dart
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/app_bar_buttons.dart
@@ -12,6 +12,7 @@ import 'package:appflowy/plugins/shared/share/share_bloc.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
+import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
@@ -28,12 +29,13 @@ class MobileViewPageImmersiveAppBar extends StatelessWidget
required this.appBarOpacity,
required this.title,
required this.actions,
+ required this.view,
});
final ValueListenable appBarOpacity;
final Widget title;
final List actions;
-
+ final ViewPB? view;
@override
final Size preferredSize;
@@ -43,9 +45,9 @@ class MobileViewPageImmersiveAppBar extends StatelessWidget
valueListenable: appBarOpacity,
builder: (_, opacity, __) => FlowyAppBar(
backgroundColor:
- AppBarTheme.of(context).backgroundColor?.withOpacity(opacity),
+ AppBarTheme.of(context).backgroundColor?.withValues(alpha: opacity),
showDivider: false,
- title: Opacity(opacity: opacity >= 0.99 ? 1.0 : 0, child: title),
+ title: _buildTitle(context, opacity: opacity),
leadingWidth: 44,
leading: Padding(
padding: const EdgeInsets.only(top: 4.0, bottom: 4.0, left: 12.0),
@@ -56,6 +58,13 @@ class MobileViewPageImmersiveAppBar extends StatelessWidget
);
}
+ Widget _buildTitle(
+ BuildContext context, {
+ required double opacity,
+ }) {
+ return title;
+ }
+
Widget _buildAppBarBackButton(BuildContext context) {
return AppBarButton(
padding: EdgeInsets.zero,
@@ -102,6 +111,12 @@ class MobileViewPageMoreButton extends StatelessWidget {
BlocProvider.value(value: context.read()),
BlocProvider.value(value: context.read()),
BlocProvider.value(value: context.read()),
+ BlocProvider(
+ create: (context) => ViewLockStatusBloc(view: view)
+ ..add(
+ ViewLockStatusEvent.initial(),
+ ),
+ ),
],
child: MobileViewPageMoreBottomSheet(view: view),
),
@@ -224,7 +239,7 @@ class _ImmersiveAppBarButton extends StatelessWidget {
child = DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(dimension / 2.0),
- color: Colors.black.withOpacity(0.2),
+ color: Colors.black.withValues(alpha: 0.2),
),
child: child,
);
diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart
index 25541ed7d4..be134e0a92 100644
--- a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart
@@ -14,6 +14,7 @@ import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
+import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
@@ -43,7 +44,8 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
},
child: ViewPageBottomSheet(
view: view,
- onAction: (action) async => _onAction(context, action),
+ onAction: (action, {arguments}) async =>
+ _onAction(context, action, arguments),
onRename: (name) {
_onRename(context, name);
context.pop();
@@ -56,6 +58,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
Future _onAction(
BuildContext context,
MobileViewBottomSheetBodyAction action,
+ Map? arguments,
) async {
switch (action) {
case MobileViewBottomSheetBodyAction.duplicate:
@@ -63,7 +66,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
break;
case MobileViewBottomSheetBodyAction.delete:
context.read().add(const ViewEvent.delete());
- context.pop();
+ Navigator.of(context).pop();
break;
case MobileViewBottomSheetBodyAction.addToFavorites:
_addFavorite(context);
@@ -107,12 +110,32 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
break;
case MobileViewBottomSheetBodyAction.updatePathName:
_updatePathName(context);
+ case MobileViewBottomSheetBodyAction.lockPage:
+ final isLocked =
+ arguments?[MobileViewBottomSheetBodyActionArguments.isLockedKey] ??
+ false;
+ await _lockPage(context, isLocked: isLocked);
+ // context.pop();
+ break;
case MobileViewBottomSheetBodyAction.rename:
// no need to implement, rename is handled by the onRename callback.
throw UnimplementedError();
}
}
+ Future _lockPage(
+ BuildContext context, {
+ required bool isLocked,
+ }) async {
+ if (isLocked) {
+ context.read().add(const ViewLockStatusEvent.lock());
+ } else {
+ context
+ .read()
+ .add(const ViewLockStatusEvent.unlock());
+ }
+ }
+
Future _publish(BuildContext context) async {
final id = context.read().view.id;
final lastPublishName = context.read().state.pathName;
@@ -138,7 +161,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
context.pop();
showToastNotification(
- context,
message: LocaleKeys.button_duplicateSuccessfully.tr(),
);
}
@@ -147,7 +169,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
_toggleFavorite(context);
showToastNotification(
- context,
message: LocaleKeys.button_favoriteSuccessfully.tr(),
);
}
@@ -156,7 +177,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
_toggleFavorite(context);
showToastNotification(
- context,
message: LocaleKeys.button_unfavoriteSuccessfully.tr(),
);
}
@@ -179,8 +199,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
),
);
showToastNotification(
- context,
- message: LocaleKeys.grid_url_copy.tr(),
+ message: LocaleKeys.message_copy_success.tr(),
);
}
}
@@ -211,12 +230,10 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
),
);
showToastNotification(
- context,
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
);
} else {
showToastNotification(
- context,
message: LocaleKeys.shareAction_copyLinkToBlockFailed.tr(),
type: ToastificationType.error,
);
@@ -300,11 +317,9 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
if (state.publishResult != null) {
state.publishResult!.fold(
(value) => showToastNotification(
- context,
message: LocaleKeys.publish_publishSuccessfully.tr(),
),
(error) => showToastNotification(
- context,
message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}',
type: ToastificationType.error,
),
@@ -312,11 +327,9 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
} else if (state.unpublishResult != null) {
state.unpublishResult!.fold(
(value) => showToastNotification(
- context,
message: LocaleKeys.publish_unpublishSuccessfully.tr(),
),
(error) => showToastNotification(
- context,
message: LocaleKeys.publish_unpublishFailed.tr(),
description: error.msg,
type: ToastificationType.error,
@@ -326,7 +339,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
state.updatePathNameResult!.onSuccess(
(value) {
showToastNotification(
- context,
message:
LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(),
);
diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart
index c1129af79d..86021ea938 100644
--- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart
@@ -65,7 +65,6 @@ class _MobileViewItemBottomSheetState extends State {
Navigator.pop(context);
context.read().add(const ViewEvent.duplicate());
showToastNotification(
- context,
message: LocaleKeys.button_duplicateSuccessfully.tr(),
);
break;
@@ -84,7 +83,6 @@ class _MobileViewItemBottomSheetState extends State {
.read()
.add(FavoriteEvent.toggle(widget.view));
showToastNotification(
- context,
message: !widget.view.isFavorite
? LocaleKeys.button_favoriteSuccessfully.tr()
: LocaleKeys.button_unfavoriteSuccessfully.tr(),
@@ -146,7 +144,6 @@ class _MobileViewItemBottomSheetState extends State {
Navigator.pop(context);
showToastNotification(
- context,
message: LocaleKeys.sideBar_removeSuccess.tr(),
);
},
diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item_body.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item_body.dart
index a078521aec..0ca60fe40b 100644
--- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item_body.dart
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item_body.dart
@@ -1,8 +1,10 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
+import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
enum MobileViewItemBottomSheetBodyAction {
rename,
@@ -40,6 +42,8 @@ class MobileViewItemBottomSheetBody extends StatelessWidget {
BuildContext context,
MobileViewItemBottomSheetBodyAction action,
) {
+ final isLocked =
+ context.read()?.state.isLocked ?? false;
switch (action) {
case MobileViewItemBottomSheetBodyAction.rename:
return FlowyOptionTile.text(
@@ -49,6 +53,7 @@ class MobileViewItemBottomSheetBody extends StatelessWidget {
FlowySvgs.view_item_rename_s,
size: Size.square(18),
),
+ enable: !isLocked,
showTopBorder: false,
showBottomBorder: false,
onTap: () => onAction(
@@ -94,6 +99,7 @@ class MobileViewItemBottomSheetBody extends StatelessWidget {
size: const Size.square(18),
color: Theme.of(context).colorScheme.error,
),
+ enable: !isLocked,
showTopBorder: false,
showBottomBorder: false,
onTap: () => onAction(
diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart
index 2e08d0f368..9706777df0 100644
--- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart
+++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart
@@ -4,9 +4,12 @@ import 'package:appflowy/mobile/application/base/mobile_view_page_bloc.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart';
import 'package:appflowy/plugins/shared/share/share_bloc.dart';
+import 'package:appflowy/workspace/application/view/view_ext.dart';
+import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -25,11 +28,27 @@ enum MobileViewBottomSheetBodyAction {
visitSite,
copyShareLink,
updatePathName,
+ lockPage;
+
+ static const disableInLockedView = [
+ undo,
+ redo,
+ rename,
+ delete,
+ ];
+}
+
+class MobileViewBottomSheetBodyActionArguments {
+ static const isLockedKey = 'is_locked';
}
typedef MobileViewBottomSheetBodyActionCallback = void Function(
MobileViewBottomSheetBodyAction action,
-);
+ // for the [MobileViewBottomSheetBodyAction.lockPage] action,
+ // it will pass the [isLocked] value to the callback.
+ {
+ Map