Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
6ffe589
Add client media upload and cache pipeline
Eoic Jun 27, 2026
73b422d
feat: add book download and delete functionality with media upload ha…
Eoic Jul 4, 2026
8517b6a
docs: design media storage hardening
Eoic Jul 11, 2026
b0722db
docs: plan media storage hardening
Eoic Jul 11, 2026
1df18f2
test: align book menu labels
Eoic Jul 11, 2026
6cdc91c
feat: define scoped media identity
Eoic Jul 11, 2026
7ba5163
feat: persist scoped covers in local files
Eoic Jul 11, 2026
2a93ee1
feat: render private covers from persistent cache
Eoic Jul 11, 2026
98aae0e
fix: scope and serialize media uploads
Eoic Jul 11, 2026
ee0c80e
fix: process media immediately after enqueue
Eoic Jul 11, 2026
422efc7
fix: clean scoped cover files
Eoic Jul 11, 2026
7689ad7
style: format media scope test
Eoic Jul 11, 2026
f0b3586
fix: close media lifecycle races
Eoic Jul 11, 2026
7a5d306
docs: define local-first cover lifecycle
Eoic Jul 11, 2026
c0628ad
docs: plan local-first cover lifecycle
Eoic Jul 11, 2026
7935715
feat: add pending and guest cover storage
Eoic Jul 11, 2026
1ad0281
fix: make cover promotion race safe
Eoic Jul 11, 2026
112c3e9
fix: coordinate cover writes across workers
Eoic Jul 11, 2026
7ed2e3e
fix: queue cover file references instead of bytes
Eoic Jul 11, 2026
13f7618
fix: wire pending cover queue readers
Eoic Jul 11, 2026
ec56e37
fix: isolate queued media read failures
Eoic Jul 11, 2026
dae456d
feat: persist imported covers outside metadata
Eoic Jul 11, 2026
7722028
fix: make import cover commit atomic
Eoic Jul 11, 2026
fb1e178
fix: await import metadata compensation
Eoic Jul 11, 2026
ebfc660
fix: preserve synchronous data store mutations
Eoic Jul 11, 2026
b91137f
fix: bind imports to captured library repository
Eoic Jul 11, 2026
fed071d
feat: render local covers by book id
Eoic Jul 11, 2026
3f8c142
fix: reload local covers across library contexts
Eoic Jul 11, 2026
cd2dde8
fix: subscribe after local cover source transition
Eoic Jul 11, 2026
a726987
feat: promote uploaded covers into local cache
Eoic Jul 11, 2026
1115150
fix: keep cover promotion diagnostics non-fatal
Eoic Jul 11, 2026
196438c
fix: clean pending and guest covers on delete
Eoic Jul 11, 2026
4f8870d
fix: preserve uploaded cover ids across stale sync
Eoic Jul 11, 2026
02999e7
docs: design stable local cover image cache
Eoic Jul 11, 2026
3f79205
docs: plan stable local cover image cache
Eoic Jul 11, 2026
4630bde
feat: add stable local cover image provider
Eoic Jul 11, 2026
02965dd
fix: reuse decoded covers across page transitions
Eoic Jul 11, 2026
21d254a
test: cover pending and guest page cache reuse
Eoic Jul 11, 2026
8b53613
fix: evict decoded covers after local removal
Eoic Jul 11, 2026
795bfba
fix: preserve pending cover cache on no-op promotion
Eoic Jul 11, 2026
1fa596f
feat: enhance cover image handling with book and media IDs
Eoic Jul 12, 2026
e7788dd
refactor: rename PrivateBookCover to CoverImage and update references
Eoic Jul 12, 2026
634370c
feat: implement hot restart cleanup and enhance book cover handling
Eoic Jul 12, 2026
48b26da
feat: improve loading state handling in DataStore and related components
Eoic Jul 12, 2026
9f1f750
feat: add loading placeholder for cover images during loading state
Eoic Jul 12, 2026
1f5c86e
feat: restructure BookEditPage layout for improved alignment and pers…
Eoic Jul 12, 2026
eacc903
feat: add waitUntilLoaded method to DataStore and update providers to…
Eoic Jul 12, 2026
f56f279
feat: enhance Book model to support clearing optional fields and upda…
Eoic Jul 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions app/lib/auth/auth_api_client.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import 'dart:convert';
import 'dart:typed_data';

