feat: integrate cloud document search (#5523)

This commit is contained in:
Mathias Mogensen 2024-06-13 01:37:19 +02:00 committed by GitHub
parent 4f4be7eac7
commit bd5f5f8b9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 539 additions and 110 deletions

View File

@ -20,7 +20,6 @@ class CommandPaletteBloc
CommandPaletteBloc() : super(CommandPaletteState.initial()) {
_searchListener.start(
onResultsChanged: _onResultsChanged,
onResultsClosed: _onResultsClosed,
);
_initTrash();
@ -35,6 +34,7 @@ class CommandPaletteBloc
final TrashListener _trashListener = TrashListener();
String? _oldQuery;
String? _workspaceId;
int _messagesReceived = 0;
@override
Future<void> close() {
@ -75,18 +75,22 @@ class CommandPaletteBloc
emit(state.copyWith(query: null, isLoading: false, results: []));
}
},
resultsChanged: (results, didClose) {
resultsChanged: (results, max) {
if (state.query != _oldQuery) {
emit(state.copyWith(results: []));
_oldQuery = state.query;
_messagesReceived = 0;
}
_messagesReceived++;
final searchResults = _filterDuplicates(results.items);
searchResults.sort((a, b) => b.score.compareTo(a.score));
emit(
state.copyWith(
results: searchResults,
isLoading: !didClose,
isLoading: _messagesReceived != max,
),
);
},
@ -94,6 +98,9 @@ class CommandPaletteBloc
_workspaceId = workspaceId;
emit(state.copyWith(results: [], query: '', isLoading: false));
},
clearSearch: () {
emit(state.copyWith(results: [], query: '', isLoading: false));
},
);
});
}
@ -125,6 +132,10 @@ class CommandPaletteBloc
final res = [...results];
for (final item in results) {
if (item.data.trim().isEmpty) {
continue;
}
final duplicateIndex = currentItems.indexWhere((a) => a.id == item.id);
if (duplicateIndex == -1) {
continue;
@ -145,10 +156,7 @@ class CommandPaletteBloc
add(CommandPaletteEvent.performSearch(search: value));
void _onResultsChanged(RepeatedSearchResultPB results) =>
add(CommandPaletteEvent.resultsChanged(results: results));
void _onResultsClosed(RepeatedSearchResultPB results) =>
add(CommandPaletteEvent.resultsChanged(results: results, didClose: true));
add(CommandPaletteEvent.resultsChanged(results: results, max: 2));
}
@freezed
@ -161,7 +169,7 @@ class CommandPaletteEvent with _$CommandPaletteEvent {
const factory CommandPaletteEvent.resultsChanged({
required RepeatedSearchResultPB results,
@Default(false) bool didClose,
@Default(1) int max,
}) = _ResultsChanged;
const factory CommandPaletteEvent.trashChanged({
@ -171,6 +179,8 @@ class CommandPaletteEvent with _$CommandPaletteEvent {
const factory CommandPaletteEvent.workspaceChanged({
@Default(null) String? workspaceId,
}) = _WorkspaceChanged;
const factory CommandPaletteEvent.clearSearch() = _ClearSearch;
}
@freezed

View File

@ -59,13 +59,6 @@ class SearchListener {
(err) => Log.error(err),
);
break;
case SearchNotification.DidCloseResults:
result.fold(
(payload) => _updateDidCloseNotifier?.value =
RepeatedSearchResultPB.fromBuffer(payload),
(err) => Log.error(err),
);
break;
default:
break;
}

View File

@ -133,8 +133,7 @@ class CommandPaletteModal extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
SearchField(query: state.query, isLoading: state.isLoading),
if ((state.query?.isEmpty ?? true) ||
state.isLoading && state.results.isEmpty) ...[
if (state.query?.isEmpty ?? true) ...[
const Divider(height: 0),
Flexible(
child: RecentViewsList(
@ -150,6 +149,9 @@ class CommandPaletteModal extends StatelessWidget {
results: state.results,
),
),
] else if ((state.query?.isNotEmpty ?? false) &&
!state.isLoading) ...[
const _NoResultsHint(),
],
_CommandPaletteFooter(
shouldShow: state.results.isNotEmpty &&
@ -163,6 +165,27 @@ class CommandPaletteModal extends StatelessWidget {
}
}
class _NoResultsHint extends StatelessWidget {
const _NoResultsHint();
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(height: 0),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: FlowyText.regular(
LocaleKeys.commandPalette_noResultsHint.tr(),
textAlign: TextAlign.left,
),
),
],
);
}
}
class _CommandPaletteFooter extends StatelessWidget {
const _CommandPaletteFooter({required this.shouldShow});
@ -177,6 +200,7 @@ class _CommandPaletteFooter extends StatelessWidget {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
border: Border(top: BorderSide(color: Theme.of(context).dividerColor)),
),
child: Row(

View File

@ -26,12 +26,12 @@ class RecentViewTile extends StatelessWidget {
title: Row(
children: [
icon,
const HSpace(4),
const HSpace(6),
FlowyText(view.name),
],
),
focusColor: Theme.of(context).colorScheme.primary.withOpacity(0.5),
hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.5),
focusColor: Theme.of(context).colorScheme.primary.withOpacity(0.1),
hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.1),
onTap: () {
onSelected();

View File

@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart';
@ -6,7 +8,6 @@ import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_v
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class RecentViewsList extends StatelessWidget {
@ -51,7 +52,7 @@ class RecentViewsList extends StatelessWidget {
: FlowySvg(view.iconData, size: const Size.square(20));
return RecentViewTile(
icon: icon,
icon: SizedBox(width: 24, child: icon),
view: view,
onSelected: onSelected,
);

View File

@ -1,4 +1,5 @@
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';
@ -23,12 +24,24 @@ class SearchField extends StatefulWidget {
}
class _SearchFieldState extends State<SearchField> {
final focusNode = FocusNode();
late final FocusNode focusNode;
late final controller = TextEditingController(text: widget.query);
@override
void initState() {
super.initState();
focusNode = FocusNode(
onKeyEvent: (node, event) {
if (node.hasFocus &&
event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.arrowDown) {
node.nextFocus();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
);
focusNode.requestFocus();
controller.selection = TextSelection(
baseOffset: 0,
@ -75,34 +88,77 @@ class _SearchFieldState extends State<SearchField> {
.textTheme
.bodySmall!
.copyWith(color: Theme.of(context).colorScheme.error),
// TODO(Mathias): Remove beta when support document/database search
suffix: FlowyTooltip(
message: LocaleKeys.commandPalette_betaTooltip.tr(),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 5,
vertical: 1,
suffix: Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedOpacity(
opacity: controller.text.trim().isNotEmpty ? 1 : 0,
duration: const Duration(milliseconds: 200),
child: Builder(
builder: (context) {
final icon = Container(
padding: const EdgeInsets.all(1),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AFThemeExtension.of(context).lightGreyHover,
),
child: const FlowySvg(
FlowySvgs.close_s,
size: Size.square(16),
),
);
if (controller.text.isEmpty) {
return icon;
}
return FlowyTooltip(
message:
LocaleKeys.commandPalette_clearSearchTooltip.tr(),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: controller.text.trim().isNotEmpty
? _clearSearch
: null,
child: icon,
),
),
);
},
),
),
decoration: BoxDecoration(
color: AFThemeExtension.of(context).lightGreyHover,
borderRadius: BorderRadius.circular(4),
const HSpace(8),
// TODO(Mathias): Remove beta when support database search
FlowyTooltip(
message: LocaleKeys.commandPalette_betaTooltip.tr(),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 5,
vertical: 2,
),
decoration: BoxDecoration(
color: AFThemeExtension.of(context).lightGreyHover,
borderRadius: BorderRadius.circular(4),
),
child: FlowyText.semibold(
LocaleKeys.commandPalette_betaLabel.tr(),
fontSize: 11,
lineHeight: 1.2,
),
),
),
child: FlowyText.semibold(
LocaleKeys.commandPalette_betaLabel.tr(),
fontSize: 10,
),
),
],
),
counterText: "",
focusedBorder: const OutlineInputBorder(
borderSide: BorderSide(color: Colors.transparent),
borderRadius: Corners.s8Border,
borderSide: BorderSide(color: Colors.transparent),
),
errorBorder: OutlineInputBorder(
borderRadius: Corners.s8Border,
borderSide: BorderSide(
color: Theme.of(context).colorScheme.error,
),
borderRadius: Corners.s8Border,
),
),
onChanged: (value) => context
@ -111,7 +167,6 @@ class _SearchFieldState extends State<SearchField> {
),
),
if (widget.isLoading) ...[
const HSpace(12),
FlowyTooltip(
message: LocaleKeys.commandPalette_loadingTooltip.tr(),
child: const SizedBox(
@ -125,4 +180,11 @@ class _SearchFieldState extends State<SearchField> {
],
);
}
void _clearSearch() {
controller.clear();
context
.read<CommandPaletteBloc>()
.add(const CommandPaletteEvent.clearSearch());
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart';
@ -8,10 +9,11 @@ import 'package:appflowy/workspace/application/command_palette/search_result_ext
import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
class SearchResultTile extends StatelessWidget {
class SearchResultTile extends StatefulWidget {
const SearchResultTile({
super.key,
required this.result,
@ -24,40 +26,123 @@ class SearchResultTile extends StatelessWidget {
final bool isTrashed;
@override
Widget build(BuildContext context) {
final icon = result.getIcon();
State<SearchResultTile> createState() => _SearchResultTileState();
}
return ListTile(
dense: true,
title: Row(
children: [
if (icon != null) ...[icon, const HSpace(6)],
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isTrashed) ...[
FlowyText(
LocaleKeys.commandPalette_fromTrashHint.tr(),
color: AFThemeExtension.of(context).textColor.withAlpha(175),
fontSize: 10,
),
],
FlowyText(result.data),
],
),
],
),
focusColor: Theme.of(context).colorScheme.primary.withOpacity(0.5),
hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.5),
class _SearchResultTileState extends State<SearchResultTile> {
bool _hasFocus = false;
final focusNode = FocusNode();
@override
void dispose() {
focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final icon = widget.result.getIcon();
final cleanedPreview = _cleanPreview(widget.result.preview);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
onSelected();
widget.onSelected();
getIt<ActionNavigationBloc>().add(
ActionNavigationEvent.performAction(
action: NavigationAction(objectId: result.viewId),
action: NavigationAction(objectId: widget.result.viewId),
),
);
},
child: Focus(
onKeyEvent: (node, event) {
if (event is! KeyDownEvent) {
return KeyEventResult.ignored;
}
if (event.logicalKey == LogicalKeyboardKey.enter) {
widget.onSelected();
getIt<ActionNavigationBloc>().add(
ActionNavigationEvent.performAction(
action: NavigationAction(objectId: widget.result.viewId),
),
);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
onFocusChange: (hasFocus) => setState(() => _hasFocus = hasFocus),
child: FlowyHover(
isSelected: () => _hasFocus,
style: HoverStyle(
hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.1),
foregroundColorOnHover: AFThemeExtension.of(context).textColor,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (icon != null) ...[
SizedBox(width: 24, child: icon),
const HSpace(6),
],
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.isTrashed) ...[
FlowyText(
LocaleKeys.commandPalette_fromTrashHint.tr(),
color: AFThemeExtension.of(context)
.textColor
.withAlpha(175),
fontSize: 10,
),
],
FlowyText(widget.result.data),
],
),
],
),
if (cleanedPreview.isNotEmpty) ...[
const VSpace(4),
_DocumentPreview(preview: cleanedPreview),
],
],
),
),
),
),
);
}
String _cleanPreview(String preview) {
return preview.replaceAll('\n', ' ').trim();
}
}
class _DocumentPreview extends StatelessWidget {
const _DocumentPreview({required this.preview});
final String preview;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16) +
const EdgeInsets.only(left: 14),
child: FlowyText.regular(
preview,
color: Theme.of(context).hintColor,
fontSize: 12,
maxLines: 3,
),
);
}
}

