worldhopper/lib/database/database.dart
Felipe M. 58a562abe8
feat: refactor server software abstraction with Kavita REST API integration
Rename all OPDS-prefixed models to generic names (Server, Feed, Entry,
Link, StreamLink), expand the ServerSoftware interface to cover all
server interactions, and implement a full Kavita REST API client that
replaces the OPDS delegation.

- Rename OPDS* models to generic names across ~60 files
- Add database migrations 13 (opds_id → entry_id) and 14 (unify credentials)
- Create OPDSServerSoftware wrapping existing OPDS services
- Create KavitaApiClient for direct Kavita REST API calls
- Create KavitaFeedMapper to convert Kavita JSON to Feed/Entry models
- Rewrite KavitaServerSoftware to use native API (no OPDS delegation)
- Unify server credentials (remove softwareUsername/softwarePassword)
- Simplify add/edit server UI to single auth section
- Add test connection button to server add/edit screens
- Add progress indicator to PublicationCard using local and server data
- Eliminate softwareType branching in reader screens
- Add EntryProgress and fetchEntryProgress to ServerSoftware interface
- Fix Entry.acquisitionLink crash on empty links
- Add 59 new tests covering models, services, and providers
2026-04-06 17:46:48 +02:00

241 lines
8.8 KiB
Dart

