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:
2026-05-09 23:20:11 +02:00
parent 8e6b1877cc
commit 14090b96f4
10 changed files with 767 additions and 7 deletions
@@ -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,
};