diff --git a/apps/readest-sync/CMakeLists.txt b/apps/readest-sync/CMakeLists.txt index d07e8d3..bb30111 100644 --- a/apps/readest-sync/CMakeLists.txt +++ b/apps/readest-sync/CMakeLists.txt @@ -67,7 +67,7 @@ if(READEST_DESKTOP OR READEST_HOST_TESTS) add_subdirectory(tests) endif() set(PRESENTATION_SOURCES src/controller.cpp src/controller.h src/library_model.cpp src/library_model.h - src/book_status.cpp src/operation_runner.cpp src/device_adapter.cpp src/cover_loader.cpp src/thumbnail_cache.cpp) + src/book_status.cpp src/book_actions.cpp src/operation_runner.cpp src/device_adapter.cpp src/cover_loader.cpp src/thumbnail_cache.cpp) if(NOT READEST_HOST_TESTS OR READEST_DESKTOP) add_library(readest-presentation STATIC ${PRESENTATION_SOURCES}) set_target_properties(readest-presentation PROPERTIES AUTOMOC ON) diff --git a/apps/readest-sync/CONTEXT.md b/apps/readest-sync/CONTEXT.md index ad5273e..3ff06b2 100644 --- a/apps/readest-sync/CONTEXT.md +++ b/apps/readest-sync/CONTEXT.md @@ -17,6 +17,10 @@ entry in Readest Sync. The displayed summary of a book's availability and relationship to Readest, including removal from Readest or a pending upload. +**Book action**: +An operation offered for a book in its current state, such as opening a local +copy, downloading an EPUB or choosing which reading position to use. + **PocketBook reading position**: The reading location saved by the PocketBook native reader for a device book. diff --git a/apps/readest-sync/README.md b/apps/readest-sync/README.md index 98a7dd0..d86c992 100644 --- a/apps/readest-sync/README.md +++ b/apps/readest-sync/README.md @@ -226,6 +226,9 @@ Matches and candidate-index updates are each committed as a batch. The library snapshot loads saved sync metadata in one query and native percentages in one grouped query. Cover checks read headers rather than whole image files. +Sync, Open and Upload read the PocketBook reading position directly in a short, +read-only transaction that keeps identity and settings consistent, including WAL +changes. They no longer copy the native database to a temporary capture file. The scale regression uses 500 valid EPUBs totaling roughly 500 MiB and checks content-read/validation counts, cache persistence, path invalidation and new cloud matches. It prints host timings as diagnostics; these are not device timings. diff --git a/apps/readest-sync/qml/BookDetailsPage.qml b/apps/readest-sync/qml/BookDetailsPage.qml index 332517f..932b8ed 100644 --- a/apps/readest-sync/qml/BookDetailsPage.qml +++ b/apps/readest-sync/qml/BookDetailsPage.qml @@ -5,34 +5,15 @@ Item { objectName: "nativeBookDetailsPage" required property var shell property var details: shell.view.book || ({}) - property var availableActions: shell.view.actions || [] + readonly property var actionPresentation: shell.view.actionPresentation || ({}) property bool menuOpen: false property bool activating: false - readonly property bool choosing: availableActions.length > 0 && availableActions[0].command.indexOf("copy:") === 0 - readonly property bool conflict: shell.view.decision - readonly property var primaryAction: choosing || conflict ? null : firstContentAction() - readonly property var secondaryActions: contentActions(false) - readonly property var menuActions: contentActions(true) + readonly property bool choosing: actionPresentation.choosing || false + readonly property var primaryAction: actionPresentation.primary || null + readonly property var secondaryActions: actionPresentation.secondary || [] + readonly property var menuActions: actionPresentation.menu || [] readonly property bool hasMenu: menuActions.length > 0 - function firstContentAction() { - for (let action of availableActions) - if (action.command !== "back" && action.command !== "uploadCover") return action - return null - } - function contentActions(menu) { - let result = [] - if (conflict) return result - for (let action of availableActions) { - if (action.command === "back") continue - if (menu) { - if (action.command === "uploadCover") result.push(action) - } else if (choosing || action.command !== (primaryAction ? primaryAction.command : "")) { - if (action.command !== "uploadCover") result.push(action) - } - } - return result - } function run(command) { if (activating) return activating = true @@ -144,8 +125,7 @@ Item { width: parent.width - 2 * shell.metrics.largeGap x: shell.metrics.largeGap height: shell.metrics.menuRow - text: root.availableActions[0] && root.availableActions[0].command.indexOf("copy:") === 0 ? - "CHOOSE LOCAL COPY" : "CHOOSE READING POSITION" + text: "CHOOSE LOCAL COPY" verticalAlignment: Text.AlignVCenter font.pixelSize: shell.metrics.smallFont font.bold: true diff --git a/apps/readest-sync/qml/DecisionDialog.qml b/apps/readest-sync/qml/DecisionDialog.qml index d5bc492..041108b 100644 --- a/apps/readest-sync/qml/DecisionDialog.qml +++ b/apps/readest-sync/qml/DecisionDialog.qml @@ -5,15 +5,7 @@ Item { objectName: "nativeDecisionDialog" required property var shell z: 60 - property var choices: decisionChoices() - - function decisionChoices() { - if (!shell.view.decision) return [] - let result = [] - for (let action of shell.view.actions || []) - if (action.command !== "back") result.push(action) - return result - } + readonly property var choices: shell.view.actionPresentation ? shell.view.actionPresentation.choices : [] Rectangle { anchors.fill: parent; color: "#dddddd"; opacity: 0.92 } Rectangle { diff --git a/apps/readest-sync/qml/LibraryPage.qml b/apps/readest-sync/qml/LibraryPage.qml index d0022b7..ceec111 100644 --- a/apps/readest-sync/qml/LibraryPage.qml +++ b/apps/readest-sync/qml/LibraryPage.qml @@ -43,7 +43,7 @@ Item { } function openBook(account, hash) { - if (activating) return + if (activating || shell.view.busy) return activating = true menuOpen = false filterMenuOpen = false diff --git a/apps/readest-sync/qml/Main.qml b/apps/readest-sync/qml/Main.qml index 0ac5e17..eef420e 100644 --- a/apps/readest-sync/qml/Main.qml +++ b/apps/readest-sync/qml/Main.qml @@ -23,13 +23,17 @@ Window { NativeMetrics { id: nativeMetrics; viewportWidth: window.width; viewportHeight: window.height } + function toggleMenu() { + if (!view.busy && !view.decision && !view.blocking && + pageLoader.item && pageLoader.item.toggleMenu && + (window.libraryActive || pageLoader.item.hasMenu)) + pageLoader.item.toggleMenu() + } + function handleHardwareButton(key) { const normalizedKey = Number(key) if (normalizedKey === Qt.Key_Menu) { - if (!view.busy && !view.decision && !view.blocking && - pageLoader.item && pageLoader.item.toggleMenu && - (window.libraryActive || pageLoader.item.hasMenu)) - pageLoader.item.toggleMenu() + window.toggleMenu() return } if (pageLoader.item && pageLoader.item.handleKey && pageLoader.item.handleKey(normalizedKey)) return @@ -40,13 +44,14 @@ Window { NativeHeader { id: header + objectName: "nativeHeader" width: parent.width height: window.metrics.chrome title: window.view.detail ? "BOOK INFO / " + (window.view.title || "Untitled") : window.view.title || "Readest Sync" showBack: window.view.detail || window.view.signingIn actionText: window.libraryActive || (pageLoader.item && pageLoader.item.hasMenu) ? "Menu" : "" onBack: window.handleHardwareButton(Qt.Key_Back) - onAction: if (pageLoader.item && pageLoader.item.toggleMenu) pageLoader.item.toggleMenu() + onAction: window.toggleMenu() onClose: window.view.close() } FocusScope { diff --git a/apps/readest-sync/src/application.cpp b/apps/readest-sync/src/application.cpp index 506d7a7..b321a47 100644 --- a/apps/readest-sync/src/application.cpp +++ b/apps/readest-sync/src/application.cpp @@ -75,13 +75,6 @@ ManagedBook ApplicationService::resolve(const BookId& id) { } throw std::runtime_error("This book is no longer in the library."); } -NativePosition ApplicationService::capture(const std::string& path) { - const auto file=config_.root+"/capture-"+std::to_string(getpid())+"-"+std::to_string(++sequence_)+".db"; - try { - backup_native_database(config_.database,file); - auto result=native_position(file,path); unlink(file.c_str()); return result; - } catch(...) { unlink(file.c_str()); throw; } -} LibrarySnapshot ApplicationService::snapshot() { trace("snapshot.begin"); LibrarySnapshot result; result.initialized=bool(state_ && cloud_); @@ -123,7 +116,7 @@ void ApplicationService::synchronize(const Request& request, OperationResult& re if(book.book.deleted) throw std::runtime_error("Removed from Readest. Re-upload this book or read offline."); if(book.local_only) throw std::runtime_error("Upload this book to Readest before synchronizing."); state_->remember_integrity(book); - const auto native=capture(book.path); + const auto native=native_position(config_.database,book.path); const bool open=request.command==Command::Open; const auto audit=config_.root+"/native-"+std::to_string(time(nullptr))+"-"+std::to_string(getpid())+"-"+std::to_string(++sequence_); const auto transition=transition_progress(*cloud_,*state_,verified,native,time(nullptr),request.choice,request.revision, @@ -232,7 +225,7 @@ OperationResult ApplicationService::execute(const Request& request,const std::at const VerifiedManagedBook verified(resolve(request.book)); state_->remember_integrity(verified.book()); UploadPosition position; - try {position.native=capture(verified.book().path);} + try {position.native=native_position(config_.database,verified.book().path);} catch(const UnsupportedNativePosition& e) {position.error=e.what();position.unsupported=true;} catch(const std::exception& e) {position.error=e.what();} const auto uploaded=upload_book(*cloud_,*state_,verified,position,config_.transport,config_.ca,config_.root,cancel); diff --git a/apps/readest-sync/src/application.h b/apps/readest-sync/src/application.h index 526c72f..99d88c1 100644 --- a/apps/readest-sync/src/application.h +++ b/apps/readest-sync/src/application.h @@ -69,7 +69,6 @@ class ApplicationService { void check_cancel(const std::atomic& cancel) const; size_t scan(const std::atomic& cancel); LibrarySnapshot snapshot(); - NativePosition capture(const std::string& path); ManagedBook resolve(const BookId& id); void synchronize(const Request& request, OperationResult& result, const std::atomic& cancel); }; diff --git a/apps/readest-sync/src/book_actions.cpp b/apps/readest-sync/src/book_actions.cpp new file mode 100644 index 0000000..10d420f --- /dev/null +++ b/apps/readest-sync/src/book_actions.cpp @@ -0,0 +1,68 @@ +#include "book_actions.h" +#include "library_model.h" + +QVariantMap BookActions::presentation() const { + return {{"primary",primary.isEmpty()?QVariant():QVariant(primary)},{"secondary",secondary},{"menu",menu}, + {"choices",choices},{"choosing",choosing}}; +} + +BookActions bookActions(const readest::LibraryEntry& entry,bool signedIn, + BookChoice choice,BookBlock block) { + using readest::Availability; + BookActions result; + const bool conflict=choice==BookChoice::OpenConflict || choice==BookChoice::SyncConflict; + enum class Place { Content, Menu, Navigation }; + auto add=[&](const QString& command,const QString& text,Place place=Place::Content) { + const QVariantMap action{{"command",command},{"text",text}}; + result.allowed.append(action); + if(place==Place::Navigation) return; + if(conflict) result.choices.append(action); + else if(place==Place::Menu) result.menu.append(action); + else if(result.choosing || !result.primary.isEmpty()) result.secondary.append(action); + else result.primary=action; + }; + auto back=[&] { add("back","Back to library",Place::Navigation); }; + if(block!=BookBlock::None) { + if(block==BookBlock::Pending) return result; + add(entry.availability==Availability::OnDevice?"sync":"refresh", + entry.availability==Availability::OnDevice?"Sync now":"Check availability"); + back(); return result; + } + if(entry.book.needs_copy_choice || choice==BookChoice::LocalCopy) { + result.choosing=!entry.book.copies.empty(); + for(size_t i=0;i1) add("copies","Choose local copy"); + back(); return result; + } + if(entry.book.copies.size()>1) add("copies","Choose local copy"); + if(entry.availability==Availability::OnDevice) { + add("open",entry.sync.pending_remote.empty()?"Open":"Open at Readest position"); + add("sync","Sync now"); add("offline","Read offline"); + } else if(entry.availability==Availability::Downloadable) add("download","Download EPUB"); + else add("refresh","Check availability"); + if(entry.availability==Availability::OnDevice && entry.book.epubs==0) + add("upload","Upload EPUB to Readest"); + if(entry.availability==Availability::OnDevice && entry.book.epubs>0) + add("uploadCover","Upload cover to Readest",Place::Menu); + back(); return result; +} diff --git a/apps/readest-sync/src/book_actions.h b/apps/readest-sync/src/book_actions.h new file mode 100644 index 0000000..4341d24 --- /dev/null +++ b/apps/readest-sync/src/book_actions.h @@ -0,0 +1,17 @@ +#pragma once +#include +#include + +namespace readest { struct LibraryEntry; } +enum class BookChoice { None, SyncConflict, OpenConflict, LocalCopy }; +enum class BookBlock { None, Pending, Acknowledged }; + +struct BookActions { + QVariantList allowed,secondary,menu,choices; + QVariantMap primary; + bool choosing=false; + QVariantMap presentation() const; +}; + +BookActions bookActions(const readest::LibraryEntry& entry,bool signedIn, + BookChoice choice,BookBlock block); diff --git a/apps/readest-sync/src/controller.cpp b/apps/readest-sync/src/controller.cpp index 539917c..11e973a 100644 --- a/apps/readest-sync/src/controller.cpp +++ b/apps/readest-sync/src/controller.cpp @@ -121,55 +121,15 @@ QVariantMap AppController::book() const { {"pocketBookProgress",percentageLabel(entry->local_percentage)}, {"readestProgress",percentageLabel(entry->remote_percentage)}}; } -QVariantList AppController::actions() const { - QVariantList result; - if(busy()) return result; - auto add=[&](const char* command,const QString& text) { result.append(QVariantMap{{"command",command},{"text",text}}); }; - const auto* entry=library_.find(selected_); if(!entry) return result; - if(blocking_state_!=BlockingState::None && selected_==blocking_book_) { - if(!blocking_acknowledged_) return result; - add(entry->availability==Availability::OnDevice?"sync":"refresh", - entry->availability==Availability::OnDevice?"Sync now":"Check availability"); - add("back","Back to library"); return result; - } - if(entry->book.needs_copy_choice || choice_==ChoiceState::LocalCopy) { - for(size_t i=0;ibook.copies.size();++i) { - const auto& copy=entry->book.copies[i]; - const auto command="copy:"+std::to_string(i); - add(command.c_str(),QString::fromStdString(copy.path)+" · "+percentageLabel(copy.percentage)); - } - add("back","Back to library");return result; - } - if(choice_==ChoiceState::OpenConflict) { - add("openPocketBook","Open at PocketBook position"); add("openReadest","Open at Readest position"); - add("back","Back to library"); return result; - } - if(choice_==ChoiceState::SyncConflict) { - add("usePocketBook","Use PocketBook position"); add("useReadest","Use Readest position"); - add("back","Back to library"); return result; - } - if(entry->upload_pending && signed_in_) { - add("upload","Retry upload / reading position"); add("back","Back to library"); return result; - } - if(entry->book.local_only || entry->book.book.deleted || !signed_in_) { - add("offline","Open at PocketBook position"); - if(signed_in_) add("upload",entry->book.book.deleted?"Re-upload to Readest":entry->upload_pending?"Retry upload":"Upload to Readest"); - else add("signin","Sign in to upload"); - if(entry->book.copies.size()>1) add("copies","Choose local copy"); - add("back","Back to library");return result; - } - if(entry->book.copies.size()>1) add("copies","Choose local copy"); - if(entry->availability==Availability::OnDevice) { - add("open",entry->sync.pending_remote.empty()?"Open":"Open at Readest position"); - add("sync","Sync now"); add("offline","Read offline"); - } else if(entry->availability==Availability::Downloadable) add("download","Download EPUB"); - else add("refresh","Check availability"); - if(entry->availability==Availability::OnDevice && entry->book.epubs==0) - add("upload","Upload EPUB to Readest"); - if(entry->availability==Availability::OnDevice && entry->book.epubs>0) - add("uploadCover","Upload cover to Readest"); - add("back","Back to library"); return result; +BookActions AppController::bookActions() const { + const auto* entry=library_.find(selected_); + if(busy() || !entry) return {}; + const auto block=blocking_state_!=BlockingState::None && selected_==blocking_book_ + ?(blocking_acknowledged_?BookBlock::Acknowledged:BookBlock::Pending):BookBlock::None; + return ::bookActions(*entry,signed_in_,choice_,block); } +QVariantList AppController::actions() const { return bookActions().allowed; } +QVariantMap AppController::actionPresentation() const { return bookActions().presentation(); } void AppController::submit(Request request) { if(runner_.busy()) { if(covers_.active()) { pending_request_=std::make_unique(std::move(request)); covers_.show({}); emit changed(); } diff --git a/apps/readest-sync/src/controller.h b/apps/readest-sync/src/controller.h index e7f1589..6111ff4 100644 --- a/apps/readest-sync/src/controller.h +++ b/apps/readest-sync/src/controller.h @@ -1,5 +1,6 @@ #pragma once #include "application.h" +#include "book_actions.h" #include "cover_loader.h" #include "device_adapter.h" #include "library_model.h" @@ -20,6 +21,7 @@ class AppController : public QObject { Q_PROPERTY(QString hint READ hint NOTIFY changed) Q_PROPERTY(QVariantMap book READ book NOTIFY changed) Q_PROPERTY(QVariantList actions READ actions NOTIFY changed) + Q_PROPERTY(QVariantMap actionPresentation READ actionPresentation NOTIFY changed) Q_PROPERTY(bool decision READ decision NOTIFY changed) Q_PROPERTY(bool blocking READ blocking NOTIFY changed) Q_PROPERTY(QString blockingMessage READ blockingMessage NOTIFY changed) @@ -39,6 +41,7 @@ class AppController : public QObject { QString hint() const; QVariantMap book() const; QVariantList actions() const; + QVariantMap actionPresentation() const; bool decision() const { return choice_==ChoiceState::SyncConflict || choice_==ChoiceState::OpenConflict; } bool blocking() const; QString blockingMessage() const; @@ -69,7 +72,7 @@ class AppController : public QObject { OperationRunner runner_; // Destroy/join the runner before the service. VisibleCoverLoader covers_; // Invalidates callbacks before runner destruction. readest::BookId selected_; - enum class ChoiceState { None, SyncConflict, OpenConflict, LocalCopy }; + using ChoiceState=BookChoice; ChoiceState choice_=ChoiceState::None; enum class BlockingState { None, AppliedUnrecorded, NativeCommitUncertain }; BlockingState blocking_state_=BlockingState::None; @@ -82,4 +85,5 @@ class AppController : public QObject { void submit(readest::Request request); void complete(const readest::Request& request,readest::OperationResult result); void prepareCovers(); + BookActions bookActions() const; }; diff --git a/apps/readest-sync/src/probe.cpp b/apps/readest-sync/src/probe.cpp index 85cb0e6..4056cdd 100644 --- a/apps/readest-sync/src/probe.cpp +++ b/apps/readest-sync/src/probe.cpp @@ -314,8 +314,8 @@ std::vector native_books(const std::string& database) { }); transaction.commit(); return result; } -NativePosition native_position(const std::string& snapshot, const std::string& book_path) { - Database db(snapshot); +NativePosition native_position(const std::string& database, const std::string& book_path) { + Database db(database); ReadTransaction transaction(db); NativePosition result; result.book_path=book_path; auto ids=query(db,native_identify,path_parameters(book_path)); if(json_object_array_length(ids.get())==0) return result; @@ -341,6 +341,7 @@ NativePosition native_position(const std::string& snapshot, const std::string& b } catch(const std::exception&) { /* Unknown counts do not block position sync. */ } if(result.profile_id.empty() || (!result.raw_position.empty() && result.cfi.empty())) throw UnsupportedNativePosition("Unsupported native saved position"); + transaction.commit(); return result; } std::map native_percentages(const std::string& database, diff --git a/apps/readest-sync/src/probe.h b/apps/readest-sync/src/probe.h index ddd56c2..b36f87d 100644 --- a/apps/readest-sync/src/probe.h +++ b/apps/readest-sync/src/probe.h @@ -54,9 +54,10 @@ struct NativePosition { std::string progress; bool indexed = false, has_settings = false; }; -// Read an app-owned snapshot after validating the managed EPUB bytes. +// Read identity and settings in one short, read-only transaction on the live +// database after validating the managed EPUB bytes. No database copy is needed. // Missing settings require a normal first Open before applying remote progress. -NativePosition native_position(const std::string& snapshot, const std::string& book_path); +NativePosition native_position(const std::string& database, const std::string& book_path); // Display-only page ratios from the live database; independent of CFI support. // Missing or ambiguous profiles and invalid page counts are omitted. std::map native_percentages(const std::string& database, diff --git a/apps/readest-sync/tests/probe_test.cpp b/apps/readest-sync/tests/probe_test.cpp index c140610..0f15963 100644 --- a/apps/readest-sync/tests/probe_test.cpp +++ b/apps/readest-sync/tests/probe_test.cpp @@ -1,8 +1,24 @@ #include "probe.h" #include #include +#include void sync_checks(); +namespace { +sqlite3* position_writer=nullptr; +bool position_changed=false; +int changePositionBetweenQueries(unsigned,void*,void*,void* sql) { + if(!position_changed && std::string(static_cast(sql)).find("SELECT * FROM books_settings")==0) { + assert(sqlite3_exec(position_writer,"UPDATE books_settings SET position_ts=999,cpage=99",nullptr,nullptr,nullptr)==SQLITE_OK); + position_changed=true; + } + return 0; +} +int tracePositionRead(sqlite3* db,char**,const sqlite3_api_routines*) { + return sqlite3_trace_v2(db,SQLITE_TRACE_STMT,changePositionBetweenQueries,nullptr); +} +} + int main(int argc, char** argv) { sync_checks(); using readest::point_cfi; @@ -18,7 +34,25 @@ int main(int argc, char** argv) { "pbr:/word?page=4", "pbr:/webkit?##epubcfi(/6/2!/4/2)\n", "epubcfi(/6/2!/4/2[bad^x])"}) assert(point_cfi(bad).empty()); assert(point_cfi(std::string(9000, 'x')).empty()); - if(argc==4 && std::string(argv[1])=="native-progress") { + if(argc==4 && std::string(argv[1])=="native-interleaved") { + // Register a test-only SQLite observer on the production read connection. + // The writer commits after identity was read, before settings are queried. + // https://sqlite.org/c3ref/auto_extension.html specifies this entry-point cast. + assert(sqlite3_open(argv[2],&position_writer)==SQLITE_OK); + assert(sqlite3_auto_extension(reinterpret_cast(tracePositionRead))==SQLITE_OK); + const auto before=readest::native_position(argv[2],argv[3]); + sqlite3_reset_auto_extension(); + assert(position_changed && before.timestamp=="100" && before.progress=="[10,100]"); + const auto after=readest::native_position(argv[2],argv[3]); + assert(after.timestamp=="999" && after.progress=="[99,100]"); + assert(sqlite3_close(position_writer)==SQLITE_OK); + } else if(argc==4 && std::string(argv[1])=="native-read") { + try { + const auto position=readest::native_position(argv[2],argv[3]); + std::cout<=4 && std::string(argv[1])=="percentage") { try { diff --git a/apps/readest-sync/tests/qt_ui_test.cpp b/apps/readest-sync/tests/qt_ui_test.cpp index 85a7dd3..4aa9e32 100644 --- a/apps/readest-sync/tests/qt_ui_test.cpp +++ b/apps/readest-sync/tests/qt_ui_test.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -37,6 +38,19 @@ static int count_named(QQuickItem* item,const QString& name) { for(auto* child:item->childItems()) count+=count_named(child,name); return count; } +static QQuickItem* find_item(QQuickItem* item,const QString& name) { + if(item->objectName()==name) return item; + for(auto* child:item->childItems()) if(auto* found=find_item(child,name)) return found; + return nullptr; +} +static void tap_book(QQuickWindow* window) { + auto* row=find_item(window->contentItem(),"detailedBookRow"); assert(row); + const auto point=row->mapToScene(QPointF(row->width()/2,row->height()/2)); + QMouseEvent press(QEvent::MouseButtonPress,point,point,Qt::LeftButton,Qt::LeftButton,Qt::NoModifier); + QCoreApplication::sendEvent(window,&press); + QMouseEvent release(QEvent::MouseButtonRelease,point,point,Qt::LeftButton,Qt::NoButton,Qt::NoModifier); + QCoreApplication::sendEvent(window,&release); settle(); +} static ApplicationConfig configAt(const QString& base) { ApplicationConfig config; config.root=(base+"/system/readest-sync").toStdString(); @@ -99,6 +113,56 @@ static void bookStatusChecks() { assert(control.hint().startsWith(expected+". Fixture author\n")); } } +static void bookActionChecks() { + auto commands=[](const QVariantList& actions) { + QStringList result; + for(const auto& action:actions) result.append(action.toMap().value("command").toString()); + return result.join(','); + }; + auto check=[&](const BookActions& actions,const char* allowed,const char* primary, + const char* secondary,const char* menu,const char* choices,bool choosing=false) { + assert(commands(actions.allowed)==allowed); + assert(actions.primary.value("command").toString()==primary); + assert(commands(actions.secondary)==secondary); + assert(commands(actions.menu)==menu); + assert(commands(actions.choices)==choices); + assert(actions.choosing==choosing); + }; + LibraryEntry entry; entry.availability=Availability::OnDevice; entry.book.epubs=1; + auto plan=[&](BookChoice choice=BookChoice::None,BookBlock block=BookBlock::None,bool signedIn=true) { + return bookActions(entry,signedIn,choice,block); + }; + check(plan(),"open,sync,offline,uploadCover,back","open","sync,offline","uploadCover",""); + entry.sync.pending_remote="epubcfi(/6/2!/4/2)"; + assert(plan().primary.value("text")=="Open at Readest position"); + entry.book.epubs=0; + check(plan(),"open,sync,offline,upload,back","open","sync,offline,upload","",""); + entry.upload_pending=true; + check(plan(),"upload,back","upload","","",""); + assert(plan().primary.value("text")=="Retry upload / reading position"); + check(plan(BookChoice::SyncConflict),"usePocketBook,useReadest,back","","","","usePocketBook,useReadest"); + check(plan(BookChoice::OpenConflict),"openPocketBook,openReadest,back","","","","openPocketBook,openReadest"); + check(plan(BookChoice::None,BookBlock::Pending),"","","","",""); + check(plan(BookChoice::None,BookBlock::Acknowledged),"sync,back","sync","","",""); + entry.upload_pending=false; entry.book.local_only=true; + check(plan(),"offline,upload,back","offline","upload","",""); + assert(plan().secondary.front().toMap().value("text")=="Upload to Readest"); + entry.book.book.deleted=true; + assert(plan().secondary.front().toMap().value("text")=="Re-upload to Readest"); + check(plan(BookChoice::None,BookBlock::None,false),"offline,signin,back","offline","signin","",""); + entry.book.local_only=false; entry.book.book.deleted=false; + entry.book.copies.resize(2); + check(plan(),"copies,open,sync,offline,upload,back","copies","open,sync,offline,upload","",""); + check(plan(BookChoice::LocalCopy),"copy:0,copy:1,back","","copy:0,copy:1","","",true); + entry.book.needs_copy_choice=true; + check(plan(),"copy:0,copy:1,back","","copy:0,copy:1","","",true); + entry.book.needs_copy_choice=false; entry.book.copies.clear(); + entry.availability=Availability::Downloadable; + check(plan(),"download,back","download","","",""); + entry.availability=Availability::Unknown; + check(plan(),"refresh,back","refresh","","",""); + check(plan(BookChoice::None,BookBlock::Acknowledged),"refresh,back","refresh","","",""); +} static void runnerChecks() { { // An online operation must hold standby off before waking Wi-Fi, @@ -263,7 +327,7 @@ static void runnerChecks() { } int main(int argc,char** argv) { QQuickWindow::setGraphicsApi(QSGRendererInterface::Software); - QGuiApplication app(argc,argv); bookStatusChecks(); runnerChecks(); + QGuiApplication app(argc,argv); bookStatusChecks(); bookActionChecks(); runnerChecks(); QTemporaryDir temp; assert(temp.isValid()); auto config=configAt(temp.path()); std::atomic cover_requests{0}; @@ -476,15 +540,44 @@ int main(int argc,char** argv) { if(const auto output=qEnvironmentVariable("READEST_UI_PREVIEW"); !output.isEmpty()) assert(picture.save(output+(wide?"-landscape.png":"-portrait.png"))); } - auto* libraryPage=window->findChild("nativeLibraryPage"); assert(libraryPage); + auto* libraryPage=find_item(window->contentItem(),"nativeLibraryPage"); assert(libraryPage); + for(int attempt=0;attempt<3;++attempt) { + tap_book(window); + assert(control.detail() && find_item(window->contentItem(),"nativeBookDetailsPage")); + control.back(); settle(); + } + libraryPage=find_item(window->contentItem(),"nativeLibraryPage"); assert(libraryPage); QMetaObject::invokeMethod(window,"handleHardwareButton",Q_ARG(QVariant,QVariant(Qt::Key_Menu))); settle(); assert(libraryPage->property("menuOpen").toBool()); QMetaObject::invokeMethod(window,"handleHardwareButton",Q_ARG(QVariant,QVariant(Qt::Key_Menu))); settle(); assert(!libraryPage->property("menuOpen").toBool()); hold_foreground=true; control.refreshLibrary(); settle(); assert(control.busy()); + tap_book(window); assert(!control.detail()); QMetaObject::invokeMethod(window,"handleHardwareButton",Q_ARG(QVariant,QVariant(Qt::Key_Menu))); settle(); assert(!libraryPage->property("menuOpen").toBool()); hold_foreground=false; finish(control); + tap_book(window); + assert(control.detail() && find_item(window->contentItem(),"nativeBookDetailsPage")); + control.back(); settle(); + libraryPage=find_item(window->contentItem(),"nativeLibraryPage"); assert(libraryPage); + auto* header=window->findChild("nativeHeader"); assert(header); + // The same shell receives Menu from both the header and hardware. + QVariantMap menuView{{"initialized",true},{"signingIn",false},{"detail",false}, + {"busy",false},{"decision",false},{"blocking",false},{"signedIn",true}, + {"title","Readest Sync"},{"status",""},{"statusKind","none"},{"blockingMessage",""}, + {"actions",QVariantList{}},{"library",QVariant::fromValue(control.library())}}; + for(const auto& guard:{QString(),QString("busy"),QString("decision"),QString("blocking")}) { + auto view=menuView; + if(!guard.isEmpty()) view[guard]=true; + window->setProperty("view",view); settle(); + for(bool hardware:{false,true}) { + libraryPage->setProperty("menuOpen",false); + if(hardware) QMetaObject::invokeMethod(window,"handleHardwareButton",Q_ARG(QVariant,QVariant(Qt::Key_Menu))); + else QMetaObject::invokeMethod(header,"action"); + assert(libraryPage->property("menuOpen").toBool()==guard.isEmpty()); + } + } + window->setProperty("view",QVariant::fromValue(&control)); settle(); libraryPage->setProperty("menuOpen",true); settle(); assert(window->findChild("nativeLibraryMenu")->isVisible()); QMetaObject::invokeMethod(window,"handleHardwareButton",Q_ARG(QVariant,QVariant(Qt::Key_Back))); settle(); @@ -500,8 +593,10 @@ int main(int argc,char** argv) { window->resize(1404,1800); settle(); control.selectBook("fixture-user",QString::fromStdString(first_hash)); settle(); assert(control.actions().size()==5); - auto* detailsPage=window->findChild("nativeBookDetailsPage"); assert(detailsPage); - assert(window->findChild("nativePrimaryAction")->isVisible()); + auto* detailsPage=find_item(window->contentItem(),"nativeBookDetailsPage"); assert(detailsPage); + assert(find_item(window->contentItem(),"nativePrimaryAction")->isVisible()); + assert(detailsPage->property("primaryAction").toMap()==control.actionPresentation().value("primary").toMap()); + assert(detailsPage->property("menuActions").toList()==control.actionPresentation().value("menu").toList()); assert(detailsPage->property("hasMenu").toBool()); QMetaObject::invokeMethod(window,"handleHardwareButton",Q_ARG(QVariant,QVariant(Qt::Key_Menu))); settle(); assert(detailsPage->property("menuOpen").toBool()); @@ -510,7 +605,7 @@ int main(int argc,char** argv) { if(const auto output=qEnvironmentVariable("READEST_UI_PREVIEW"); !output.isEmpty()) assert(window->grabWindow().save(output+"-detail-portrait.png")); QMetaObject::invokeMethod(detailsPage,"toggleMenu"); settle(); - assert(window->findChild("nativeBookMenu")->isVisible()); + assert(find_item(window->contentItem(),"nativeBookMenu")->isVisible()); QMetaObject::invokeMethod(window,"handleHardwareButton",Q_ARG(QVariant,QVariant(Qt::Key_Back))); settle(); assert(control.detail() && !detailsPage->property("menuOpen").toBool()); window->resize(1800,1404); settle(); assert(control.detail() && window->findChild("nativeBookDetailsPage")); diff --git a/apps/readest-sync/tests/test_probe.py b/apps/readest-sync/tests/test_probe.py index a82eec6..4d55e17 100644 --- a/apps/readest-sync/tests/test_probe.py +++ b/apps/readest-sync/tests/test_probe.py @@ -263,6 +263,44 @@ def percentages(requested): db.execute('INSERT INTO files SELECT book_id,folder_id,storageid,filename,? FROM files WHERE book_id=3', (b'x' * 16,)) self.assertEqual(percentages(paths[:4]), [10, -1, -1, 25]) + def test_native_position_keeps_one_snapshot_across_queries(self): + with closing(sqlite3.connect(self.explorer)) as db: + db.execute('PRAGMA journal_mode=WAL') + result = subprocess.run([str(self.binary), 'native-interleaved', str(self.explorer), BOOK], + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_native_position_reads_committed_wal_without_writing(self): + def read(path=BOOK): + before = self.explorer.read_bytes() + wal_path = Path(str(self.explorer) + '-wal') + wal = wal_path.read_bytes() + result = subprocess.run([str(self.binary), 'native-read', str(self.explorer), path], + capture_output=True, text=True) + self.assertEqual(self.explorer.read_bytes(), before) + self.assertEqual(wal_path.read_bytes(), wal) + return result + with closing(sqlite3.connect(self.explorer)) as db: + db.execute('PRAGMA journal_mode=WAL') + db.execute('UPDATE books_settings SET position_ts=765,cpage=25') + db.commit() + self.assertEqual(read().stdout.splitlines(), ['1', '1', '765', '[25,100]']) + db.execute('UPDATE books_settings SET position_ts=900,cpage=30') + # An active writer cannot leak uncommitted settings into a read. + self.assertEqual(read().stdout.splitlines(), ['1', '1', '765', '[25,100]']) + db.commit() + self.assertEqual(read().stdout.splitlines(), ['1', '1', '900', '[30,100]']) + self.assertEqual(read('/missing.epub').stdout.splitlines()[:2], ['0', '0']) + db.execute('INSERT INTO books_settings SELECT bookid,2,position,position_ts,cpage,npage,completed FROM books_settings') + db.commit() + self.assertIn('Multiple native reading profiles', read().stderr) + db.execute('DELETE FROM books_settings') + db.commit() + self.assertEqual(read().stdout.splitlines()[:2], ['1', '0']) + db.execute('INSERT INTO files SELECT book_id+1,folder_id,storageid,filename,fast_hash FROM files') + db.commit() + self.assertIn('Ambiguous native book identity', read().stderr) + def test_native_transactional_snapshot_includes_wal(self): with closing(sqlite3.connect(self.explorer)) as db, db: db.execute('PRAGMA journal_mode=WAL')