import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:worldhopper/config/constants.dart';
import 'package:worldhopper/database/tables/servers_table.dart';
import 'package:worldhopper/database/tables/reading_progress_table.dart';
import 'package:worldhopper/database/tables/publications_table.dart';
import 'package:worldhopper/database/tables/series_reading_mode_table.dart';
import 'package:worldhopper/database/tables/series_cover_page_table.dart';
import 'package:worldhopper/database/tables/series_two_page_mode_table.dart';
import 'package:worldhopper/database/tables/downloaded_series_table.dart';
import 'package:worldhopper/database/tables/downloaded_chapters_table.dart';
/// Incremental migrations from one version to the next.
/// Key is the target version (e.g. 5 means "migrate from 4 to 5").
final Map<int, Future<void> Function(Database)> _migrations = {
5: (db) async {
await db.execute(
'ALTER TABLE reading_progress ADD COLUMN series_feed_url TEXT');
},
6: (db) async {
await db.execute(SeriesReadingModeTable.createTable);
},
7: (db) async {
await db.execute(SeriesToPageModeTable.createTable);
await db.execute(SeriesCoverPageTable.createTable);
},
9: (db) async {
await db.execute('ALTER TABLE servers ADD COLUMN software_type TEXT');
},
10: (db) async {
await db.execute('ALTER TABLE servers ADD COLUMN software_username TEXT');
await db.execute('ALTER TABLE servers ADD COLUMN software_password TEXT');
},
11: (db) async {
await db.execute(DownloadedSeriesTable.createTable);
await db.execute(DownloadedSeriesTable.createServerIndex);
await db.execute(DownloadedSeriesTable.createStatusIndex);
await db.execute(DownloadedChaptersTable.createTable);
await db.execute(DownloadedChaptersTable.createSeriesIndex);
await db.execute(DownloadedChaptersTable.createServerIndex);
await db.execute(DownloadedChaptersTable.createStatusIndex);
},
12: (db) async {
// Check if column already exists (migration 11 creates the table with this column)
final columns = await db.rawQuery('PRAGMA table_info(downloaded_chapters)');
final hasErrorMessage =
columns.any((col) => col['name'] == 'error_message');
if (!hasErrorMessage) {
await db.execute(
'ALTER TABLE downloaded_chapters ADD COLUMN error_message TEXT');
}
},
13: (db) async {
// Rename opds_id → entry_id in publications table.
// Using table-recreate approach for compatibility with SQLite < 3.25.0.
// Wrapped in a transaction to ensure atomicity — if any step fails,
// the entire migration rolls back and the original table is preserved.
await db.transaction((txn) async {
await txn.execute('''
CREATE TABLE publications_new (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL,
entry_id TEXT NOT NULL,
title TEXT NOT NULL,
authors TEXT,
summary TEXT,
content TEXT,
cover_path TEXT,
thumbnail_path TEXT,
cover_url TEXT,
thumbnail_url TEXT,
series TEXT,
series_position REAL,
publisher TEXT,
language TEXT,
isbn TEXT,
categories TEXT,
links TEXT,
stream_link TEXT,
published INTEGER,
updated INTEGER,
first_cached_at INTEGER NOT NULL,
last_cached_at INTEGER NOT NULL,
last_accessed_at INTEGER NOT NULL,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
UNIQUE(server_id, entry_id)
)
''');
await txn.execute('''
INSERT INTO publications_new
SELECT id, server_id, opds_id, title, authors, summary, content,
cover_path, thumbnail_path, cover_url, thumbnail_url,
series, series_position, publisher, language, isbn,
categories, links, stream_link, published, updated,
first_cached_at, last_cached_at, last_accessed_at
FROM publications
''');
await txn.execute('DROP TABLE publications');
await txn.execute('ALTER TABLE publications_new RENAME TO publications');
// Recreate indexes
await txn.execute(PublicationsTable.createServerIndex);
await txn.execute(PublicationsTable.createServerEntryIndex);
await txn.execute(PublicationsTable.createAccessedIndex);
});
},
14: (db) async {
// Unify credentials: migrate software_username/software_password into
// username/password where the user had software creds but no OPDS creds,
// then drop the software credential columns.
await db.transaction((txn) async {
// Preserve software credentials for servers that only had those
await txn.execute('''
UPDATE servers
SET username = software_username, password = software_password
WHERE software_username IS NOT NULL
AND (username IS NULL OR username = '')
''');
// Recreate table without software credential columns
await txn.execute('''
CREATE TABLE servers_new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
username TEXT,
password TEXT,
created_at INTEGER NOT NULL,
last_synced_at INTEGER,
software_type TEXT
)
''');
await txn.execute('''
INSERT INTO servers_new
SELECT id, name, url, username, password, created_at,
last_synced_at, software_type
FROM servers
''');
await txn.execute('DROP TABLE servers');
await txn.execute('ALTER TABLE servers_new RENAME TO servers');
});
},
};
/// Database helper for SQLite operations
class DatabaseHelper {
DatabaseHelper._();
static final DatabaseHelper instance = DatabaseHelper._();
static Database? _database;
/// Get the database instance, creating it if necessary
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDatabase();
return _database!;
}
/// Initialize the database
Future<Database> _initDatabase() async {
final databasePath = await getDatabasesPath();
final path = join(databasePath, AppConstants.databaseName);
return await openDatabase(
path,
version: AppConstants.databaseVersion,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
onConfigure: _onConfigure,
);
}
/// Configure database settings
Future<void> _onConfigure(Database db) async {
// Enable foreign key constraints
await db.execute('PRAGMA foreign_keys = ON');
}
/// Create database tables
Future<void> _onCreate(Database db, int version) async {
// Create servers table
await db.execute(ServersTable.createTable);
// Create publications table
await db.execute(PublicationsTable.createTable);
await db.execute(PublicationsTable.createServerIndex);
await db.execute(PublicationsTable.createServerEntryIndex);
await db.execute(PublicationsTable.createAccessedIndex);
// Create reading_progress table
await db.execute(ReadingProgressTable.createTable);
await db.execute(ReadingProgressTable.createPublicationIndex);
await db.execute(ReadingProgressTable.createServerIndex);
await db.execute(ReadingProgressTable.createCacheIdIndex);
// Create series_reading_mode table
await db.execute(SeriesReadingModeTable.createTable);
// Create series_two_page_mode table
await db.execute(SeriesToPageModeTable.createTable);
// Create series_cover_page table
await db.execute(SeriesCoverPageTable.createTable);
// Create downloaded_series table
await db.execute(DownloadedSeriesTable.createTable);
await db.execute(DownloadedSeriesTable.createServerIndex);
await db.execute(DownloadedSeriesTable.createStatusIndex);
// Create downloaded_chapters table
await db.execute(DownloadedChaptersTable.createTable);
await db.execute(DownloadedChaptersTable.createSeriesIndex);
await db.execute(DownloadedChaptersTable.createServerIndex);
await db.execute(DownloadedChaptersTable.createStatusIndex);
}
/// Handle database upgrades
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
// Apply incremental migrations from oldVersion+1 to newVersion
for (var version = oldVersion + 1; version <= newVersion; version++) {
final migration = _migrations[version];
if (migration != null) {
await migration(db);
}
}
}
/// Close the database
Future<void> close() async {
final db = await database;
await db.close();
_database = null;
}
/// Delete the database (for testing purposes)
Future<void> deleteDatabase() async {
final databasePath = await getDatabasesPath();
final path = join(databasePath, AppConstants.databaseName);
await databaseFactory.deleteDatabase(path);
_database = null;
}
}