implemented file search with local cache and server-side support, added result highlighting, and integrated search delegate into files page
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../nextcloud_ocs.dart';
|
||||
import 'search_files_response.dart';
|
||||
|
||||
/// Wraps the Nextcloud OCS Search Provider API for the `files` provider.
|
||||
/// Endpoint: `/ocs/v2.php/search/providers/files/search`.
|
||||
class SearchFiles {
|
||||
Future<SearchFilesResponse> run({
|
||||
required String term,
|
||||
int limit = 50,
|
||||
int? cursor,
|
||||
}) async {
|
||||
final endpoint = NextcloudOcs.uri(
|
||||
'search/providers/files/search',
|
||||
queryParameters: {
|
||||
'term': term,
|
||||
'limit': limit.toString(),
|
||||
if (cursor != null) 'cursor': cursor.toString(),
|
||||
},
|
||||
);
|
||||
final response = await http.get(endpoint, headers: NextcloudOcs.headers());
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw Exception(
|
||||
'Files search failed with ${response.statusCode}: ${response.body}',
|
||||
);
|
||||
}
|
||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final ocs = decoded['ocs'] as Map<String, dynamic>;
|
||||
final data = ocs['data'] as Map<String, dynamic>;
|
||||
return SearchFilesResponse.fromJson(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../webdav/queries/list_files/cacheable_file.dart';
|
||||
|
||||
part 'search_files_response.g.dart';
|
||||
|
||||
/// Subset of the OCS Search Provider API response we actually consume.
|
||||
/// The provider (`files`) returns one object per match plus pagination state.
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class SearchFilesResponse {
|
||||
final String name;
|
||||
final bool isPaginated;
|
||||
final int? cursor;
|
||||
final List<SearchFilesEntry> entries;
|
||||
|
||||
SearchFilesResponse({
|
||||
required this.name,
|
||||
required this.isPaginated,
|
||||
required this.cursor,
|
||||
required this.entries,
|
||||
});
|
||||
|
||||
factory SearchFilesResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$SearchFilesResponseFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$SearchFilesResponseToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class SearchFilesEntry {
|
||||
final String title;
|
||||
final String? subline;
|
||||
final String? icon;
|
||||
final String? resourceUrl;
|
||||
final Map<String, dynamic>? attributes;
|
||||
|
||||
SearchFilesEntry({
|
||||
required this.title,
|
||||
this.subline,
|
||||
this.icon,
|
||||
this.resourceUrl,
|
||||
this.attributes,
|
||||
});
|
||||
|
||||
factory SearchFilesEntry.fromJson(Map<String, dynamic> json) =>
|
||||
_$SearchFilesEntryFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$SearchFilesEntryToJson(this);
|
||||
|
||||
/// Heuristic — the files provider sets icon classes containing "folder" for
|
||||
/// directories. Falls back to false when missing or unrecognised.
|
||||
bool get isDirectory => (icon ?? '').toLowerCase().contains('folder');
|
||||
|
||||
String? _stringAttribute(String key) {
|
||||
final raw = attributes?[key];
|
||||
return raw is String && raw.isNotEmpty ? raw : null;
|
||||
}
|
||||
|
||||
String? _dirFromResourceUrl() {
|
||||
final url = resourceUrl;
|
||||
if (url == null) return null;
|
||||
return Uri.tryParse(url)?.queryParameters['dir'];
|
||||
}
|
||||
|
||||
/// Reconstructs the WebDAV-relative path used elsewhere (matching
|
||||
/// [CacheableFile.path] — no leading slash, trailing slash for
|
||||
/// directories). Prefers the explicit `path` attribute set by Nextcloud's
|
||||
/// files search provider (28+); falls back to the `dir` query parameter
|
||||
/// in [resourceUrl]. Returns `null` when neither is available — `subline`
|
||||
/// is intentionally **not** parsed because it is localized UI text
|
||||
/// ("in {folder}"), not a path, and using it produced bogus duplicate
|
||||
/// folder headers like "/in Alte-Notebooks".
|
||||
String? get webdavPath {
|
||||
final attrPath = _stringAttribute('path');
|
||||
if (attrPath != null) {
|
||||
final stripped = attrPath.replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
return isDirectory ? '$stripped/' : stripped;
|
||||
}
|
||||
final dir = _dirFromResourceUrl();
|
||||
if (dir != null) {
|
||||
final stripped = dir.replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
final base = stripped.isEmpty ? title : '$stripped/$title';
|
||||
return isDirectory ? '$base/' : base;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
CacheableFile? toCacheable() {
|
||||
final path = webdavPath;
|
||||
if (path == null) return null;
|
||||
return CacheableFile(path: path, isDirectory: isDirectory, name: title);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'search_files_response.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
SearchFilesResponse _$SearchFilesResponseFromJson(Map<String, dynamic> json) =>
|
||||
SearchFilesResponse(
|
||||
name: json['name'] as String,
|
||||
isPaginated: json['isPaginated'] as bool,
|
||||
cursor: (json['cursor'] as num?)?.toInt(),
|
||||
entries: (json['entries'] as List<dynamic>)
|
||||
.map((e) => SearchFilesEntry.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SearchFilesResponseToJson(
|
||||
SearchFilesResponse instance,
|
||||
) => <String, dynamic>{
|
||||
'name': instance.name,
|
||||
'isPaginated': instance.isPaginated,
|
||||
'cursor': instance.cursor,
|
||||
'entries': instance.entries.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
|
||||
SearchFilesEntry _$SearchFilesEntryFromJson(Map<String, dynamic> json) =>
|
||||
SearchFilesEntry(
|
||||
title: json['title'] as String,
|
||||
subline: json['subline'] as String?,
|
||||
icon: json['icon'] as String?,
|
||||
resourceUrl: json['resourceUrl'] as String?,
|
||||
attributes: json['attributes'] as Map<String, dynamic>?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SearchFilesEntryToJson(SearchFilesEntry instance) =>
|
||||
<String, dynamic>{
|
||||
'title': instance.title,
|
||||
'subline': instance.subline,
|
||||
'icon': instance.icon,
|
||||
'resourceUrl': instance.resourceUrl,
|
||||
'attributes': instance.attributes,
|
||||
};
|
||||
Reference in New Issue
Block a user