import 'package:http/http.dart' as http;
import 'package:http_parser/http_parser.dart';
import 'package:papyrus/auth/auth_models.dart';
import 'package:papyrus/auth/papyrus_api_config.dart';
import 'package:papyrus/media/media_models.dart';

class AuthApiException implements Exception {
final int statusCode;
Expand Down Expand Up @@ -151,6 +154,46 @@ class AuthApiClient {
await _postJson(config.endpoint('/sync/powersync-upload'), accessToken: accessToken, body: {'batch': batch});
}

Future<MediaStorageUsage> fetchMediaUsage(String accessToken) async {
final json = await _getJson(config.endpoint('/media/usage'), accessToken: accessToken);
return MediaStorageUsage.fromJson(json);
}

Future<MediaAsset> uploadMedia(String accessToken, MediaUploadPayload payload) async {
final request = http.MultipartRequest('POST', config.endpoint('/media'))
..headers.addAll(_authHeaders(accessToken))
..fields['book_id'] = payload.bookId
..fields['kind'] = payload.kind.apiValue
..files.add(
http.MultipartFile.fromBytes(
'file',
payload.bytes,
filename: payload.filename,
contentType: _mediaType(payload.contentType),
),
);

final response = await http.Response.fromStream(await _httpClient.send(request));
return MediaAsset.fromJson(_decodeResponse(response));
}

Future<Uint8List> downloadMedia(String accessToken, String assetId) async {
final response = await _httpClient.get(config.endpoint('/media/$assetId'), headers: _authHeaders(accessToken));
if (response.statusCode >= 200 && response.statusCode < 300) {
return response.bodyBytes;
}
_decodeResponse(response);
throw const AuthApiException(statusCode: 0, message: 'Media download failed');
}

Future<void> deleteMedia(String accessToken, String assetId) async {
final response = await _httpClient.delete(config.endpoint('/media/$assetId'), headers: _authHeaders(accessToken));
if (response.statusCode >= 200 && response.statusCode < 300) {
return;
}
_decodeResponse(response);
}

Future<Map<String, dynamic>> _getJson(Uri uri, {String? accessToken}) async {
final response = await _httpClient.get(uri, headers: _headers(accessToken));

Expand Down Expand Up @@ -185,6 +228,16 @@ class AuthApiClient {
};
}

Map<String, String> _authHeaders(String accessToken) {
return {'Accept': 'application/json', 'Authorization': 'Bearer $accessToken'};
}

MediaType _mediaType(String contentType) {
final parts = contentType.split('/');
if (parts.length != 2) return MediaType('application', 'octet-stream');
return MediaType(parts[0], parts[1]);
}

Map<String, dynamic> _decodeResponse(http.Response response) {
final decoded = response.body.isEmpty ? <String, dynamic>{} : jsonDecode(response.body) as Map<String, dynamic>;

Expand Down
25 changes: 25 additions & 0 deletions app/lib/auth/auth_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:papyrus/auth/auth_api_client.dart';
import 'package:papyrus/auth/auth_models.dart';
import 'package:papyrus/media/media_models.dart';
import 'package:papyrus/auth/token_store.dart';
import 'package:papyrus/platform/web_redirect.dart';

Expand Down Expand Up @@ -194,6 +195,30 @@ class AuthRepository {
});
}

Future<MediaStorageUsage> fetchMediaUsage() {
return _withFreshAccessToken((accessToken) {
return apiClient.fetchMediaUsage(accessToken);
});
}

Future<MediaAsset> uploadMedia(MediaUploadPayload payload) {
return _withFreshAccessToken((accessToken) {
return apiClient.uploadMedia(accessToken, payload);
});
}

Future<Uint8List> downloadMedia(String assetId) {
return _withFreshAccessToken((accessToken) {
return apiClient.downloadMedia(accessToken, assetId);
});
}

Future<void> deleteMedia(String assetId) {
return _withFreshAccessToken((accessToken) {
return apiClient.deleteMedia(accessToken, assetId);
});
}

