feat(koreader): retire devices without deleting their synced data

Removing a KOReader device was the only way to take a decommissioned reader off the list, and it
permanently deleted that device's page-by-page reading events, per-device progress, sweep record,
naming pattern and sole-source unmatched books. Derived reading sessions survived, but the raw
events did not, and the plugin keeps its upload watermark on the device, so nothing ever
re-uploaded them.

Add retirement as a marker table rather than a device registry. Device identity is implicit,
spread across five device-keyed tables and surfaced by a DISTINCT ON over device progress, so
there is no row to carry a flag and nothing to backfill. Retiring records only (user, device);
every byte the device synced stays where it is.

Clear the marker whenever a retired device syncs again, on single-book progress, bulk sweeps,
page-stat uploads and sweep completion, so reading never accrues against a device the user cannot
see. Keep deleting synced data as a separate, explicit action on the retired row, and clear the
marker it would otherwise leave behind.

Move the confirm copy to new message keys instead of reusing the ones whose meaning changed, so
the sparse target catalogs cannot ship a stale translation of a destructive dialog.

Closes #996
This commit is contained in:
neon
2026-08-14 05:34:06 +00:00
parent 53be54752d
commit 0480242a98
23 changed files with 16579 additions and 45 deletions
@@ -0,0 +1,9 @@
CREATE TABLE "koreader_device_retirements" (
"user_id" integer NOT NULL,
"device_id" varchar(100) NOT NULL,
"retired_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "koreader_device_retirements_user_id_device_id_pk" PRIMARY KEY("user_id","device_id")
);
--> statement-breakpoint
ALTER TABLE "koreader_device_retirements" ADD CONSTRAINT "koreader_device_retirements_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "koreader_device_retirements_user_id_idx" ON "koreader_device_retirements" USING btree ("user_id");
File diff suppressed because it is too large Load Diff
@@ -463,6 +463,13 @@
"when": 1786466476402,
"tag": "0065_add_collection_books_position",
"breakpoints": true
},
{
"idx": 66,
"version": "7",
"when": 1786683893541,
"tag": "0066_add_koreader_device_retirements",
"breakpoints": true
}
]
}
+18
View File
@@ -118,6 +118,24 @@ export const koreaderDeviceSettings = pgTable(
export type KoreaderDeviceSetting = typeof koreaderDeviceSettings.$inferSelect;
export type NewKoreaderDeviceSetting = typeof koreaderDeviceSettings.$inferInsert;
// Retiring a device hides it without touching its synced data. A marker table rather than a
// column because device identity is implicit: a device exists because it has progress, sweep
// or settings rows, so there is no single row to carry the flag.
export const koreaderDeviceRetirements = pgTable(
'koreader_device_retirements',
{
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
deviceId: varchar('device_id', { length: 100 }).notNull(),
retiredAt: timestamp('retired_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => [primaryKey({ columns: [t.userId, t.deviceId] }), index('koreader_device_retirements_user_id_idx').on(t.userId)],
);
export type KoreaderDeviceRetirement = typeof koreaderDeviceRetirements.$inferSelect;
export type NewKoreaderDeviceRetirement = typeof koreaderDeviceRetirements.$inferInsert;
export const bookFileHashHistory = pgTable(
'book_file_hash_history',
{
@@ -1,4 +1,4 @@
import { IsString, Matches, MaxLength, MinLength } from 'class-validator';
import { IsBoolean, IsString, Matches, MaxLength, MinLength } from 'class-validator';
export const KOREADER_DEVICE_ID_REGEX = /^[A-Za-z0-9-]{1,100}$/;
@@ -9,3 +9,8 @@ export class KoreaderDeviceParamDto {
@Matches(KOREADER_DEVICE_ID_REGEX)
deviceId!: string;
}
export class UpdateKoreaderDeviceDto {
@IsBoolean()
retired!: boolean;
}
@@ -29,6 +29,7 @@ describe('KoreaderPluginService', () => {
resolveBookFilesByHashes: ReturnType<typeof vi.fn>;
upsertUnmatchedBooks: ReturnType<typeof vi.fn>;
clearUnmatchedBooks: ReturnType<typeof vi.fn>;
restoreDevice: ReturnType<typeof vi.fn>;
};
let pluginRepo: {
getRatings: ReturnType<typeof vi.fn>;
@@ -63,6 +64,7 @@ describe('KoreaderPluginService', () => {
resolveBookFilesByHashes: vi.fn().mockResolvedValue(new Map([[HASH_A, { bookFileId: 10, bookId: 20, libraryId: 1 }]])),
upsertUnmatchedBooks: vi.fn().mockResolvedValue(undefined),
clearUnmatchedBooks: vi.fn().mockResolvedValue(undefined),
restoreDevice: vi.fn().mockResolvedValue(undefined),
};
pluginRepo = {
getRatings: vi.fn().mockResolvedValue(new Map()),
@@ -262,6 +262,7 @@ export class KoreaderPluginService {
pageStatsUploaded: dto.pageStatsUploaded,
annotationsUpserted: dto.annotationsUpserted,
});
await this.koreaderRepo.restoreDevice(user.id, dto.deviceId);
const accessibleLibraryIds = await this.koreaderRepo.getAccessibleLibraryIds(user.id);
const libraryVersion = await this.computeLibraryVersion(user.id, accessibleLibraryIds);
@@ -37,7 +37,11 @@ function makeSession(startEpoch: number): DerivedKoreaderSession {
}
describe('KoreaderStatsService', () => {
let koreaderRepo: { getAccessibleLibraryIds: ReturnType<typeof vi.fn>; resolveBookFilesByHashes: ReturnType<typeof vi.fn> };
let koreaderRepo: {
getAccessibleLibraryIds: ReturnType<typeof vi.fn>;
resolveBookFilesByHashes: ReturnType<typeof vi.fn>;
restoreDevice: ReturnType<typeof vi.fn>;
};
let pluginRepo: { ingestAndDeriveForBook: ReturnType<typeof vi.fn> };
let achievementEvents: { emit: ReturnType<typeof vi.fn> };
let service: KoreaderStatsService;
@@ -50,6 +54,7 @@ describe('KoreaderStatsService', () => {
koreaderRepo = {
getAccessibleLibraryIds: vi.fn().mockResolvedValue([1]),
resolveBookFilesByHashes: vi.fn().mockResolvedValue(new Map([[HASH_A, { bookFileId: 10, bookId: 20, libraryId: 1 }]])),
restoreDevice: vi.fn().mockResolvedValue(undefined),
};
pluginRepo = {
ingestAndDeriveForBook: vi.fn().mockResolvedValue({ accepted: 2, duplicates: 0, insertedSessions: [], updatedSessions: 0, deletedSessions: 0 }),
@@ -90,6 +90,8 @@ export class KoreaderStatsService {
results.push({ hash, accepted: result.accepted, duplicates: result.duplicates, watermark });
}
if (acceptedTotal > 0) await this.koreaderRepo.restoreDevice(user.id, dto.deviceId);
this.emitSessionAchievements(user, insertedSessions);
this.logger.log(
@@ -22,6 +22,7 @@ function makeController() {
setDeviceFileNamingPattern: vi.fn().mockResolvedValue(undefined),
clearDeviceFileNamingPattern: vi.fn().mockResolvedValue(undefined),
removeDevice: vi.fn().mockResolvedValue(undefined),
setDeviceRetired: vi.fn().mockResolvedValue(undefined),
getBookProgress: vi.fn().mockResolvedValue(null),
testConnection: vi.fn().mockResolvedValue(true),
};
@@ -158,6 +159,21 @@ describe('KoreaderController', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, KoreaderController.prototype.removeDevice)).toBe(Permission.KoreaderSync);
});
it('forwards device retirement changes to the service', async () => {
const { controller, koreaderService } = makeController();
const user = { id: 7 } as never;
await expect(controller.updateDevice(user, { deviceId: 'device-1' }, { retired: true })).resolves.toEqual({ success: true });
expect(koreaderService.setDeviceRetired).toHaveBeenCalledWith(7, 'device-1', true);
await expect(controller.updateDevice(user, { deviceId: 'device-1' }, { retired: false })).resolves.toEqual({ success: true });
expect(koreaderService.setDeviceRetired).toHaveBeenCalledWith(7, 'device-1', false);
});
it('grants Koreader Sync permission for the device update route', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, KoreaderController.prototype.updateDevice)).toBe(Permission.KoreaderSync);
});
it('grants Koreader Sync permission for the unmatched-book dismiss route', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, KoreaderController.prototype.dismissUnmatchedBook)).toBe(Permission.KoreaderSync);
});
@@ -32,6 +32,7 @@ import {
KoreaderSaveProgressDto,
LinkKoreaderUnmatchedBookDto,
TestConnectionDto,
UpdateKoreaderDeviceDto,
UpdateKoreaderDeviceFilePatternDto,
UpdateKoreaderFilePatternDto,
UpdateKoreaderManualHashLinkDto,
@@ -152,6 +153,13 @@ export class KoreaderController {
return { success: true };
}
@RequirePermission(Permission.KoreaderSync)
@Patch('devices/:deviceId')
async updateDevice(@CurrentUser() user: RequestUser, @Param() params: KoreaderDeviceParamDto, @Body() dto: UpdateKoreaderDeviceDto) {
await this.koreaderService.setDeviceRetired(user.id, params.deviceId, dto.retired);
return { success: true };
}
@RequirePermission(Permission.KoreaderSync)
@Delete('devices/:deviceId')
async removeDevice(@CurrentUser() user: RequestUser, @Param() params: KoreaderDeviceParamDto) {
@@ -623,18 +623,19 @@ describe('KoreaderRepository', () => {
await expect(repo.getAllDeviceProgress(10, 42)).resolves.toBe(rows);
});
it('maps device list rows from raw SQL results', async () => {
it('maps device list rows from raw SQL results, carrying the retirement marker', async () => {
const lastSync = new Date('2026-01-01T00:00:00.000Z');
const retiredAt = new Date('2026-02-01T00:00:00.000Z');
db.execute.mockResolvedValue({
rows: [
{ device: 'Kobo', device_id: 'device-1', last_sync_at: lastSync, last_book_title: 'Book' },
{ device: 'Phone', device_id: 'device-2', last_sync_at: lastSync, last_book_title: null },
{ device: 'Kobo', device_id: 'device-1', last_sync_at: lastSync, last_book_title: 'Book', retired_at: null },
{ device: 'Phone', device_id: 'device-2', last_sync_at: lastSync, last_book_title: null, retired_at: retiredAt },
],
});
await expect(repo.getDevicesList(42)).resolves.toEqual([
{ device: 'Kobo', deviceId: 'device-1', lastSyncAt: lastSync, lastBookTitle: 'Book' },
{ device: 'Phone', deviceId: 'device-2', lastSyncAt: lastSync, lastBookTitle: null },
{ device: 'Kobo', deviceId: 'device-1', lastSyncAt: lastSync, lastBookTitle: 'Book', retiredAt: null },
{ device: 'Phone', deviceId: 'device-2', lastSyncAt: lastSync, lastBookTitle: null, retiredAt: retiredAt },
]);
});
@@ -662,8 +663,9 @@ describe('KoreaderRepository', () => {
await expect(repo.removeDevice(42, 'device-1')).resolves.toBe(6);
expect(db.transaction).toHaveBeenCalledTimes(1);
expect(tx.delete).toHaveBeenCalledTimes(6);
expect(txDeleteBuilder.where).toHaveBeenCalledTimes(6);
// Six data deletes plus the retirement marker, which is cleared without being counted.
expect(tx.delete).toHaveBeenCalledTimes(7);
expect(txDeleteBuilder.where).toHaveBeenCalledTimes(7);
expect(returning).toHaveBeenCalledTimes(6);
expect(tx.select).toHaveBeenCalledTimes(1);
});
@@ -682,7 +684,7 @@ describe('KoreaderRepository', () => {
await expect(repo.removeDevice(42, 'device-1')).resolves.toBe(1);
expect(tx.delete).toHaveBeenCalledTimes(5);
expect(tx.delete).toHaveBeenCalledTimes(6);
expect(tx.select).not.toHaveBeenCalled();
});
@@ -697,6 +699,49 @@ describe('KoreaderRepository', () => {
});
});
describe('device retirement', () => {
it('retireDevice inserts a marker and tolerates one that already exists', async () => {
const onConflictDoNothing = vi.fn().mockResolvedValue(undefined);
const values = vi.fn().mockReturnValue({ onConflictDoNothing });
db.insert.mockReturnValue({ values });
await repo.retireDevice(42, 'device-1');
expect(values).toHaveBeenCalledWith({ userId: 42, deviceId: 'device-1' });
expect(onConflictDoNothing).toHaveBeenCalledTimes(1);
});
it('restoreDevice deletes the marker for the given user and device', async () => {
const where = vi.fn().mockResolvedValue(undefined);
db.delete.mockReturnValue({ where });
await repo.restoreDevice(42, 'device-1');
expect(db.delete).toHaveBeenCalledTimes(1);
expect(where).toHaveBeenCalledTimes(1);
});
it('listRetiredDeviceIds returns a device-to-timestamp map', async () => {
const retiredAt = new Date('2026-02-01T00:00:00.000Z');
db.select.mockReturnValue(makeQueryChain([{ deviceId: 'device-1', retiredAt }]));
await expect(repo.listRetiredDeviceIds(42)).resolves.toEqual(new Map([['device-1', retiredAt]]));
});
it('deviceExists reports a device known to any device-keyed table', async () => {
db.execute.mockResolvedValueOnce({ rows: [{ device_exists: true }] }).mockResolvedValueOnce({ rows: [{ device_exists: false }] });
await expect(repo.deviceExists(42, 'device-1')).resolves.toBe(true);
await expect(repo.deviceExists(42, 'device-2')).resolves.toBe(false);
});
it('deviceExists treats an empty result as unknown rather than throwing', async () => {
db.execute.mockResolvedValue({ rows: [] });
await expect(repo.deviceExists(42, 'device-1')).resolves.toBe(false);
});
});
describe('reading progress records', () => {
it('returns reading progress or null', async () => {
const row = { id: 1, bookFileId: 10, userId: 42 };
@@ -656,8 +656,9 @@ export class KoreaderRepository {
device_id: string;
last_sync_at: Date;
last_book_title: string | null;
retired_at: Date | null;
}>(sql`
SELECT device, device_id, last_sync_at, last_book_title
SELECT sub.device, sub.device_id, sub.last_sync_at, sub.last_book_title, r.retired_at
FROM (
SELECT DISTINCT ON (d.device, d.device_id)
d.device,
@@ -670,7 +671,8 @@ export class KoreaderRepository {
WHERE d.user_id = ${userId} AND d.orphaned = false
ORDER BY d.device, d.device_id, d.updated_at DESC
) sub
ORDER BY last_sync_at DESC
LEFT JOIN koreader_device_retirements r ON r.user_id = ${userId} AND r.device_id = sub.device_id
ORDER BY sub.last_sync_at DESC
`);
return result.rows.map((r) => ({
@@ -678,9 +680,45 @@ export class KoreaderRepository {
deviceId: r.device_id,
lastSyncAt: new Date(r.last_sync_at),
lastBookTitle: r.last_book_title ?? null,
retiredAt: r.retired_at ? new Date(r.retired_at) : null,
}));
}
async listRetiredDeviceIds(userId: number): Promise<Map<string, Date>> {
const rows = await this.db
.select({ deviceId: schema.koreaderDeviceRetirements.deviceId, retiredAt: schema.koreaderDeviceRetirements.retiredAt })
.from(schema.koreaderDeviceRetirements)
.where(eq(schema.koreaderDeviceRetirements.userId, userId));
return new Map(rows.map((row) => [row.deviceId, row.retiredAt]));
}
/** True when any device-keyed table still knows this device, which is what makes it listable. */
async deviceExists(userId: number, deviceId: string): Promise<boolean> {
const result = await this.db.execute<{ device_exists: boolean }>(sql`
SELECT
EXISTS (SELECT 1 FROM koreader_device_progress WHERE user_id = ${userId} AND device_id = ${deviceId})
OR EXISTS (SELECT 1 FROM koreader_device_sweeps WHERE user_id = ${userId} AND device_id = ${deviceId})
OR EXISTS (SELECT 1 FROM koreader_device_settings WHERE user_id = ${userId} AND device_id = ${deviceId})
AS device_exists
`);
return result.rows[0]?.device_exists === true;
}
async retireDevice(userId: number, deviceId: string): Promise<void> {
await this.db
.insert(schema.koreaderDeviceRetirements)
.values({ userId, deviceId })
.onConflictDoNothing({
target: [schema.koreaderDeviceRetirements.userId, schema.koreaderDeviceRetirements.deviceId],
});
}
async restoreDevice(userId: number, deviceId: string): Promise<void> {
await this.db
.delete(schema.koreaderDeviceRetirements)
.where(and(eq(schema.koreaderDeviceRetirements.userId, userId), eq(schema.koreaderDeviceRetirements.deviceId, deviceId)));
}
async getKoreaderUserDefaultPattern(userId: number): Promise<string | null> {
const row = await this.db.query.koreaderUserSettings.findFirst({
where: eq(schema.koreaderUserSettings.userId, userId),
@@ -763,6 +801,11 @@ export class KoreaderRepository {
.returning({ deviceId: schema.koreaderDeviceSettings.deviceId }),
]);
// Not counted towards the deleted-row total: a marker on its own never made the device listable.
await tx
.delete(schema.koreaderDeviceRetirements)
.where(and(eq(schema.koreaderDeviceRetirements.userId, userId), eq(schema.koreaderDeviceRetirements.deviceId, deviceId)));
// Only drop unmatched-book rows this device orphaned - a hash still reported by another
// device must stay visible until that device is removed (or the hash is matched/linked).
const affectedHashes = [...new Set(deletedUnmatchedDeviceLinks.map((row) => row.hash))];
@@ -146,6 +146,10 @@ describe('KoreaderService', () => {
getChapters: vi.fn(),
getLastFileWriteTime: vi.fn(),
removeDevice: vi.fn(),
listRetiredDeviceIds: vi.fn().mockResolvedValue(new Map()),
deviceExists: vi.fn().mockResolvedValue(true),
retireDevice: vi.fn().mockResolvedValue(undefined),
restoreDevice: vi.fn().mockResolvedValue(undefined),
};
mockChapterService = {
@@ -391,6 +395,7 @@ describe('KoreaderService', () => {
xpointer: '/body/DocFragment[7]',
pageNumber: null,
});
expect(mockRepo.restoreDevice).toHaveBeenCalledWith(12, 'device-12');
expect(mockBookService.syncKoboReadingStateForExternalProgress).toHaveBeenCalledWith(12, 44, 50);
expect(mockBookService.autoUpdateReadStatusForProgress).toHaveBeenCalledWith(
12,
@@ -815,6 +820,7 @@ describe('KoreaderService', () => {
deviceId: 'device-1',
lastSyncAt: '2026-02-01T10:00:00.000Z',
lastBookTitle: null,
retiredAt: null,
fileNamingPattern: null,
seriesFileNamingPattern: null,
standaloneFileNamingPattern: null,
@@ -1122,6 +1128,7 @@ describe('KoreaderService', () => {
deviceId: 'device-1',
lastSyncAt: '2026-02-01T10:00:00.000Z',
lastBookTitle: 'Project Hail Mary',
retiredAt: null,
fileNamingPattern: 'Device/{title}',
seriesFileNamingPattern: 'Series/{title}',
standaloneFileNamingPattern: null,
@@ -1131,6 +1138,7 @@ describe('KoreaderService', () => {
deviceId: 'device-2',
lastSyncAt: '2026-02-01T11:00:00.000Z',
lastBookTitle: null,
retiredAt: null,
fileNamingPattern: null,
seriesFileNamingPattern: null,
standaloneFileNamingPattern: null,
@@ -1251,6 +1259,30 @@ describe('KoreaderService', () => {
});
});
describe('setDeviceRetired', () => {
it('retires a known device without deleting any of its data', async () => {
await service.setDeviceRetired(7, 'device-1', true);
expect(mockRepo.retireDevice).toHaveBeenCalledWith(7, 'device-1');
expect(mockRepo.restoreDevice).not.toHaveBeenCalled();
expect(mockRepo.removeDevice).not.toHaveBeenCalled();
});
it('restores a retired device', async () => {
await service.setDeviceRetired(7, 'device-1', false);
expect(mockRepo.restoreDevice).toHaveBeenCalledWith(7, 'device-1');
expect(mockRepo.retireDevice).not.toHaveBeenCalled();
});
it('throws NotFoundException for a device no table knows about', async () => {
mockRepo.deviceExists.mockResolvedValue(false);
await expect(service.setDeviceRetired(7, 'missing-device', true)).rejects.toThrow(NotFoundException);
expect(mockRepo.retireDevice).not.toHaveBeenCalled();
});
});
describe('getBookProgress', () => {
it('returns full sync info with chapters when KOReader is the canonical source', async () => {
const latestDeviceTime = new Date('2026-03-01T10:00:00.000Z');
@@ -22,6 +22,7 @@ const BCRYPT_ROUNDS = 12;
const SYNC_EVENT = 'koreader.sync';
const CREDENTIALS_EVENT = 'koreader.credentials';
const DEVICE_REMOVE_EVENT = 'koreader.device_remove';
const DEVICE_RETIRE_EVENT = 'koreader.device_retire';
const FILE_NAMING_EVENT = 'koreader.file_naming';
const DEFAULT_DEVICE = 'KOReader';
const FILE_NAMING_CACHE_TTL_MS = 30_000;
@@ -190,6 +191,10 @@ export class KoreaderService {
syncTimestamp: data.timestamp ?? null,
});
// A retired device that syncs again is back in service. Clearing the marker keeps it
// visible rather than letting activity accrue against a device the user cannot see.
await this.repo.restoreDevice(userId, data.deviceId);
if (options?.skipSharedProgress) return;
const previousPercentage = previousDeviceProgress?.percentage != null ? toBookorbitPercentage(previousDeviceProgress.percentage) : null;
@@ -263,6 +268,7 @@ export class KoreaderService {
});
}
await this.repo.upsertDeviceProgressMany([...deviceUpserts.values()], appliedAt);
await this.repo.restoreDevice(userId, device.deviceId);
const shared = plans.filter((plan) => !plan.stale);
// Chapter extraction parses an EPUB per file, so it stays off the request path as it
@@ -391,7 +397,7 @@ export class KoreaderService {
}
async getSyncStatus(userId: number): Promise<KoreaderSyncStatus> {
const [credentials, deviceRows, deviceSettings, totalSyncedBooks, sweepRows, pluginTotals, versionInfo] = await Promise.all([
const [credentials, deviceRows, deviceSettings, totalSyncedBooks, sweepRows, pluginTotals, versionInfo, retirements] = await Promise.all([
this.getCredentials(userId),
this.repo.getDevicesList(userId),
this.repo.getDeviceFileNamingPatterns(userId),
@@ -399,9 +405,11 @@ export class KoreaderService {
this.pluginRepo.listSweeps(userId),
this.pluginRepo.getPluginTotals(userId),
this.packageService.getVersionInfo(),
this.repo.listRetiredDeviceIds(userId),
]);
const devices = this.mapDevices(deviceRows, deviceSettings);
const lastSyncAt = devices.length > 0 ? devices[0]!.lastSyncAt : null;
// Rows are ordered newest first, so the first active device is the last sync that still counts.
const lastSyncAt = devices.find((device) => device.retiredAt === null)?.lastSyncAt ?? null;
const latestPluginVersion = versionInfo.pluginVersion === 'unknown' ? null : versionInfo.pluginVersion;
const settingsByDevice = new Map(deviceSettings.map((setting) => [setting.deviceId, setting]));
const sweeps = sweepRows.map((row) => {
@@ -417,12 +425,13 @@ export class KoreaderService {
lastSweepBooksMatched: row.lastSweepBooksMatched,
lastSweepPageStats: row.lastSweepPageStats,
lastSweepAnnotations: row.lastSweepAnnotations,
retiredAt: retirements.get(row.deviceId)?.toISOString() ?? null,
fileNamingPattern: setting?.fileNamingPattern ?? null,
seriesFileNamingPattern: setting?.seriesFileNamingPattern ?? null,
standaloneFileNamingPattern: setting?.standaloneFileNamingPattern ?? null,
};
});
const pluginUpdateAvailable = sweeps.some((sweep) => sweep.updateAvailable === true);
const pluginUpdateAvailable = sweeps.some((sweep) => sweep.retiredAt === null && sweep.updateAvailable === true);
return { credentials, devices, totalSyncedBooks, lastSyncAt, latestPluginVersion, pluginUpdateAvailable, sweeps, pluginTotals };
}
@@ -444,6 +453,7 @@ export class KoreaderService {
deviceId: r.deviceId,
lastSyncAt: r.lastSyncAt.toISOString(),
lastBookTitle: r.lastBookTitle,
retiredAt: r.retiredAt?.toISOString() ?? null,
fileNamingPattern: setting?.fileNamingPattern ?? null,
seriesFileNamingPattern: setting?.seriesFileNamingPattern ?? null,
standaloneFileNamingPattern: setting?.standaloneFileNamingPattern ?? null,
@@ -547,6 +557,30 @@ export class KoreaderService {
);
}
/**
* Retiring hides a decommissioned device without touching a byte of what it synced.
* Restoring is the same call with `retired: false`, and a device that syncs again
* restores itself.
*/
async setDeviceRetired(userId: number, deviceId: string, retired: boolean): Promise<void> {
const startedAt = Date.now();
const safeDeviceId = sanitizeLogValue(deviceId);
if (!(await this.repo.deviceExists(userId, deviceId))) {
this.logger.warn(
`[${DEVICE_RETIRE_EVENT}] [fail] userId=${userId} deviceId="${safeDeviceId}" retired=${retired} durationMs=${Date.now() - startedAt} errorClass=NotFoundException error="device not found" - set device retired failed`,
);
throw new NotFoundException('KOReader device not found');
}
if (retired) await this.repo.retireDevice(userId, deviceId);
else await this.repo.restoreDevice(userId, deviceId);
this.logger.log(
`[${DEVICE_RETIRE_EVENT}] [end] userId=${userId} deviceId="${safeDeviceId}" retired=${retired} durationMs=${Date.now() - startedAt} - set device retired completed`,
);
}
private fileNamingCacheScope(userId: number): string {
return `koreader-file-naming:${userId}`;
}