View File

@ -1330,7 +1330,7 @@ dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa 1.0.6",
"phf 0.11.2",
"phf 0.8.0",
"smallvec",
]
@ -1927,6 +1927,7 @@ dependencies = [
"flowy-folder",
"flowy-folder-pub",
"flowy-search",
"flowy-search-pub",
"flowy-server",
"flowy-server-pub",
"flowy-sqlite",
@ -2216,6 +2217,7 @@ dependencies = [
"flowy-user",
"futures",
"lib-dispatch",
"lib-infra",
"protobuf",
"serde",
"serde_json",
@ -2232,9 +2234,12 @@ dependencies = [
name = "flowy-search-pub"
version = "0.1.0"
dependencies = [
"client-api",
"collab",
"collab-folder",
"flowy-error",
"futures",
"lib-infra",
]
[[package]]
@ -2256,6 +2261,7 @@ dependencies = [
"flowy-encrypt",
"flowy-error",
"flowy-folder-pub",
"flowy-search-pub",
"flowy-server-pub",
"flowy-storage",
"flowy-user-pub",
@ -4808,7 +4814,7 @@ checksum = "c55e02e35260070b6f716a2423c2ff1c3bb1642ddca6f99e1f26d06268a0e2d2"
dependencies = [
"bytes",
"heck 0.4.1",
"itertools 0.11.0",
"itertools 0.10.5",
"log",
"multimap",
"once_cell",
@ -4829,7 +4835,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efb6c9a1dd1def8e2124d17e83a20af56f1570d6c2d2bd9e266ccb768df3840e"
dependencies = [
"anyhow",
"itertools 0.11.0",
"itertools 0.10.5",
"proc-macro2",
"quote",
"syn 2.0.47",

View File

@ -1519,9 +1519,12 @@ dependencies = [
name = "flowy-search-pub"
version = "0.1.0"
dependencies = [
"client-api",
"collab",
"collab-folder",
"flowy-error",
"futures",
"lib-infra",
]
[[package]]
@ -1543,6 +1546,7 @@ dependencies = [
"flowy-encrypt",
"flowy-error",
"flowy-folder-pub",
"flowy-search-pub",
"flowy-server-pub",
"flowy-storage",
"flowy-user-pub",

View File

@ -1964,6 +1964,7 @@ dependencies = [
"flowy-folder",
"flowy-folder-pub",
"flowy-search",
"flowy-search-pub",
"flowy-server",
"flowy-server-pub",
"flowy-sqlite",
@ -2253,6 +2254,7 @@ dependencies = [
"flowy-user",
"futures",
"lib-dispatch",
"lib-infra",
"protobuf",
"serde",
"serde_json",
@ -2269,9 +2271,12 @@ dependencies = [
name = "flowy-search-pub"
version = "0.1.0"
dependencies = [
"client-api",
"collab",
"collab-folder",
"flowy-error",
"futures",
"lib-infra",
]
[[package]]
@ -2293,6 +2298,7 @@ dependencies = [
"flowy-encrypt",
"flowy-error",
"flowy-folder-pub",
"flowy-search-pub",
"flowy-server-pub",
"flowy-storage",
"flowy-user-pub",

View File

@ -1876,13 +1876,15 @@
"image": "Image"
},
"commandPalette": {
"placeholder": "Type to search for views...",
"placeholder": "Type to search...",
"bestMatches": "Best matches",
"recentHistory": "Recent history",
"navigateHint": "to navigate",
"loadingTooltip": "We are looking for results...",
"betaLabel": "BETA",
"betaTooltip": "We currently only support searching for pages",
"fromTrashHint": "From trash"
"betaTooltip": "We currently only support searching for pages and content in documents",
"fromTrashHint": "From trash",
"noResultsHint": "We didn't find what you're looking for, try searching for another term.",
"clearSearchTooltip": "Clear search field"
}
}

View File

@ -1761,6 +1761,7 @@ dependencies = [
"flowy-folder",
"flowy-folder-pub",
"flowy-search",
"flowy-search-pub",
"flowy-server",
"flowy-server-pub",
"flowy-sqlite",
@ -2048,12 +2049,14 @@ dependencies = [
"flowy-codegen",
"flowy-derive",
"flowy-error",
"flowy-folder",
"flowy-notification",
"flowy-search-pub",
"flowy-sqlite",
"flowy-user",
"futures",
"lib-dispatch",
"lib-infra",
"protobuf",
"serde",
"serde_json",
@ -2070,9 +2073,12 @@ dependencies = [
name = "flowy-search-pub"
version = "0.1.0"
dependencies = [
"client-api",
"collab",
"collab-folder",
"flowy-error",
"futures",
"lib-infra",
]
[[package]]
@ -2097,6 +2103,7 @@ dependencies = [
"flowy-encrypt",
"flowy-error",
"flowy-folder-pub",
"flowy-search-pub",
"flowy-server-pub",
"flowy-storage",
"flowy-user-pub",

View File

@ -24,6 +24,7 @@ flowy-config = { workspace = true }
flowy-date = { workspace = true }
collab-integrate = { workspace = true }
flowy-search = { workspace = true }
flowy-search-pub = { workspace = true }
collab-entity = { workspace = true }
collab-plugins = { workspace = true }
collab = { workspace = true }

View File

@ -1,12 +1,20 @@
use flowy_folder::manager::FolderManager;
use flowy_search::document::handler::DocumentSearchHandler;
use flowy_search::folder::handler::FolderSearchHandler;
use flowy_search::folder::indexer::FolderIndexManagerImpl;
use flowy_search::services::manager::SearchManager;
use flowy_search_pub::cloud::SearchCloudService;
use std::sync::Arc;
pub struct SearchDepsResolver();
impl SearchDepsResolver {
pub async fn resolve(folder_indexer: Arc<FolderIndexManagerImpl>) -> Arc<SearchManager> {
pub async fn resolve(
folder_indexer: Arc<FolderIndexManagerImpl>,
cloud_service: Arc<dyn SearchCloudService>,
folder_manager: Arc<FolderManager>,
) -> Arc<SearchManager> {
let folder_handler = Arc::new(FolderSearchHandler::new(folder_indexer));
Arc::new(SearchManager::new(vec![folder_handler]))
let document_handler = Arc::new(DocumentSearchHandler::new(cloud_service, folder_manager));
Arc::new(SearchManager::new(vec![folder_handler, document_handler]))
}
}

View File

@ -1,3 +1,5 @@
use client_api::entity::search_dto::SearchDocumentResponseItem;
use flowy_search_pub::cloud::SearchCloudService;
use flowy_storage::{ObjectIdentity, ObjectStorageService};
use std::sync::Arc;
@ -601,3 +603,18 @@ impl ChatCloudService for ServerProvider {
})
}
}
#[async_trait]
impl SearchCloudService for ServerProvider {
async fn document_search(
&self,
workspace_id: &str,
query: String,
) -> Result<Vec<SearchDocumentResponseItem>, FlowyError> {
let server = self.get_server()?;
match server.search_service() {
Some(search_service) => search_service.document_search(workspace_id, query).await,
None => Err(FlowyError::internal().with_context("SearchCloudService not found")),
}
}
}

View File

@ -200,7 +200,12 @@ impl AppFlowyCore {
)
.await;
let search_manager = SearchDepsResolver::resolve(folder_indexer).await;
let search_manager = SearchDepsResolver::resolve(
folder_indexer,
server_provider.clone(),
folder_manager.clone(),
)
.await;
(
user_manager,

View File

@ -525,8 +525,8 @@ impl DatabaseManager {
.into_iter()
.map(|value| {
value
.into_iter()
.map(|(_k, v)| v.to_string())
.into_values()
.map(|v| v.to_string())
.collect::<Vec<String>>()
.join(", ")
})

View File

@ -6,7 +6,9 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
lib-infra = { workspace = true }
collab = { workspace = true }
collab-folder = { workspace = true }
flowy-error = { workspace = true }
client-api = { workspace = true }
futures = { workspace = true }

View File

@ -0,0 +1,12 @@
use client_api::entity::search_dto::SearchDocumentResponseItem;
use flowy_error::FlowyError;
use lib_infra::async_trait::async_trait;
#[async_trait]
pub trait SearchCloudService: Send + Sync + 'static {
async fn document_search(
&self,
workspace_id: &str,
query: String,
) -> Result<Vec<SearchDocumentResponseItem>, FlowyError>;
}

View File

@ -1 +1,2 @@
pub mod cloud;
pub mod entities;

View File

@ -21,10 +21,12 @@ flowy-notification.workspace = true
flowy-sqlite.workspace = true
flowy-user.workspace = true
flowy-search-pub.workspace = true
flowy-folder = { workspace = true }
bytes.workspace = true
futures.workspace = true
lib-dispatch.workspace = true
lib-infra = { workspace = true }
protobuf.workspace = true
serde.workspace = true
serde_json.workspace = true

View File

@ -0,0 +1,99 @@
use std::sync::Arc;
use flowy_error::FlowyResult;
use flowy_folder::{manager::FolderManager, ViewLayout};
use flowy_search_pub::cloud::SearchCloudService;
use lib_infra::async_trait::async_trait;
use crate::{
entities::{IndexTypePB, ResultIconPB, ResultIconTypePB, SearchFilterPB, SearchResultPB},
services::manager::{SearchHandler, SearchType},
};
pub struct DocumentSearchHandler {
pub cloud_service: Arc<dyn SearchCloudService>,
pub folder_manager: Arc<FolderManager>,
}
impl DocumentSearchHandler {
pub fn new(
cloud_service: Arc<dyn SearchCloudService>,
folder_manager: Arc<FolderManager>,
) -> Self {
Self {
cloud_service,
folder_manager,
}
}
}
#[async_trait]
impl SearchHandler for DocumentSearchHandler {
fn search_type(&self) -> SearchType {
SearchType::Document
}
async fn perform_search(
&self,
query: String,
filter: Option<SearchFilterPB>,
) -> FlowyResult<Vec<SearchResultPB>> {
let filter = match filter {
Some(filter) => filter,
None => return Ok(vec![]),
};
let workspace_id = match filter.workspace_id {
Some(workspace_id) => workspace_id,
None => return Ok(vec![]),
};
let results = self
.cloud_service
.document_search(&workspace_id, query)
.await?;
// Grab all views from folder cache
// Notice that `get_all_view_pb` returns Views that don't include trashed and private views
let mut views = self.folder_manager.get_all_views_pb().await?.into_iter();
let mut search_results: Vec<SearchResultPB> = vec![];
for result in results {
if let Some(view) = views.find(|v| v.id == result.object_id) {
// If there is no View for the result, we don't add it to the results
// If possible we will extract the icon to display for the result
let icon: Option<ResultIconPB> = match view.icon.clone() {
Some(view_icon) => Some(ResultIconPB::from(view_icon)),
None => {
let view_layout_ty: i64 = ViewLayout::from(view.layout.clone()).into();
Some(ResultIconPB {
ty: ResultIconTypePB::Icon,
value: view_layout_ty.to_string(),
})
},
};
search_results.push(SearchResultPB {
index_type: IndexTypePB::Document,
view_id: result.object_id.clone(),
id: result.object_id.clone(),
data: view.name.clone(),
icon,
// We reverse the score, the cloud search score is based on
// 1 being the worst result, and closer to 0 being good result, that is
// the opposite of local search.
score: 1.0 - result.score,
workspace_id: result.workspace_id,
preview: result.preview,
});
}
}
Ok(search_results)
}
/// Ignore for [DocumentSearchHandler]
fn index_count(&self) -> u64 {
0
}
}

View File

@ -0,0 +1 @@
pub mod handler;

View File

@ -3,8 +3,9 @@ use flowy_derive::ProtoBuf_Enum;
#[derive(ProtoBuf_Enum, Eq, PartialEq, Debug, Clone)]
pub enum IndexTypePB {
View = 0,
DocumentBlock = 1,
DatabaseRow = 2,
Document = 1,
DocumentBlock = 2,
DatabaseRow = 3,
}
impl Default for IndexTypePB {

View File

@ -8,7 +8,7 @@ pub struct SearchResultNotificationPB {
pub items: Vec<SearchResultPB>,
#[pb(index = 2)]
pub closed: bool,
pub sends: u64,
#[pb(index = 3, one_of)]
pub channel: Option<String>,
@ -19,7 +19,6 @@ pub enum SearchNotification {
#[default]
Unknown = 0,
DidUpdateResults = 1,
DidCloseResults = 2,
}
impl std::convert::From<SearchNotification> for i32 {
@ -32,7 +31,6 @@ impl std::convert::From<i32> for SearchNotification {
fn from(notification: i32) -> Self {
match notification {
1 => SearchNotification::DidUpdateResults,
2 => SearchNotification::DidCloseResults,
_ => SearchNotification::Unknown,
}
}

View File

@ -1,5 +1,6 @@
use collab_folder::{IconType, ViewIcon};
use flowy_derive::{ProtoBuf, ProtoBuf_Enum};
use flowy_folder::entities::ViewIconPB;
use super::IndexTypePB;
@ -31,6 +32,9 @@ pub struct SearchResultPB {
#[pb(index = 7)]
pub workspace_id: String,
#[pb(index = 8, one_of)]
pub preview: Option<String>,
}
impl SearchResultPB {
@ -43,6 +47,7 @@ impl SearchResultPB {
icon: self.icon.clone(),
score,
workspace_id: self.workspace_id.clone(),
preview: self.preview.clone(),
}
}
}
@ -122,3 +127,12 @@ impl From<ViewIcon> for ResultIconPB {
}
}
}
impl From<ViewIconPB> for ResultIconPB {
fn from(val: ViewIconPB) -> Self {
ResultIconPB {
ty: IconType::from(val.ty).into(),
value: val.value,
}
}
}

View File

@ -30,6 +30,7 @@ impl From<FolderIndexData> for SearchResultPB {
score: 0.0,
icon,
workspace_id: data.workspace_id,
preview: None,
}
}
}

View File

@ -3,6 +3,7 @@ use crate::{
services::manager::{SearchHandler, SearchType},
};
use flowy_error::FlowyResult;
use lib_infra::async_trait::async_trait;
use std::sync::Arc;
use super::indexer::FolderIndexManagerImpl;
@ -17,12 +18,13 @@ impl FolderSearchHandler {
}
}
#[async_trait]
impl SearchHandler for FolderSearchHandler {
fn search_type(&self) -> SearchType {
SearchType::Folder
}
fn perform_search(
async fn perform_search(
&self,
query: String,
filter: Option<SearchFilterPB>,

View File

@ -298,12 +298,9 @@ impl IndexManager for FolderIndexManagerImpl {
let wid = workspace_id.clone();
af_spawn(async move {
while let Ok(msg) = rx.recv().await {
tracing::warn!("[Indexer] Message received: {:?}", msg);
match msg {
IndexContent::Create(value) => match serde_json::from_value::<ViewIndexContent>(value) {
Ok(view) => {
tracing::warn!("[Indexer] CREATE: {:?}", view);
let _ = indexer.add_index(IndexableData {
id: view.id,
data: view.name,
@ -316,7 +313,6 @@ impl IndexManager for FolderIndexManagerImpl {
},
IndexContent::Update(value) => match serde_json::from_value::<ViewIndexContent>(value) {
Ok(view) => {
tracing::warn!("[Indexer] UPDATE: {:?}", view);
let _ = indexer.update_index(IndexableData {
id: view.id,
data: view.name,
@ -328,7 +324,6 @@ impl IndexManager for FolderIndexManagerImpl {
Err(err) => tracing::error!("FolderIndexManager error deserialize: {:?}", err),
},
IndexContent::Delete(ids) => {
tracing::warn!("[Indexer] DELETE: {:?}", ids);
if let Err(e) = indexer.remove_indices(ids) {
tracing::error!("FolderIndexManager error deserialize: {:?}", e);
}
@ -459,7 +454,6 @@ impl FolderIndexManager for FolderIndexManagerImpl {
}
},
FolderViewChange::Deleted { view_ids } => {
tracing::warn!("[Indexer] ViewChange Reached Deleted: {:?}", view_ids);
let _ = self.remove_indices(view_ids);
},
};

View File

@ -1,3 +1,4 @@
pub mod document;
pub mod entities;
pub mod event_handler;
pub mod event_map;

View File

@ -5,21 +5,27 @@ use super::notifier::{SearchNotifier, SearchResultChanged, SearchResultReceiverR
use crate::entities::{SearchFilterPB, SearchResultNotificationPB, SearchResultPB};
use flowy_error::FlowyResult;
use lib_dispatch::prelude::af_spawn;
use tokio::{sync::broadcast, task::spawn_blocking};
use lib_infra::async_trait::async_trait;
use tokio::sync::broadcast;
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub enum SearchType {
Folder,
Document,
}
#[async_trait]
pub trait SearchHandler: Send + Sync + 'static {
/// returns the type of search this handler is responsible for
fn search_type(&self) -> SearchType;
/// performs a search and returns the results
fn perform_search(
async fn perform_search(
&self,
query: String,
filter: Option<SearchFilterPB>,
) -> FlowyResult<Vec<SearchResultPB>>;
/// returns the number of indexed objects
fn index_count(&self) -> u64;
}
@ -57,25 +63,22 @@ impl SearchManager {
filter: Option<SearchFilterPB>,
channel: Option<String>,
) {
let mut sends: usize = 0;
let max: usize = self.handlers.len();
let handlers = self.handlers.clone();
for (_, handler) in handlers {
let q = query.clone();
let f = filter.clone();
let ch = channel.clone();
let notifier = self.notifier.clone();
spawn_blocking(move || {
let res = handler.perform_search(q, f);
sends += 1;
af_spawn(async move {
let res = handler.perform_search(q, f).await;
let close = sends == max;
let items = res.unwrap_or_default();
let notification = SearchResultNotificationPB {
items,
closed: close,
sends: max as u64,
channel: ch,
};

View File

@ -31,15 +31,13 @@ impl SearchResultReceiverRunner {
.for_each(|changed| async {
match changed {
SearchResultChanged::SearchResultUpdate(notification) => {
let ty = if notification.closed {
SearchNotification::DidCloseResults
} else {
SearchNotification::DidUpdateResults
};
send_notification(SEARCH_ID, ty, notification.channel.clone())
.payload(notification)
.send();
send_notification(
SEARCH_ID,
SearchNotification::DidUpdateResults,
notification.channel.clone(),
)
.payload(notification)
.send();
},
}
})

View File

@ -40,6 +40,7 @@ flowy-document-pub = { workspace = true }
appflowy-cloud-billing-client = { workspace = true }
flowy-error = { workspace = true, features = ["impl_from_serde", "impl_from_reqwest", "impl_from_url", "impl_from_appflowy_cloud"] }
flowy-server-pub = { workspace = true }
flowy-search-pub = { workspace = true }
flowy-encrypt = { workspace = true }
flowy-storage = { workspace = true }
flowy-chat-pub = { workspace = true }

View File

@ -3,6 +3,7 @@ pub(crate) use database::*;
pub(crate) use document::*;
pub(crate) use file_storage::*;
pub(crate) use folder::*;
pub(crate) use search::*;
pub(crate) use user::*;
mod chat;
@ -10,5 +11,6 @@ mod database;
mod document;
mod file_storage;
mod folder;
mod search;
mod user;
mod util;

View File

@ -0,0 +1,40 @@
use client_api::entity::search_dto::SearchDocumentResponseItem;
use flowy_error::FlowyError;
use flowy_search_pub::cloud::SearchCloudService;
use lib_infra::async_trait::async_trait;
use crate::af_cloud::AFServer;
pub(crate) struct AFCloudSearchCloudServiceImpl<T> {
pub inner: T,
}
// The limit of what the score should be for results, used to
// filter out irrelevant results.
const SCORE_LIMIT: f64 = 0.8;
const DEFAULT_PREVIEW: u32 = 80;
#[async_trait]
impl<T> SearchCloudService for AFCloudSearchCloudServiceImpl<T>
where
T: AFServer,
{
async fn document_search(
&self,
workspace_id: &str,
query: String,
) -> Result<Vec<SearchDocumentResponseItem>, FlowyError> {
let client = self.inner.try_get_client()?;
let result = client
.search_documents(workspace_id, &query, 10, DEFAULT_PREVIEW)
.await?;
// Filter out irrelevant results
let result = result
.into_iter()
.filter(|r| r.score < SCORE_LIMIT)
.collect();
Ok(result)
}
}

View File

@ -11,6 +11,7 @@ use client_api::ws::{
};
use client_api::{Client, ClientConfiguration};
use flowy_chat_pub::cloud::ChatCloudService;
use flowy_search_pub::cloud::SearchCloudService;
use flowy_storage::ObjectStorageService;
use rand::Rng;
use semver::Version;
@ -38,6 +39,8 @@ use crate::af_cloud::impls::{
use crate::AppFlowyServer;
use super::impls::AFCloudSearchCloudServiceImpl;
pub(crate) type AFCloudClient = Client;
pub struct AppFlowyCloudServer {
@ -255,6 +258,14 @@ impl AppFlowyServer for AppFlowyCloudServer {
};
Some(Arc::new(AFCloudFileStorageServiceImpl::new(client)))
}
fn search_service(&self) -> Option<Arc<dyn SearchCloudService>> {
let server = AFServerImpl {
client: self.get_client(),
};
Some(Arc::new(AFCloudSearchCloudServiceImpl { inner: server }))
}
}
/// Spawns a new asynchronous task to handle WebSocket connections based on token state.

View File

@ -1,3 +1,4 @@
use flowy_search_pub::cloud::SearchCloudService;
use flowy_storage::ObjectStorageService;
use std::sync::Arc;
@ -70,4 +71,8 @@ impl AppFlowyServer for LocalServer {
fn file_storage(&self) -> Option<Arc<dyn ObjectStorageService>> {
None
}
fn search_service(&self) -> Option<Arc<dyn SearchCloudService>> {
None
}
}

View File

@ -1,6 +1,7 @@
use client_api::ws::ConnectState;
use client_api::ws::WSConnectStateReceiver;
use client_api::ws::WebSocketChannel;
use flowy_search_pub::cloud::SearchCloudService;
use flowy_storage::ObjectStorageService;
use std::sync::Arc;
@ -100,6 +101,10 @@ pub trait AppFlowyServer: Send + Sync + 'static {
Arc::new(DefaultChatCloudServiceImpl)
}
/// Bridge for the Cloud AI Search features
///
fn search_service(&self) -> Option<Arc<dyn SearchCloudService>>;
/// Manages collaborative objects within a remote storage system. This includes operations such as
/// checking storage status, retrieving updates and snapshots, and dispatching updates. The service
/// also provides subscription capabilities for real-time updates.

View File

@ -1,3 +1,4 @@
use flowy_search_pub::cloud::SearchCloudService;
use flowy_storage::ObjectStorageService;
use std::collections::HashMap;
use std::sync::{Arc, Weak};
@ -194,4 +195,8 @@ impl AppFlowyServer for SupabaseServer {
.clone()
.map(|s| s as Arc<dyn ObjectStorageService>)
}
fn search_service(&self) -> Option<Arc<dyn SearchCloudService>> {
None
}
}