Future<void> clearTokens() {
return tokenStore.clear();
}
Expand Down
91 changes: 88 additions & 3 deletions app/lib/data/data_store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,29 @@ class DataStore extends ChangeNotifier {

bool get isLoaded => _isLoaded;

/// Completes when the active book repository has emitted its first snapshot.
///
/// A direct repository lookup may temporarily return null while persistent
/// storage is still opening. Callers should wait for this before treating a
/// missing ID as authoritative.
Future<void> waitUntilLoaded() {
if (_isLoaded) return Future<void>.value();

final completer = Completer<void>();
late VoidCallback listener;
listener = () {
if (!_isLoaded || completer.isCompleted) return;
removeListener(listener);
completer.complete();
};

addListener(listener);
// Close the gap if loading completed between the initial check and listener
// registration.
listener();
return completer.future;
}

List<Book> get books => _books.values.toList();

/// Get all shelves with computed bookCount and coverPreviews.
Expand All @@ -74,6 +97,9 @@ class DataStore extends ChangeNotifier {
Book? getBook(String id) => _books[id];

Future<void> attachBookRepository(BookRepository repository) async {
final wasLoaded = _isLoaded;
_isLoaded = false;
if (wasLoaded) notifyListeners();
await _bookSubscription?.cancel();
_bookRepository = repository;
_bookSubscription = repository.watchAll().listen(
Expand All @@ -92,22 +118,54 @@ class DataStore extends ChangeNotifier {
_bookRepository = null;
}

BookRepository requireBookRepository() {
final repository = _bookRepository;
if (repository == null) {
throw StateError('Book repository is not initialized');
}
return repository;
}

bool isBookRepositoryCurrent(BookRepository repository) => identical(_bookRepository, repository);

void addBook(Book book) {
final repository = _bookRepository;
if (repository == null) {
throw StateError('Book repository is not initialized');
}
_books[book.id] = book;
notifyListeners();
unawaited(repository.upsert(book));
}

Future<void> addBookAndWait(Book book) async {
final repository = requireBookRepository();
await addBookToRepositoryAndWait(repository, book);
}

Future<void> addBookToRepositoryAndWait(BookRepository repository, Book book) async {
await repository.upsert(book);
if (isBookRepositoryCurrent(repository) && !identical(_books[book.id], book)) {
_books[book.id] = book;
notifyListeners();
}
}

void updateBook(Book book) {
final repository = _bookRepository;
if (repository == null) {
throw StateError('Book repository is not initialized');
}
_books[book.id] = book;
notifyListeners();
unawaited(repository.upsert(book));
}

Future<void> updateBookAndWait(Book book) async {
final repository = requireBookRepository();
await addBookToRepositoryAndWait(repository, book);
}

void deleteBook(String id) {
final repository = _bookRepository;
if (repository == null) {
Expand All @@ -116,11 +174,35 @@ class DataStore extends ChangeNotifier {
unawaited(repository.delete(id));
}

Future<void> deleteBookAndWait(String id) async {
final repository = requireBookRepository();
await deleteBookFromRepositoryAndWait(repository, id);
}

Future<void> deleteBookFromRepositoryAndWait(BookRepository repository, String id) async {
await repository.delete(id);
if (isBookRepositoryCurrent(repository) && _books.remove(id) != null) {
notifyListeners();
}
}

void replaceBooksFromSync(List<Book> books) {
final syncedIds = books.map((book) => book.id).toSet();
final mergedBooks = books
.map((book) {
final localBook = _books[book.id];
if (book.coverMediaId == null && localBook?.coverMediaId != null) {
// PowerSync can briefly emit the downloaded server row before its
// pending local media-reference update is acknowledged. Keep the
// established local reference through that transient null snapshot.
return book.copyWith(coverMediaId: localBook!.coverMediaId);
}
return book;
})
.toList(growable: false);
Comment on lines +190 to +201
final syncedIds = mergedBooks.map((book) => book.id).toSet();
_books
..clear()
..addEntries(books.map((book) => MapEntry(book.id, book)));
..addEntries(mergedBooks.map((book) => MapEntry(book.id, book)));
_bookShelfRelations.removeWhere((relation) => !syncedIds.contains(relation.bookId));
_bookTagRelations.removeWhere((relation) => !syncedIds.contains(relation.bookId));
_annotations.removeWhere((key, annotation) => !syncedIds.contains(annotation.bookId));
Expand Down Expand Up @@ -185,7 +267,10 @@ class DataStore extends ChangeNotifier {
/// Get cover previews for a shelf (up to 4 books).
List<CoverPreview> getCoverPreviewsForShelf(String shelfId, {int limit = 4}) {
final books = getBooksInShelf(shelfId);
return books.take(limit).map((b) => CoverPreview(url: b.coverUrl, title: b.title)).toList();
return books
.take(limit)
.map((b) => CoverPreview(bookId: b.id, url: b.coverUrl, mediaId: b.coverMediaId, title: b.title))
.toList();
}

// ============================================================
Expand Down
Loading
Loading