diff --git a/client/src/features/library/components/LibraryCreatorFileWrite.vue b/client/src/features/library/components/LibraryCreatorFileWrite.vue index 9be95ef9d..8b9d6b49a 100644 --- a/client/src/features/library/components/LibraryCreatorFileWrite.vue +++ b/client/src/features/library/components/LibraryCreatorFileWrite.vue @@ -10,6 +10,8 @@ const props = defineProps<{ fileWriteWriteCover: boolean fileWriteEpubEnabled: boolean fileWriteEpubMaxFileSizeMb: number + fileWriteFb2Enabled: boolean + fileWriteFb2MaxFileSizeMb: number fileWritePdfEnabled: boolean fileWritePdfMaxFileSizeMb: number fileWriteCbxEnabled: boolean @@ -26,6 +28,8 @@ const emit = defineEmits<{ 'update:fileWriteWriteCover': [value: boolean] 'update:fileWriteEpubEnabled': [value: boolean] 'update:fileWriteEpubMaxFileSizeMb': [value: number] + 'update:fileWriteFb2Enabled': [value: boolean] + 'update:fileWriteFb2MaxFileSizeMb': [value: number] 'update:fileWritePdfEnabled': [value: boolean] 'update:fileWritePdfMaxFileSizeMb': [value: number] 'update:fileWriteCbxEnabled': [value: boolean] @@ -56,6 +60,10 @@ function handleEpubToggle() { emit('update:fileWriteEpubEnabled', !props.fileWriteEpubEnabled) } +function handleFb2Toggle() { + emit('update:fileWriteFb2Enabled', !props.fileWriteFb2Enabled) +} + function handlePdfToggle() { emit('update:fileWritePdfEnabled', !props.fileWritePdfEnabled) } @@ -72,9 +80,10 @@ function handleAudioToggle() { emit('update:fileWriteAudioEnabled', !props.fileWriteAudioEnabled) } -function onMaxSizeInput(field: 'epub' | 'pdf' | 'cbx' | 'kindle' | 'audio', e: Event) { +function onMaxSizeInput(field: 'epub' | 'fb2' | 'pdf' | 'cbx' | 'kindle' | 'audio', e: Event) { const val = Number((e.target as HTMLInputElement).value) if (field === 'epub') emit('update:fileWriteEpubMaxFileSizeMb', val) + else if (field === 'fb2') emit('update:fileWriteFb2MaxFileSizeMb', val) else if (field === 'pdf') emit('update:fileWritePdfMaxFileSizeMb', val) else if (field === 'cbx') emit('update:fileWriteCbxMaxFileSizeMb', val) else if (field === 'kindle') emit('update:fileWriteKindleMaxFileSizeMb', val) @@ -85,6 +94,10 @@ function onEpubMaxSizeInput(e: Event) { onMaxSizeInput('epub', e) } +function onFb2MaxSizeInput(e: Event) { + onMaxSizeInput('fb2', e) +} + function onPdfMaxSizeInput(e: Event) { onMaxSizeInput('pdf', e) } @@ -177,6 +190,33 @@ function onAudioMaxSizeInput(e: Event) { +
+
+
+

{{ t('library.creator.fileWrite.fb2.title') }}

+

{{ t('library.creator.fileWrite.fb2.hint') }}

+
+ +
+ +
+
diff --git a/client/src/features/library/components/LibraryCreatorModal.vue b/client/src/features/library/components/LibraryCreatorModal.vue index 65388d2ba..7b054b31e 100644 --- a/client/src/features/library/components/LibraryCreatorModal.vue +++ b/client/src/features/library/components/LibraryCreatorModal.vue @@ -163,6 +163,8 @@ const sectionProps = computed(() => ({ fileWriteWriteCover: form.fileWriteWriteCover, fileWriteEpubEnabled: form.fileWriteEpubEnabled, fileWriteEpubMaxFileSizeMb: form.fileWriteEpubMaxFileSizeMb, + fileWriteFb2Enabled: form.fileWriteFb2Enabled, + fileWriteFb2MaxFileSizeMb: form.fileWriteFb2MaxFileSizeMb, fileWritePdfEnabled: form.fileWritePdfEnabled, fileWritePdfMaxFileSizeMb: form.fileWritePdfMaxFileSizeMb, fileWriteCbxEnabled: form.fileWriteCbxEnabled, @@ -277,6 +279,8 @@ const sectionListeners = { 'update:fileWriteWriteCover': (value: boolean) => (form.fileWriteWriteCover = value), 'update:fileWriteEpubEnabled': (value: boolean) => (form.fileWriteEpubEnabled = value), 'update:fileWriteEpubMaxFileSizeMb': (value: number) => (form.fileWriteEpubMaxFileSizeMb = value), + 'update:fileWriteFb2Enabled': (value: boolean) => (form.fileWriteFb2Enabled = value), + 'update:fileWriteFb2MaxFileSizeMb': (value: number) => (form.fileWriteFb2MaxFileSizeMb = value), 'update:fileWritePdfEnabled': (value: boolean) => (form.fileWritePdfEnabled = value), 'update:fileWritePdfMaxFileSizeMb': (value: number) => (form.fileWritePdfMaxFileSizeMb = value), 'update:fileWriteCbxEnabled': (value: boolean) => (form.fileWriteCbxEnabled = value), diff --git a/client/src/features/library/components/__tests__/LibraryCreatorFileWrite.spec.ts b/client/src/features/library/components/__tests__/LibraryCreatorFileWrite.spec.ts index 9833505df..1d3e0ffa5 100644 --- a/client/src/features/library/components/__tests__/LibraryCreatorFileWrite.spec.ts +++ b/client/src/features/library/components/__tests__/LibraryCreatorFileWrite.spec.ts @@ -12,6 +12,8 @@ describe('LibraryCreatorFileWrite', () => { fileWriteWriteCover: false, fileWriteEpubEnabled: false, fileWriteEpubMaxFileSizeMb: 100, + fileWriteFb2Enabled: false, + fileWriteFb2MaxFileSizeMb: 100, fileWritePdfEnabled: false, fileWritePdfMaxFileSizeMb: 100, fileWriteCbxEnabled: false, @@ -37,54 +39,94 @@ describe('LibraryCreatorFileWrite', () => { expect(wrapper.emitted('update:fileRenameEnabled')).toEqual([[true]]) }) - it('emits file-write toggles and max-size updates when advanced controls are visible', async () => { - const wrapper = mountComponent({ - fileRenameEnabled: true, - fileWriteEnabled: true, - fileWriteWriteCover: true, - fileWriteEpubEnabled: true, - fileWriteEpubMaxFileSizeMb: 10, - fileWritePdfEnabled: true, - fileWritePdfMaxFileSizeMb: 20, - fileWriteCbxEnabled: true, - fileWriteCbxMaxFileSizeMb: 30, - fileWriteKindleEnabled: true, - fileWriteKindleMaxFileSizeMb: 50, - fileWriteAudioEnabled: true, - fileWriteAudioMaxFileSizeMb: 40, - }) + const ALL_ENABLED = { + fileRenameEnabled: true, + fileWriteEnabled: true, + fileWriteWriteCover: true, + fileWriteEpubEnabled: true, + fileWriteEpubMaxFileSizeMb: 10, + fileWriteFb2Enabled: true, + fileWriteFb2MaxFileSizeMb: 60, + fileWritePdfEnabled: true, + fileWritePdfMaxFileSizeMb: 20, + fileWriteCbxEnabled: true, + fileWriteCbxMaxFileSizeMb: 30, + fileWriteKindleEnabled: true, + fileWriteKindleMaxFileSizeMb: 50, + fileWriteAudioEnabled: true, + fileWriteAudioMaxFileSizeMb: 40, + } - const switches = wrapper.findAll('[role="switch"]') - expect(switches).toHaveLength(8) - await switches[1]!.trigger('click') - await switches[2]!.trigger('click') - await switches[3]!.trigger('click') - await switches[4]!.trigger('click') - await switches[5]!.trigger('click') - await switches[6]!.trigger('click') - await switches[7]!.trigger('click') + function toggleByLabel(wrapper: ReturnType, label: string) { + const control = wrapper.findAll('[role="switch"]').find((node) => node.attributes('aria-label') === label) + if (!control) throw new Error(`no switch labelled "${label}"`) + return control.trigger('click') + } + + it('emits an update for every format toggle', async () => { + const wrapper = mountComponent(ALL_ENABLED) + + expect(wrapper.findAll('[role="switch"]')).toHaveLength(9) + + await toggleByLabel(wrapper, 'Write metadata to files') + await toggleByLabel(wrapper, 'Write EPUB metadata') + await toggleByLabel(wrapper, 'Write FB2 metadata') + await toggleByLabel(wrapper, 'Write PDF metadata') + await toggleByLabel(wrapper, 'Write comic archive metadata') + await toggleByLabel(wrapper, 'Write Kindle metadata') + await toggleByLabel(wrapper, 'Write audio covers') - const inputs = wrapper.findAll('input[type="number"]') - expect(inputs).toHaveLength(5) - await inputs[0]!.setValue('15') - await inputs[1]!.setValue('25') - await inputs[2]!.setValue('35') - await inputs[3]!.setValue('55') - await inputs[4]!.setValue('45') expect(wrapper.emitted('update:fileWriteEnabled')).toEqual([[false]]) - expect(wrapper.emitted('update:fileWriteWriteCover')).toEqual([[false]]) expect(wrapper.emitted('update:fileWriteEpubEnabled')).toEqual([[false]]) + expect(wrapper.emitted('update:fileWriteFb2Enabled')).toEqual([[false]]) expect(wrapper.emitted('update:fileWritePdfEnabled')).toEqual([[false]]) expect(wrapper.emitted('update:fileWriteCbxEnabled')).toEqual([[false]]) expect(wrapper.emitted('update:fileWriteKindleEnabled')).toEqual([[false]]) - expect(wrapper.emitted('update:fileWriteAudioEnabled')).toEqual([[false], [false]]) + expect(wrapper.emitted('update:fileWriteAudioEnabled')).toEqual([[false]]) + }) + + it('emits max-size updates for every format', async () => { + const wrapper = mountComponent(ALL_ENABLED) + + const inputs = wrapper.findAll('input[type="number"]') + expect(inputs).toHaveLength(6) + + await wrapper.find('#epub-max-size').setValue('15') + await wrapper.find('#fb2-max-size').setValue('65') + await wrapper.find('#pdf-max-size').setValue('25') + await wrapper.find('#cbx-max-size').setValue('35') + await wrapper.find('#kindle-max-size').setValue('55') + await wrapper.find('#audio-max-size').setValue('45') + expect(wrapper.emitted('update:fileWriteEpubMaxFileSizeMb')).toEqual([[15]]) + expect(wrapper.emitted('update:fileWriteFb2MaxFileSizeMb')).toEqual([[65]]) expect(wrapper.emitted('update:fileWritePdfMaxFileSizeMb')).toEqual([[25]]) expect(wrapper.emitted('update:fileWriteCbxMaxFileSizeMb')).toEqual([[35]]) expect(wrapper.emitted('update:fileWriteKindleMaxFileSizeMb')).toEqual([[55]]) expect(wrapper.emitted('update:fileWriteAudioMaxFileSizeMb')).toEqual([[45]]) }) + it('hides the FB2 size input until the FB2 toggle is on', () => { + const off = mountComponent({ fileWriteEnabled: true, fileWriteFb2Enabled: false }) + expect(off.text()).toContain('FictionBook (FB2)') + expect(off.find('#fb2-max-size').exists()).toBe(false) + + const on = mountComponent({ fileWriteEnabled: true, fileWriteFb2Enabled: true }) + expect(on.find('#fb2-max-size').exists()).toBe(true) + expect(on.find('#fb2-max-size').attributes('value')).toBe('100') + }) + + it('shows FB2 controls independently of the cover-writing toggle', () => { + const wrapper = mountComponent({ fileWriteEnabled: true, fileWriteWriteCover: false, fileWriteFb2Enabled: true }) + + expect(wrapper.text()).toContain('FictionBook (FB2)') + expect(wrapper.find('#fb2-max-size').exists()).toBe(true) + }) + + it('hides the FB2 card until file writing is enabled', () => { + expect(mountComponent({ fileWriteEnabled: false, fileWriteFb2Enabled: true }).text()).not.toContain('FictionBook (FB2)') + }) + it('hides the Kindle size input until the Kindle toggle is on', () => { const off = mountComponent({ fileWriteEnabled: true, fileWriteKindleEnabled: false }) expect(off.text()).toContain('Kindle (MOBI, AZW3)') diff --git a/client/src/features/library/composables/__tests__/useLibraries.spec.ts b/client/src/features/library/composables/__tests__/useLibraries.spec.ts index 3e966f888..cbe109c59 100644 --- a/client/src/features/library/composables/__tests__/useLibraries.spec.ts +++ b/client/src/features/library/composables/__tests__/useLibraries.spec.ts @@ -28,6 +28,8 @@ function makeLibrary(overrides: Partial = {}): Library { fileWriteWriteCover: false, fileWriteEpubEnabled: false, fileWriteEpubMaxFileSizeMb: 50, + fileWriteFb2Enabled: false, + fileWriteFb2MaxFileSizeMb: 100, fileWritePdfEnabled: false, fileWritePdfMaxFileSizeMb: 50, fileWriteCbxEnabled: false, diff --git a/client/src/features/library/composables/__tests__/useLibraryCreator.spec.ts b/client/src/features/library/composables/__tests__/useLibraryCreator.spec.ts index 4df464865..ba32dcc27 100644 --- a/client/src/features/library/composables/__tests__/useLibraryCreator.spec.ts +++ b/client/src/features/library/composables/__tests__/useLibraryCreator.spec.ts @@ -130,6 +130,8 @@ describe('useLibraryCreator', () => { fileWriteWriteCover: true, fileWriteEpubEnabled: true, fileWriteEpubMaxFileSizeMb: 100, + fileWriteFb2Enabled: false, + fileWriteFb2MaxFileSizeMb: 100, fileWritePdfEnabled: true, fileWritePdfMaxFileSizeMb: 100, fileWriteCbxEnabled: false, @@ -240,6 +242,8 @@ function makeLibrary(overrides: Partial = {}): Library { fileWriteWriteCover: true, fileWriteEpubEnabled: true, fileWriteEpubMaxFileSizeMb: 100, + fileWriteFb2Enabled: false, + fileWriteFb2MaxFileSizeMb: 100, fileWritePdfEnabled: true, fileWritePdfMaxFileSizeMb: 100, fileWriteCbxEnabled: false, diff --git a/client/src/features/library/composables/useLibraryCreator.ts b/client/src/features/library/composables/useLibraryCreator.ts index 09f2f127a..dfc3f849e 100644 --- a/client/src/features/library/composables/useLibraryCreator.ts +++ b/client/src/features/library/composables/useLibraryCreator.ts @@ -38,6 +38,8 @@ function blankForm() { fileWriteWriteCover: true, fileWriteEpubEnabled: true, fileWriteEpubMaxFileSizeMb: 100, + fileWriteFb2Enabled: false, + fileWriteFb2MaxFileSizeMb: 100, fileWritePdfEnabled: true, fileWritePdfMaxFileSizeMb: 100, fileWriteCbxEnabled: false, @@ -117,6 +119,8 @@ export function useLibraryCreator() { form.fileWriteWriteCover = library.fileWriteWriteCover form.fileWriteEpubEnabled = library.fileWriteEpubEnabled form.fileWriteEpubMaxFileSizeMb = library.fileWriteEpubMaxFileSizeMb + form.fileWriteFb2Enabled = library.fileWriteFb2Enabled + form.fileWriteFb2MaxFileSizeMb = library.fileWriteFb2MaxFileSizeMb form.fileWritePdfEnabled = library.fileWritePdfEnabled form.fileWritePdfMaxFileSizeMb = library.fileWritePdfMaxFileSizeMb form.fileWriteCbxEnabled = library.fileWriteCbxEnabled diff --git a/client/src/features/settings/__tests__/LibrariesSettings.spec.ts b/client/src/features/settings/__tests__/LibrariesSettings.spec.ts index 51eaccbc0..aa675cf88 100644 --- a/client/src/features/settings/__tests__/LibrariesSettings.spec.ts +++ b/client/src/features/settings/__tests__/LibrariesSettings.spec.ts @@ -83,6 +83,8 @@ function makeLibrary(overrides: Partial = {}): Library { fileWriteWriteCover: false, fileWriteEpubEnabled: false, fileWriteEpubMaxFileSizeMb: 100, + fileWriteFb2Enabled: false, + fileWriteFb2MaxFileSizeMb: 100, fileWritePdfEnabled: false, fileWritePdfMaxFileSizeMb: 100, fileWriteCbxEnabled: false, diff --git a/client/src/features/settings/metadata-auto-fetch/components/LibraryAutoFetchConfig.spec.ts b/client/src/features/settings/metadata-auto-fetch/components/LibraryAutoFetchConfig.spec.ts index da382d20b..6d66c8db1 100644 --- a/client/src/features/settings/metadata-auto-fetch/components/LibraryAutoFetchConfig.spec.ts +++ b/client/src/features/settings/metadata-auto-fetch/components/LibraryAutoFetchConfig.spec.ts @@ -101,6 +101,8 @@ function makeLibrary(): Library { fileWriteWriteCover: false, fileWriteEpubEnabled: false, fileWriteEpubMaxFileSizeMb: 100, + fileWriteFb2Enabled: false, + fileWriteFb2MaxFileSizeMb: 100, fileWritePdfEnabled: false, fileWritePdfMaxFileSizeMb: 100, fileWriteCbxEnabled: false, diff --git a/client/src/locales/de.json b/client/src/locales/de.json index 77a1dcb88..e7e221003 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -5094,6 +5094,11 @@ "hint": "Schreibt Metadaten in die OPF-Datei innerhalb des EPUB-Archivs.", "toggleAria": "EPUB-Metadaten schreiben" }, + "fb2": { + "title": "FictionBook (FB2)", + "hint": "Rewrites the description block inside FB2 files, leaving the book text untouched.", + "toggleAria": "Write FB2 metadata" + }, "pdf": { "title": "PDF", "hint": "Bettet Metadaten in das PDF-Info-Dictionary und den XMP-Stream ein.", diff --git a/client/src/locales/en.json b/client/src/locales/en.json index c47db8344..5c16f6550 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -5052,6 +5052,11 @@ "hint": "Writes metadata into the OPF file inside the EPUB archive.", "toggleAria": "Write EPUB metadata" }, + "fb2": { + "title": "FictionBook (FB2)", + "hint": "Rewrites the description block inside FB2 files, leaving the book text untouched.", + "toggleAria": "Write FB2 metadata" + }, "pdf": { "title": "PDF", "hint": "Embeds metadata into PDF Info dictionary and XMP stream.", diff --git a/client/src/locales/nl.json b/client/src/locales/nl.json index ad646e96a..6fadd7b05 100644 --- a/client/src/locales/nl.json +++ b/client/src/locales/nl.json @@ -5094,6 +5094,11 @@ "hint": "Schrijft metadata naar het OPF-bestand in het EPUB-archief.", "toggleAria": "EPUB-metadata schrijven" }, + "fb2": { + "title": "FictionBook (FB2)", + "hint": "Rewrites the description block inside FB2 files, leaving the book text untouched.", + "toggleAria": "Write FB2 metadata" + }, "pdf": { "title": "PDF", "hint": "Sluit metadata in de PDF Info-dictionary en XMP-stream in.", diff --git a/client/src/locales/pt.json b/client/src/locales/pt.json index 9e9d9ba7a..428d8176e 100644 --- a/client/src/locales/pt.json +++ b/client/src/locales/pt.json @@ -5094,6 +5094,11 @@ "hint": "Grava os metadados no arquivo OPF dentro do arquivo EPUB.", "toggleAria": "Gravar metadados no EPUB" }, + "fb2": { + "title": "FictionBook (FB2)", + "hint": "Rewrites the description block inside FB2 files, leaving the book text untouched.", + "toggleAria": "Write FB2 metadata" + }, "pdf": { "title": "PDF", "hint": "Embutir metadados no dicionário Info do PDF e no fluxo XMP.", diff --git a/client/src/locales/sl.json b/client/src/locales/sl.json index 0d0ec6ae6..833887748 100644 --- a/client/src/locales/sl.json +++ b/client/src/locales/sl.json @@ -5094,6 +5094,11 @@ "hint": "Zapiše metapodatke v datoteko OPF znotraj arhiva EPUB.", "toggleAria": "Zapiši metapodatke EPUB" }, + "fb2": { + "title": "FictionBook (FB2)", + "hint": "Rewrites the description block inside FB2 files, leaving the book text untouched.", + "toggleAria": "Write FB2 metadata" + }, "pdf": { "title": "PDF", "hint": "Vdela metapodatke v slovar Info datoteke PDF in tok XMP.", diff --git a/packages/types/src/file-write.ts b/packages/types/src/file-write.ts index cadce32d1..1cb27f130 100644 --- a/packages/types/src/file-write.ts +++ b/packages/types/src/file-write.ts @@ -109,6 +109,15 @@ export const EPUB_BOOK_FILE_WRITE_FIELDS = [ "coverBytes", ] as const satisfies readonly BookFileWriteField[]; +// FB2 keeps managed metadata in : standard slots cover the core +// fields, and carries the rest. +export const FB2_BOOK_FILE_WRITE_FIELDS = [ + ...CORE_BOOK_FILE_WRITE_FIELDS, + ...COMMON_PROVIDER_BOOK_FILE_WRITE_FIELDS, + "itunesId", + "coverBytes", +] as const satisfies readonly BookFileWriteField[]; + export const PDF_BOOK_FILE_WRITE_FIELDS = [ ...CORE_BOOK_FILE_WRITE_FIELDS, ...COMMON_PROVIDER_BOOK_FILE_WRITE_FIELDS, @@ -158,6 +167,7 @@ export const AUDIO_BOOK_FILE_WRITE_FIELDS = [ export const BOOK_FILE_WRITE_FORMAT_FIELDS = { epub: EPUB_BOOK_FILE_WRITE_FIELDS, + fb2: FB2_BOOK_FILE_WRITE_FIELDS, pdf: PDF_BOOK_FILE_WRITE_FIELDS, cbz: CBX_BOOK_FILE_WRITE_FIELDS, cb7: CBX_BOOK_FILE_WRITE_FIELDS, diff --git a/packages/types/src/library.ts b/packages/types/src/library.ts index 009de7c22..f04ae3b21 100644 --- a/packages/types/src/library.ts +++ b/packages/types/src/library.ts @@ -70,6 +70,8 @@ export interface Library { fileWriteWriteCover: boolean; fileWriteEpubEnabled: boolean; fileWriteEpubMaxFileSizeMb: number; + fileWriteFb2Enabled: boolean; + fileWriteFb2MaxFileSizeMb: number; fileWritePdfEnabled: boolean; fileWritePdfMaxFileSizeMb: number; fileWriteCbxEnabled: boolean; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d059713a6..afde18860 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -427,6 +427,9 @@ importers: fastify: specifier: ^5.10.0 version: 5.10.0 + iconv-lite: + specifier: ^0.7.3 + version: 0.7.3 jose: specifier: ^6.2.4 version: 6.2.4 diff --git a/server/package.json b/server/package.json index fbf5626c6..9bc2aefb4 100644 --- a/server/package.json +++ b/server/package.json @@ -81,6 +81,7 @@ "fast-xml-parser": "^5.10.1", "fastest-levenshtein": "^1.0.16", "fastify": "^5.10.0", + "iconv-lite": "^0.7.3", "jose": "^6.2.4", "ms": "^2.1.3", "mysql2": "^3.23.1", diff --git a/server/src/common/utils/fb2-encoding.utils.test.ts b/server/src/common/utils/fb2-encoding.utils.test.ts new file mode 100644 index 000000000..777a5263a --- /dev/null +++ b/server/src/common/utils/fb2-encoding.utils.test.ts @@ -0,0 +1,111 @@ +import iconv from 'iconv-lite'; + +import { decodeFb2, decodeFb2Document, detectFb2Encoding, encodeFb2, isFb2EncodingSupported } from './fb2-encoding.utils'; + +function declaration(encoding: string | null): string { + const attr = encoding ? ` encoding="${encoding}"` : ''; + return `\n`; +} + +describe('detectFb2Encoding', () => { + it('defaults to utf-8 when the declaration omits an encoding', () => { + const encoding = detectFb2Encoding(Buffer.from(declaration(null), 'utf8')); + expect(encoding).toMatchObject({ name: 'utf-8', declared: null, byteAligned: true, bomLength: 0 }); + }); + + it('normalizes the declared label so casing and spacing do not matter', () => { + expect(detectFb2Encoding(Buffer.from(declaration('UTF-8'), 'utf8')).name).toBe('utf-8'); + expect(detectFb2Encoding(Buffer.from(declaration('Windows-1251'), 'latin1')).name).toBe('windows-1251'); + expect(detectFb2Encoding(Buffer.from(declaration('KOI8_R'), 'latin1')).name).toBe('koi8-r'); + }); + + it('keeps the raw declared label alongside the normalized name', () => { + expect(detectFb2Encoding(Buffer.from(declaration('Windows-1251'), 'latin1')).declared).toBe('Windows-1251'); + }); + + it('reports a utf-8 BOM length so byte offsets stay correct', () => { + const withBom = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(declaration('utf-8'), 'utf8')]); + expect(detectFb2Encoding(withBom)).toMatchObject({ name: 'utf-8', bomLength: 3, byteAligned: true }); + }); + + it('detects utf-16 from a BOM and marks it as not byte aligned', () => { + const le = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(declaration(null), 'utf16le')]); + const be = Buffer.concat([Buffer.from([0xfe, 0xff]), Buffer.from('x')]); + expect(detectFb2Encoding(le)).toMatchObject({ name: 'utf-16le', byteAligned: false }); + expect(detectFb2Encoding(be)).toMatchObject({ name: 'utf-16be', byteAligned: false }); + }); + + it('marks a declared utf-16 as not byte aligned even without a BOM', () => { + expect(detectFb2Encoding(Buffer.from(declaration('utf-16'), 'latin1')).byteAligned).toBe(false); + expect(detectFb2Encoding(Buffer.from(declaration('UTF-32LE'), 'latin1')).byteAligned).toBe(false); + expect(detectFb2Encoding(Buffer.from(declaration('ucs-2'), 'latin1')).byteAligned).toBe(false); + }); + + it('ignores an encoding attribute that appears after the declaration', () => { + const xml = `\n`; + expect(detectFb2Encoding(Buffer.from(xml, 'utf8')).declared).toBeNull(); + }); +}); + +describe('isFb2EncodingSupported', () => { + it('accepts single-byte encodings iconv-lite knows', () => { + expect(isFb2EncodingSupported(detectFb2Encoding(Buffer.from(declaration('utf-8'), 'utf8')))).toBe(true); + expect(isFb2EncodingSupported(detectFb2Encoding(Buffer.from(declaration('windows-1251'), 'latin1')))).toBe(true); + expect(isFb2EncodingSupported(detectFb2Encoding(Buffer.from(declaration('koi8-r'), 'latin1')))).toBe(true); + expect(isFb2EncodingSupported(detectFb2Encoding(Buffer.from(declaration('iso-8859-5'), 'latin1')))).toBe(true); + }); + + it('rejects utf-16 because ASCII markup is not byte aligned', () => { + expect(isFb2EncodingSupported(detectFb2Encoding(Buffer.from(declaration('utf-16'), 'latin1')))).toBe(false); + }); + + it('rejects encodings iconv-lite cannot handle', () => { + expect(isFb2EncodingSupported(detectFb2Encoding(Buffer.from(declaration('made-up-codec-9000'), 'latin1')))).toBe(false); + }); +}); + +describe('decodeFb2 / encodeFb2', () => { + it('round-trips cyrillic through windows-1251 without loss', () => { + const encoding = detectFb2Encoding(Buffer.from(declaration('windows-1251'), 'latin1')); + const text = 'Мастер и Маргарита'; + const bytes = encodeFb2(text, encoding); + expect(bytes.length).toBe(text.length); + expect(decodeFb2(bytes, encoding)).toBe(text); + }); + + it('round-trips cyrillic through koi8-r without loss', () => { + const encoding = detectFb2Encoding(Buffer.from(declaration('koi8-r'), 'latin1')); + const text = 'Булгаков'; + expect(decodeFb2(encodeFb2(text, encoding), encoding)).toBe(text); + }); + + it('decodes windows-1251 bytes that utf-8 would mangle', () => { + const encoding = detectFb2Encoding(Buffer.from(declaration('windows-1251'), 'latin1')); + const bytes = iconv.encode('Привет', 'win1251'); + expect(bytes.toString('utf8')).not.toBe('Привет'); + expect(decodeFb2(bytes, encoding)).toBe('Привет'); + }); +}); + +describe('decodeFb2Document', () => { + it('decodes a windows-1251 document using its declared encoding', () => { + const xml = `Мастер`; + const { text, encoding } = decodeFb2Document(iconv.encode(xml, 'win1251')); + expect(encoding.name).toBe('windows-1251'); + expect(text).toContain('Мастер'); + }); + + it('decodes a plain utf-8 document', () => { + const xml = `Über`; + const { text, encoding } = decodeFb2Document(Buffer.from(xml, 'utf8')); + expect(encoding.name).toBe('utf-8'); + expect(text).toContain('Über'); + }); + + it('falls back to utf-8 when the declared encoding is unknown', () => { + const xml = `Plain`; + const { text, encoding } = decodeFb2Document(Buffer.from(xml, 'utf8')); + expect(encoding.name).toBe('utf-8'); + expect(text).toContain('Plain'); + }); +}); diff --git a/server/src/common/utils/fb2-encoding.utils.ts b/server/src/common/utils/fb2-encoding.utils.ts new file mode 100644 index 000000000..4219c31d9 --- /dev/null +++ b/server/src/common/utils/fb2-encoding.utils.ts @@ -0,0 +1,86 @@ +import iconv from 'iconv-lite'; + +/** + * FB2 files are plain XML whose bytes may be in any encoding the declaration + * names. Single-byte encodings keep ASCII markup at ASCII byte values, so tag + * offsets can be found without decoding the whole document. UTF-16/UTF-32 do + * not, which is why they are rejected for byte-level editing. + */ +export type Fb2Encoding = { + /** Encoding label accepted by iconv-lite. */ + name: string; + /** Label as it appeared in the XML declaration, when present. */ + declared: string | null; + /** True when ASCII markup is byte-aligned, so byte offsets can be trusted. */ + byteAligned: boolean; + bomLength: number; +}; + +const XML_DECL_ENCODING_RE = /<\?xml[^>]*?encoding\s*=\s*["']([^"']+)["'][^>]*\?>/i; +const DECLARATION_SCAN_BYTES = 512; + +const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]); +const UTF16LE_BOM = Buffer.from([0xff, 0xfe]); +const UTF16BE_BOM = Buffer.from([0xfe, 0xff]); + +function normalizeLabel(label: string): string { + return label + .trim() + .toLowerCase() + .replace(/[\s_]+/g, '-'); +} + +function isUtf16Or32(label: string): boolean { + return /^(utf-?16|utf-?32|ucs-?2|ucs-?4|unicodefffe|unicode)/.test(label.replace(/-/g, '')); +} + +/** + * Inspects the leading bytes of an FB2 file and resolves the encoding to use + * for its metadata block. Never decodes the whole buffer. + */ +export function detectFb2Encoding(head: Buffer): Fb2Encoding { + if (head.subarray(0, 2).equals(UTF16LE_BOM)) { + return { name: 'utf-16le', declared: null, byteAligned: false, bomLength: 2 }; + } + if (head.subarray(0, 2).equals(UTF16BE_BOM)) { + return { name: 'utf-16be', declared: null, byteAligned: false, bomLength: 2 }; + } + + const bomLength = head.subarray(0, 3).equals(UTF8_BOM) ? 3 : 0; + const declaration = head.subarray(bomLength, bomLength + DECLARATION_SCAN_BYTES).toString('latin1'); + const declared = declaration.match(XML_DECL_ENCODING_RE)?.[1] ?? null; + if (!declared) { + return { name: 'utf-8', declared: null, byteAligned: true, bomLength }; + } + + const name = normalizeLabel(declared); + if (isUtf16Or32(name)) { + return { name, declared, byteAligned: false, bomLength }; + } + + return { name, declared, byteAligned: true, bomLength }; +} + +export function isFb2EncodingSupported(encoding: Fb2Encoding): boolean { + return encoding.byteAligned && iconv.encodingExists(encoding.name); +} + +export function decodeFb2(bytes: Buffer, encoding: Fb2Encoding): string { + return iconv.decode(bytes, encoding.name); +} + +export function encodeFb2(text: string, encoding: Fb2Encoding): Buffer { + return iconv.encode(text, encoding.name); +} + +/** + * Decodes a whole FB2 buffer for reading. Falls back to UTF-8 when the declared + * encoding is unknown to iconv-lite so extraction still yields ASCII metadata. + */ +export function decodeFb2Document(buffer: Buffer): { text: string; encoding: Fb2Encoding } { + const encoding = detectFb2Encoding(buffer.subarray(0, DECLARATION_SCAN_BYTES + 3)); + if (!iconv.encodingExists(encoding.name)) { + return { text: buffer.toString('utf8'), encoding: { ...encoding, name: 'utf-8' } }; + } + return { text: decodeFb2(buffer, encoding), encoding }; +} diff --git a/server/src/db/migrations/0058_add_fb2_file_write_settings.sql b/server/src/db/migrations/0058_add_fb2_file_write_settings.sql new file mode 100644 index 000000000..a90e6d88b --- /dev/null +++ b/server/src/db/migrations/0058_add_fb2_file_write_settings.sql @@ -0,0 +1,2 @@ +ALTER TABLE "libraries" ADD COLUMN "file_write_fb2_enabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "libraries" ADD COLUMN "file_write_fb2_max_file_size_mb" integer DEFAULT 100 NOT NULL; \ No newline at end of file diff --git a/server/src/db/migrations/meta/0058_snapshot.json b/server/src/db/migrations/meta/0058_snapshot.json new file mode 100644 index 000000000..1a34b666c --- /dev/null +++ b/server/src/db/migrations/meta/0058_snapshot.json @@ -0,0 +1,15579 @@ +{ + "id": "5a43f922-b650-4e21-83b1-19a63025524f", + "prevId": "10b44bb3-b07b-48fe-8137-d3dd135bb563", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "actor_username": { + "name": "actor_username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_audit_user_id": { + "name": "idx_audit_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_actor_username": { + "name": "idx_audit_actor_username", + "columns": [ + { + "expression": "actor_username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_actor_username_trgm": { + "name": "idx_audit_actor_username_trgm", + "columns": [ + { + "expression": "actor_username", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_audit_resource": { + "name": "idx_audit_resource", + "columns": [ + { + "expression": "resource", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_resource_id": { + "name": "idx_audit_resource_id", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_resource_trgm": { + "name": "idx_audit_resource_trgm", + "columns": [ + { + "expression": "resource", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_audit_action": { + "name": "idx_audit_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_action_trgm": { + "name": "idx_audit_action_trgm", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_audit_description_trgm": { + "name": "idx_audit_description_trgm", + "columns": [ + { + "expression": "description", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_audit_meta_text_trgm": { + "name": "idx_audit_meta_text_trgm", + "columns": [ + { + "expression": "(\"meta\"::text) gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_audit_ip": { + "name": "idx_audit_ip", + "columns": [ + { + "expression": "ip", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_created_at": { + "name": "idx_audit_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_reading_insights_access": { + "name": "idx_audit_reading_insights_access", + "columns": [ + { + "expression": "resource", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_user_id_users_id_fk": { + "name": "audit_log_user_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_settings": { + "name": "app_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_settings_key_unique": { + "name": "app_settings_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.magic_access_tokens": { + "name": "magic_access_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "raw_token": { + "name": "raw_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "magic_access_tokens_user_id_idx": { + "name": "magic_access_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "magic_access_tokens_user_id_users_id_fk": { + "name": "magic_access_tokens_user_id_users_id_fk", + "tableFrom": "magic_access_tokens", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "magic_access_tokens_created_by_users_id_fk": { + "name": "magic_access_tokens_created_by_users_id_fk", + "tableFrom": "magic_access_tokens", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "magic_access_tokens_token_hash_unique": { + "name": "magic_access_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": ["token_hash"] + } + }, + "policies": {}, + "checkConstraints": { + "magic_access_tokens_use_count_nonneg_chk": { + "name": "magic_access_tokens_use_count_nonneg_chk", + "value": "\"magic_access_tokens\".\"use_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.password_reset_tokens": { + "name": "password_reset_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "password_reset_tokens_user_id_idx": { + "name": "password_reset_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "password_reset_tokens_expires_at_idx": { + "name": "password_reset_tokens_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "password_reset_tokens_user_id_users_id_fk": { + "name": "password_reset_tokens_user_id_users_id_fk", + "tableFrom": "password_reset_tokens", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_tokens_token_hash_unique": { + "name": "password_reset_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": ["token_hash"] + } + }, + "policies": {}, + "checkConstraints": { + "password_reset_tokens_expires_after_created_chk": { + "name": "password_reset_tokens_expires_after_created_chk", + "value": "\"password_reset_tokens\".\"expires_at\" > \"password_reset_tokens\".\"created_at\"" + } + }, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "replaced_by_token_hash": { + "name": "replaced_by_token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_user_id_idx": { + "name": "refresh_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_expires_at_idx": { + "name": "refresh_tokens_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "refresh_tokens_token_hash_unique": { + "name": "refresh_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": ["token_hash"] + } + }, + "policies": {}, + "checkConstraints": { + "refresh_tokens_expires_after_created_chk": { + "name": "refresh_tokens_expires_after_created_chk", + "value": "\"refresh_tokens\".\"expires_at\" > \"refresh_tokens\".\"created_at\"" + } + }, + "isRLSEnabled": false + }, + "public.user_content_filter_genres": { + "name": "user_content_filter_genres", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "filter_type": { + "name": "filter_type", + "type": "content_filter_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "genre_id": { + "name": "genre_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_content_filter_genres_genre_id_idx": { + "name": "user_content_filter_genres_genre_id_idx", + "columns": [ + { + "expression": "genre_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_content_filter_genres_user_id_users_id_fk": { + "name": "user_content_filter_genres_user_id_users_id_fk", + "tableFrom": "user_content_filter_genres", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_content_filter_genres_genre_id_genres_id_fk": { + "name": "user_content_filter_genres_genre_id_genres_id_fk", + "tableFrom": "user_content_filter_genres", + "tableTo": "genres", + "columnsFrom": ["genre_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_content_filter_genres_user_id_filter_type_genre_id_pk": { + "name": "user_content_filter_genres_user_id_filter_type_genre_id_pk", + "columns": ["user_id", "filter_type", "genre_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_content_filter_tags": { + "name": "user_content_filter_tags", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "filter_type": { + "name": "filter_type", + "type": "content_filter_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_content_filter_tags_tag_id_idx": { + "name": "user_content_filter_tags_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_content_filter_tags_user_id_users_id_fk": { + "name": "user_content_filter_tags_user_id_users_id_fk", + "tableFrom": "user_content_filter_tags", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_content_filter_tags_tag_id_tags_id_fk": { + "name": "user_content_filter_tags_tag_id_tags_id_fk", + "tableFrom": "user_content_filter_tags", + "tableTo": "tags", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_content_filter_tags_user_id_filter_type_tag_id_pk": { + "name": "user_content_filter_tags_user_id_filter_type_tag_id_pk", + "columns": ["user_id", "filter_type", "tag_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_library_access": { + "name": "user_library_access", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "library_id": { + "name": "library_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "access_level": { + "name": "access_level", + "type": "library_access_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + } + }, + "indexes": { + "user_library_access_library_user_idx": { + "name": "user_library_access_library_user_idx", + "columns": [ + { + "expression": "library_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_library_access_user_id_users_id_fk": { + "name": "user_library_access_user_id_users_id_fk", + "tableFrom": "user_library_access", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_library_access_library_id_libraries_id_fk": { + "name": "user_library_access_library_id_libraries_id_fk", + "tableFrom": "user_library_access", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_library_access_user_id_library_id_pk": { + "name": "user_library_access_user_id_library_id_pk", + "columns": ["user_id", "library_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_permissions": { + "name": "user_permissions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "permission_name": { + "name": "permission_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_permissions_user_id_users_id_fk": { + "name": "user_permissions_user_id_users_id_fk", + "tableFrom": "user_permissions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_permissions_user_id_permission_name_pk": { + "name": "user_permissions_user_id_permission_name_pk", + "columns": ["user_id", "permission_name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_superuser": { + "name": "is_superuser", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default_password": { + "name": "is_default_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "token_version": { + "name": "token_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "failed_login_attempts": { + "name": "failed_login_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "locked_until": { + "name": "locked_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "oidc_subject": { + "name": "oidc_subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oidc_issuer": { + "name": "oidc_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_source": { + "name": "avatar_source", + "type": "user_avatar_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "avatar_version": { + "name": "avatar_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provisioning_method": { + "name": "provisioning_method", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'local'" + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_authenticated_at": { + "name": "last_authenticated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reading_insights_sharing_level": { + "name": "reading_insights_sharing_level", + "type": "reading_insights_sharing_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "reading_insights_consented_at": { + "name": "reading_insights_consented_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_username_lower_uidx": { + "name": "users_username_lower_uidx", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_lower_uidx": { + "name": "users_email_lower_uidx", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"users\".\"email\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_oidc_subject_issuer_uidx": { + "name": "users_oidc_subject_issuer_uidx", + "columns": [ + { + "expression": "oidc_subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "oidc_issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"users\".\"oidc_subject\" is not null and \"users\".\"oidc_issuer\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_last_authenticated_at_idx": { + "name": "users_last_authenticated_at_idx", + "columns": [ + { + "expression": "last_authenticated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": { + "users_provisioning_method_chk": { + "name": "users_provisioning_method_chk", + "value": "\"users\".\"provisioning_method\" in ('local', 'manual', 'oidc', 'shared')" + }, + "users_token_version_nonnegative_chk": { + "name": "users_token_version_nonnegative_chk", + "value": "\"users\".\"token_version\" >= 0" + }, + "users_failed_login_attempts_nonnegative_chk": { + "name": "users_failed_login_attempts_nonnegative_chk", + "value": "\"users\".\"failed_login_attempts\" >= 0" + }, + "users_avatar_version_nonnegative_chk": { + "name": "users_avatar_version_nonnegative_chk", + "value": "\"users\".\"avatar_version\" >= 0" + }, + "users_reading_insights_consent_chk": { + "name": "users_reading_insights_consent_chk", + "value": "(\"users\".\"reading_insights_sharing_level\" = 'private' and \"users\".\"reading_insights_consented_at\" is null) or (\"users\".\"reading_insights_sharing_level\" <> 'private' and \"users\".\"reading_insights_consented_at\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.comic_metadata": { + "name": "comic_metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issue_number": { + "name": "issue_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "volume_name": { + "name": "volume_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pencillers": { + "name": "pencillers", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "inkers": { + "name": "inkers", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "colorists": { + "name": "colorists", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "letterers": { + "name": "letterers", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "cover_artists": { + "name": "cover_artists", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "characters": { + "name": "characters", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "teams": { + "name": "teams", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "locations": { + "name": "locations", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "story_arcs": { + "name": "story_arcs", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "comic_metadata_book_id_books_id_fk": { + "name": "comic_metadata_book_id_books_id_fk", + "tableFrom": "comic_metadata", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "comic_metadata_book_id_unique": { + "name": "comic_metadata_book_id_unique", + "nullsNotDistinct": false, + "columns": ["book_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_custom_metadata_values": { + "name": "book_custom_metadata_values", + "schema": "", + "columns": { + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "field_id": { + "name": "field_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value_text": { + "name": "value_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "value_number": { + "name": "value_number", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "value_date": { + "name": "value_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "value_boolean": { + "name": "value_boolean", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "book_custom_metadata_values_field_idx": { + "name": "book_custom_metadata_values_field_idx", + "columns": [ + { + "expression": "field_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_custom_metadata_values_book_id_books_id_fk": { + "name": "book_custom_metadata_values_book_id_books_id_fk", + "tableFrom": "book_custom_metadata_values", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_custom_metadata_values_field_id_custom_metadata_fields_id_fk": { + "name": "book_custom_metadata_values_field_id_custom_metadata_fields_id_fk", + "tableFrom": "book_custom_metadata_values", + "tableTo": "custom_metadata_fields", + "columnsFrom": ["field_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "book_custom_metadata_values_book_id_field_id_pk": { + "name": "book_custom_metadata_values_book_id_field_id_pk", + "columns": ["book_id", "field_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "book_custom_metadata_values_single_value_chk": { + "name": "book_custom_metadata_values_single_value_chk", + "value": "(\n (\"book_custom_metadata_values\".\"value_text\" is not null)::int +\n (\"book_custom_metadata_values\".\"value_number\" is not null)::int +\n (\"book_custom_metadata_values\".\"value_date\" is not null)::int +\n (\"book_custom_metadata_values\".\"value_boolean\" is not null)::int\n ) <= 1" + } + }, + "isRLSEnabled": false + }, + "public.custom_metadata_fields": { + "name": "custom_metadata_fields", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_metadata_fields_key_uidx": { + "name": "custom_metadata_fields_key_uidx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_metadata_fields_active_order_idx": { + "name": "custom_metadata_fields_active_order_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "label", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "custom_metadata_fields_type_chk": { + "name": "custom_metadata_fields_type_chk", + "value": "\"custom_metadata_fields\".\"type\" in ('text', 'url', 'number', 'date', 'boolean')" + }, + "custom_metadata_fields_key_format_chk": { + "name": "custom_metadata_fields_key_format_chk", + "value": "\"custom_metadata_fields\".\"key\" ~ '^[a-z0-9][a-z0-9_]{0,99}$'" + }, + "custom_metadata_fields_label_not_blank_chk": { + "name": "custom_metadata_fields_label_not_blank_chk", + "value": "length(btrim(\"custom_metadata_fields\".\"label\")) > 0" + }, + "custom_metadata_fields_display_order_nonnegative_chk": { + "name": "custom_metadata_fields_display_order_nonnegative_chk", + "value": "\"custom_metadata_fields\".\"display_order\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.custom_metadata_library_fields": { + "name": "custom_metadata_library_fields", + "schema": "", + "columns": { + "field_id": { + "name": "field_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "library_id": { + "name": "library_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_metadata_library_fields_field_idx": { + "name": "custom_metadata_library_fields_field_idx", + "columns": [ + { + "expression": "field_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_metadata_library_fields_library_order_idx": { + "name": "custom_metadata_library_fields_library_order_idx", + "columns": [ + { + "expression": "library_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "field_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_metadata_library_fields_field_id_custom_metadata_fields_id_fk": { + "name": "custom_metadata_library_fields_field_id_custom_metadata_fields_id_fk", + "tableFrom": "custom_metadata_library_fields", + "tableTo": "custom_metadata_fields", + "columnsFrom": ["field_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_metadata_library_fields_library_id_libraries_id_fk": { + "name": "custom_metadata_library_fields_library_id_libraries_id_fk", + "tableFrom": "custom_metadata_library_fields", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "custom_metadata_library_fields_library_id_field_id_pk": { + "name": "custom_metadata_library_fields_library_id_field_id_pk", + "columns": ["library_id", "field_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "custom_metadata_library_fields_display_order_nonnegative_chk": { + "name": "custom_metadata_library_fields_display_order_nonnegative_chk", + "value": "\"custom_metadata_library_fields\".\"display_order\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.custom_icons": { + "name": "custom_icons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "original_file_name": { + "name": "original_file_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "stored_file_name": { + "name": "stored_file_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "file_hash": { + "name": "file_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_icons_slug_uidx": { + "name": "custom_icons_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "custom_icons_slug_chk": { + "name": "custom_icons_slug_chk", + "value": "\"custom_icons\".\"slug\" ~ '^[a-z0-9][a-z0-9-]*[a-z0-9]$' or \"custom_icons\".\"slug\" ~ '^[a-z0-9]$'" + }, + "custom_icons_file_size_positive_chk": { + "name": "custom_icons_file_size_positive_chk", + "value": "\"custom_icons\".\"file_size\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.migration_plan_artifacts": { + "name": "migration_plan_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "profile_id": { + "name": "profile_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source_snapshot_hash": { + "name": "source_snapshot_hash", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "profile_hash": { + "name": "profile_hash", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "plan_hash": { + "name": "plan_hash", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source_data": { + "name": "source_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "migration_plan_artifacts_plan_hash_uidx": { + "name": "migration_plan_artifacts_plan_hash_uidx", + "columns": [ + { + "expression": "plan_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_plan_artifacts_source_id_idx": { + "name": "migration_plan_artifacts_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_plan_artifacts_profile_id_idx": { + "name": "migration_plan_artifacts_profile_id_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_plan_artifacts_source_id_migration_sources_id_fk": { + "name": "migration_plan_artifacts_source_id_migration_sources_id_fk", + "tableFrom": "migration_plan_artifacts", + "tableTo": "migration_sources", + "columnsFrom": ["source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_plan_artifacts_profile_id_migration_profiles_id_fk": { + "name": "migration_plan_artifacts_profile_id_migration_profiles_id_fk", + "tableFrom": "migration_plan_artifacts", + "tableTo": "migration_profiles", + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_plan_artifacts_created_by_user_id_users_id_fk": { + "name": "migration_plan_artifacts_created_by_user_id_users_id_fk", + "tableFrom": "migration_plan_artifacts", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "migration_plan_artifacts_profile_source_fk": { + "name": "migration_plan_artifacts_profile_source_fk", + "tableFrom": "migration_plan_artifacts", + "tableTo": "migration_profiles", + "columnsFrom": ["profile_id", "source_id"], + "columnsTo": ["id", "source_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "migration_plan_artifacts_id_source_profile_unique": { + "name": "migration_plan_artifacts_id_source_profile_unique", + "nullsNotDistinct": false, + "columns": ["id", "source_id", "profile_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_profiles": { + "name": "migration_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "user_mappings": { + "name": "user_mappings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "path_mappings": { + "name": "path_mappings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "migration_profiles_source_name_version_uidx": { + "name": "migration_profiles_source_name_version_uidx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_profiles_source_id_idx": { + "name": "migration_profiles_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_profiles_source_id_migration_sources_id_fk": { + "name": "migration_profiles_source_id_migration_sources_id_fk", + "tableFrom": "migration_profiles", + "tableTo": "migration_sources", + "columnsFrom": ["source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_profiles_created_by_user_id_users_id_fk": { + "name": "migration_profiles_created_by_user_id_users_id_fk", + "tableFrom": "migration_profiles", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "migration_profiles_id_source_id_unique": { + "name": "migration_profiles_id_source_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "source_id"] + } + }, + "policies": {}, + "checkConstraints": { + "migration_profiles_version_positive_chk": { + "name": "migration_profiles_version_positive_chk", + "value": "\"migration_profiles\".\"version\" >= 1" + } + }, + "isRLSEnabled": false + }, + "public.migration_run_metrics": { + "name": "migration_run_metrics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "stage": { + "name": "stage", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "processed": { + "name": "processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "imported": { + "name": "imported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped": { + "name": "skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unresolved": { + "name": "unresolved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed": { + "name": "failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "migration_run_metrics_run_stage_entity_uidx": { + "name": "migration_run_metrics_run_stage_entity_uidx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stage", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_run_metrics_run_id_idx": { + "name": "migration_run_metrics_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_run_metrics_run_id_migration_runs_id_fk": { + "name": "migration_run_metrics_run_id_migration_runs_id_fk", + "tableFrom": "migration_run_metrics", + "tableTo": "migration_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "migration_run_metrics_processed_nonnegative_chk": { + "name": "migration_run_metrics_processed_nonnegative_chk", + "value": "\"migration_run_metrics\".\"processed\" >= 0" + }, + "migration_run_metrics_imported_nonnegative_chk": { + "name": "migration_run_metrics_imported_nonnegative_chk", + "value": "\"migration_run_metrics\".\"imported\" >= 0" + }, + "migration_run_metrics_skipped_nonnegative_chk": { + "name": "migration_run_metrics_skipped_nonnegative_chk", + "value": "\"migration_run_metrics\".\"skipped\" >= 0" + }, + "migration_run_metrics_unresolved_nonnegative_chk": { + "name": "migration_run_metrics_unresolved_nonnegative_chk", + "value": "\"migration_run_metrics\".\"unresolved\" >= 0" + }, + "migration_run_metrics_failed_nonnegative_chk": { + "name": "migration_run_metrics_failed_nonnegative_chk", + "value": "\"migration_run_metrics\".\"failed\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.migration_runs": { + "name": "migration_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "profile_id": { + "name": "profile_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "plan_artifact_id": { + "name": "plan_artifact_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "target_key": { + "name": "target_key", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'bookorbit'" + }, + "state": { + "name": "state", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "current_stage": { + "name": "current_stage", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "migration_runs_source_target_state_idx": { + "name": "migration_runs_source_target_state_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_runs_state_idx": { + "name": "migration_runs_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_runs_source_id_migration_sources_id_fk": { + "name": "migration_runs_source_id_migration_sources_id_fk", + "tableFrom": "migration_runs", + "tableTo": "migration_sources", + "columnsFrom": ["source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_runs_profile_id_migration_profiles_id_fk": { + "name": "migration_runs_profile_id_migration_profiles_id_fk", + "tableFrom": "migration_runs", + "tableTo": "migration_profiles", + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_runs_plan_artifact_id_migration_plan_artifacts_id_fk": { + "name": "migration_runs_plan_artifact_id_migration_plan_artifacts_id_fk", + "tableFrom": "migration_runs", + "tableTo": "migration_plan_artifacts", + "columnsFrom": ["plan_artifact_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "migration_runs_triggered_by_user_id_users_id_fk": { + "name": "migration_runs_triggered_by_user_id_users_id_fk", + "tableFrom": "migration_runs", + "tableTo": "users", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "migration_runs_profile_source_fk": { + "name": "migration_runs_profile_source_fk", + "tableFrom": "migration_runs", + "tableTo": "migration_profiles", + "columnsFrom": ["profile_id", "source_id"], + "columnsTo": ["id", "source_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "migration_runs_plan_artifact_source_profile_fk": { + "name": "migration_runs_plan_artifact_source_profile_fk", + "tableFrom": "migration_runs", + "tableTo": "migration_plan_artifacts", + "columnsFrom": ["plan_artifact_id", "source_id", "profile_id"], + "columnsTo": ["id", "source_id", "profile_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "migration_runs_state_chk": { + "name": "migration_runs_state_chk", + "value": "\"state\" IN ('draft', 'preflight_failed', 'dry_run_ready', 'running', 'failed', 'completed')" + }, + "migration_runs_started_before_ended_chk": { + "name": "migration_runs_started_before_ended_chk", + "value": "\"migration_runs\".\"ended_at\" is null or \"migration_runs\".\"started_at\" is null or \"migration_runs\".\"ended_at\" >= \"migration_runs\".\"started_at\"" + } + }, + "isRLSEnabled": false + }, + "public.migration_sources": { + "name": "migration_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection_config": { + "name": "connection_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_validated_at": { + "name": "last_validated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "migration_sources_type_name_uidx": { + "name": "migration_sources_type_name_uidx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_sources_created_by_user_id_idx": { + "name": "migration_sources_created_by_user_id_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_sources_created_by_user_id_users_id_fk": { + "name": "migration_sources_created_by_user_id_users_id_fk", + "tableFrom": "migration_sources", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.libraries": { + "name": "libraries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cover_aspect_ratio": { + "name": "cover_aspect_ratio", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'2/3'" + }, + "watch": { + "name": "watch", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_scan_cron_expression": { + "name": "auto_scan_cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_precedence": { + "name": "metadata_precedence", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"folderStructure\",\"embedded\",\"nfoFile\",\"opfFile\",\"sidecar\"]'::jsonb" + }, + "format_priority": { + "name": "format_priority", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"epub\",\"pdf\",\"cbz\",\"cbr\",\"cb7\",\"mobi\",\"azw3\",\"azw\",\"fb2\",\"m4b\",\"mp3\",\"m4a\",\"opus\",\"ogg\",\"flac\"]'::jsonb" + }, + "allowed_formats": { + "name": "allowed_formats", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "organization_mode": { + "name": "organization_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'book_per_folder'" + }, + "exclude_patterns": { + "name": "exclude_patterns", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reading_threshold": { + "name": "reading_threshold", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0.25 + }, + "mark_as_finished_percent_complete": { + "name": "mark_as_finished_percent_complete", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 98 + }, + "file_write_enabled": { + "name": "file_write_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "file_write_write_cover": { + "name": "file_write_write_cover", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "file_write_epub_enabled": { + "name": "file_write_epub_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "file_write_epub_max_file_size_mb": { + "name": "file_write_epub_max_file_size_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "file_write_fb2_enabled": { + "name": "file_write_fb2_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "file_write_fb2_max_file_size_mb": { + "name": "file_write_fb2_max_file_size_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "file_write_pdf_enabled": { + "name": "file_write_pdf_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "file_write_pdf_max_file_size_mb": { + "name": "file_write_pdf_max_file_size_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "file_write_cbx_enabled": { + "name": "file_write_cbx_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "file_write_cbx_max_file_size_mb": { + "name": "file_write_cbx_max_file_size_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 500 + }, + "file_write_kindle_enabled": { + "name": "file_write_kindle_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "file_write_kindle_max_file_size_mb": { + "name": "file_write_kindle_max_file_size_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "file_write_audio_enabled": { + "name": "file_write_audio_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "file_write_audio_max_file_size_mb": { + "name": "file_write_audio_max_file_size_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 500 + }, + "file_rename_enabled": { + "name": "file_rename_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "file_naming_pattern": { + "name": "file_naming_pattern", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "metadata_fetch_preferences": { + "name": "metadata_fetch_preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "book_metadata_fetch_config": { + "name": "book_metadata_fetch_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "book_metadata_fetch_last_run_at": { + "name": "book_metadata_fetch_last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "book_metadata_fetch_last_queued_count": { + "name": "book_metadata_fetch_last_queued_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scan_mode": { + "name": "scan_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'auto'" + }, + "poll_interval_seconds": { + "name": "poll_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 300 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "libraries_name_lower_uidx": { + "name": "libraries_name_lower_uidx", + "columns": [ + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "libraries_display_order_nonnegative_chk": { + "name": "libraries_display_order_nonnegative_chk", + "value": "\"libraries\".\"display_order\" >= 0" + }, + "libraries_organization_mode_chk": { + "name": "libraries_organization_mode_chk", + "value": "\"libraries\".\"organization_mode\" in ('book_per_folder', 'book_per_file')" + }, + "libraries_reading_threshold_range_chk": { + "name": "libraries_reading_threshold_range_chk", + "value": "\"libraries\".\"reading_threshold\" >= 0 and \"libraries\".\"reading_threshold\" <= 100" + }, + "libraries_mark_finished_percent_range_chk": { + "name": "libraries_mark_finished_percent_range_chk", + "value": "\"libraries\".\"mark_as_finished_percent_complete\" >= 0 and \"libraries\".\"mark_as_finished_percent_complete\" <= 100" + }, + "libraries_scan_mode_chk": { + "name": "libraries_scan_mode_chk", + "value": "\"libraries\".\"scan_mode\" in ('auto', 'manual')" + }, + "libraries_poll_interval_nonnegative_chk": { + "name": "libraries_poll_interval_nonnegative_chk", + "value": "\"libraries\".\"poll_interval_seconds\" is null or \"libraries\".\"poll_interval_seconds\" >= 0" + }, + "libraries_file_write_epub_max_size_chk": { + "name": "libraries_file_write_epub_max_size_chk", + "value": "\"libraries\".\"file_write_epub_max_file_size_mb\" >= 1" + }, + "libraries_file_write_pdf_max_size_chk": { + "name": "libraries_file_write_pdf_max_size_chk", + "value": "\"libraries\".\"file_write_pdf_max_file_size_mb\" >= 1" + }, + "libraries_file_write_cbx_max_size_chk": { + "name": "libraries_file_write_cbx_max_size_chk", + "value": "\"libraries\".\"file_write_cbx_max_file_size_mb\" >= 1" + }, + "libraries_file_write_kindle_max_size_chk": { + "name": "libraries_file_write_kindle_max_size_chk", + "value": "\"libraries\".\"file_write_kindle_max_file_size_mb\" >= 1" + }, + "libraries_file_write_audio_max_size_chk": { + "name": "libraries_file_write_audio_max_size_chk", + "value": "\"libraries\".\"file_write_audio_max_file_size_mb\" >= 1" + } + }, + "isRLSEnabled": false + }, + "public.library_folders": { + "name": "library_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "library_id": { + "name": "library_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar(4096)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "library_folders_library_id_idx": { + "name": "library_folders_library_id_idx", + "columns": [ + { + "expression": "library_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "library_folders_library_path_uidx": { + "name": "library_folders_library_path_uidx", + "columns": [ + { + "expression": "library_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "library_folders_library_id_libraries_id_fk": { + "name": "library_folders_library_id_libraries_id_fk", + "tableFrom": "library_folders", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "library_folders_id_library_id_unique": { + "name": "library_folders_id_library_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "library_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_files": { + "name": "book_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "library_folder_id": { + "name": "library_folder_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "absolute_path": { + "name": "absolute_path", + "type": "varchar(4096)", + "primaryKey": false, + "notNull": true + }, + "rel_path": { + "name": "rel_path", + "type": "varchar(4096)", + "primaryKey": false, + "notNull": false + }, + "ino": { + "name": "ino", + "type": "numeric(20, 0)", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "mtime": { + "name": "mtime", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "file_hash": { + "name": "file_hash", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'content'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "book_files_absolute_path_uidx": { + "name": "book_files_absolute_path_uidx", + "columns": [ + { + "expression": "absolute_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_files_book_id_idx": { + "name": "book_files_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_files_library_folder_id_idx": { + "name": "book_files_library_folder_id_idx", + "columns": [ + { + "expression": "library_folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_files_file_hash_idx": { + "name": "book_files_file_hash_idx", + "columns": [ + { + "expression": "file_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_files_ino_idx": { + "name": "book_files_ino_idx", + "columns": [ + { + "expression": "ino", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_files_format_idx": { + "name": "book_files_format_idx", + "columns": [ + { + "expression": "format", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_files_library_folder_file_hash_idx": { + "name": "book_files_library_folder_file_hash_idx", + "columns": [ + { + "expression": "library_folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_files_library_folder_ino_idx": { + "name": "book_files_library_folder_ino_idx", + "columns": [ + { + "expression": "library_folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ino", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_files_book_id_books_id_fk": { + "name": "book_files_book_id_books_id_fk", + "tableFrom": "book_files", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_files_library_folder_id_library_folders_id_fk": { + "name": "book_files_library_folder_id_library_folders_id_fk", + "tableFrom": "book_files", + "tableTo": "library_folders", + "columnsFrom": ["library_folder_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_files_book_folder_consistency_fk": { + "name": "book_files_book_folder_consistency_fk", + "tableFrom": "book_files", + "tableTo": "books", + "columnsFrom": ["book_id", "library_folder_id"], + "columnsTo": ["id", "library_folder_id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "book_files_role_chk": { + "name": "book_files_role_chk", + "value": "\"book_files\".\"role\" in ('content', 'cover', 'metadata', 'supplement')" + }, + "book_files_size_bytes_nonnegative_chk": { + "name": "book_files_size_bytes_nonnegative_chk", + "value": "\"book_files\".\"size_bytes\" is null or \"book_files\".\"size_bytes\" >= 0" + }, + "book_files_duration_seconds_nonnegative_chk": { + "name": "book_files_duration_seconds_nonnegative_chk", + "value": "\"book_files\".\"duration_seconds\" is null or \"book_files\".\"duration_seconds\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.books": { + "name": "books", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "library_id": { + "name": "library_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "library_folder_id": { + "name": "library_folder_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "primary_file_id": { + "name": "primary_file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "primary_author_sort_name": { + "name": "primary_author_sort_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "folder_path": { + "name": "folder_path", + "type": "varchar(4096)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'present'" + }, + "added_at": { + "name": "added_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "books_library_id_folder_path_idx": { + "name": "books_library_id_folder_path_idx", + "columns": [ + { + "expression": "library_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "folder_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "books_primary_file_id_idx": { + "name": "books_primary_file_id_idx", + "columns": [ + { + "expression": "primary_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "books_library_status_idx": { + "name": "books_library_status_idx", + "columns": [ + { + "expression": "library_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "books_library_added_at_idx": { + "name": "books_library_added_at_idx", + "columns": [ + { + "expression": "library_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"added_at\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "books_library_visible_added_id_idx": { + "name": "books_library_visible_added_id_idx", + "columns": [ + { + "expression": "library_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"added_at\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"books\".\"status\" <> 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "books_library_author_sort_id_idx": { + "name": "books_library_author_sort_id_idx", + "columns": [ + { + "expression": "library_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"primary_author_sort_name\" asc nulls last", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"books\".\"status\" <> 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "books_library_author_sort_desc_id_idx": { + "name": "books_library_author_sort_desc_id_idx", + "columns": [ + { + "expression": "library_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"primary_author_sort_name\" desc nulls last", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"books\".\"status\" <> 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "books_library_id_libraries_id_fk": { + "name": "books_library_id_libraries_id_fk", + "tableFrom": "books", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "books_library_folder_id_library_folders_id_fk": { + "name": "books_library_folder_id_library_folders_id_fk", + "tableFrom": "books", + "tableTo": "library_folders", + "columnsFrom": ["library_folder_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "books_primary_file_id_book_files_id_fk": { + "name": "books_primary_file_id_book_files_id_fk", + "tableFrom": "books", + "tableTo": "book_files", + "columnsFrom": ["primary_file_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "books_library_folder_library_fk": { + "name": "books_library_folder_library_fk", + "tableFrom": "books", + "tableTo": "library_folders", + "columnsFrom": ["library_folder_id", "library_id"], + "columnsTo": ["id", "library_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "books_id_library_folder_id_unique": { + "name": "books_id_library_folder_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "library_folder_id"] + } + }, + "policies": {}, + "checkConstraints": { + "books_status_chk": { + "name": "books_status_chk", + "value": "\"books\".\"status\" in ('present', 'missing', 'processing')" + } + }, + "isRLSEnabled": false + }, + "public.book_duplicate_group_members": { + "name": "book_duplicate_group_members", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scan_id": { + "name": "scan_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "book_duplicate_group_members_group_idx": { + "name": "book_duplicate_group_members_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_duplicate_group_members_group_id_book_duplicate_groups_id_fk": { + "name": "book_duplicate_group_members_group_id_book_duplicate_groups_id_fk", + "tableFrom": "book_duplicate_group_members", + "tableTo": "book_duplicate_groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_duplicate_group_members_scan_id_book_duplicate_scans_id_fk": { + "name": "book_duplicate_group_members_scan_id_book_duplicate_scans_id_fk", + "tableFrom": "book_duplicate_group_members", + "tableTo": "book_duplicate_scans", + "columnsFrom": ["scan_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_duplicate_group_members_book_id_books_id_fk": { + "name": "book_duplicate_group_members_book_id_books_id_fk", + "tableFrom": "book_duplicate_group_members", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "book_duplicate_group_members_scan_id_book_id_pk": { + "name": "book_duplicate_group_members_scan_id_book_id_pk", + "columns": ["scan_id", "book_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_duplicate_groups": { + "name": "book_duplicate_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "scan_id": { + "name": "scan_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "root_book_id": { + "name": "root_book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reasons": { + "name": "reasons", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "max_title_similarity": { + "name": "max_title_similarity", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "member_count": { + "name": "member_count", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "book_duplicate_groups_scan_root_uidx": { + "name": "book_duplicate_groups_scan_root_uidx", + "columns": [ + { + "expression": "scan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "root_book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_duplicate_groups_scan_id_idx": { + "name": "book_duplicate_groups_scan_id_idx", + "columns": [ + { + "expression": "scan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_duplicate_groups_scan_member_count_idx": { + "name": "book_duplicate_groups_scan_member_count_idx", + "columns": [ + { + "expression": "scan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_duplicate_groups_scan_id_book_duplicate_scans_id_fk": { + "name": "book_duplicate_groups_scan_id_book_duplicate_scans_id_fk", + "tableFrom": "book_duplicate_groups", + "tableTo": "book_duplicate_scans", + "columnsFrom": ["scan_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_duplicate_groups_root_book_id_books_id_fk": { + "name": "book_duplicate_groups_root_book_id_books_id_fk", + "tableFrom": "book_duplicate_groups", + "tableTo": "books", + "columnsFrom": ["root_book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_duplicate_pairs": { + "name": "book_duplicate_pairs", + "schema": "", + "columns": { + "scan_id": { + "name": "scan_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "book_id_a": { + "name": "book_id_a", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id_b": { + "name": "book_id_b", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reasons": { + "name": "reasons", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "title_similarity": { + "name": "title_similarity", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "book_duplicate_pairs_group_idx": { + "name": "book_duplicate_pairs_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_duplicate_pairs_scan_a_idx": { + "name": "book_duplicate_pairs_scan_a_idx", + "columns": [ + { + "expression": "scan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id_a", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_duplicate_pairs_scan_b_idx": { + "name": "book_duplicate_pairs_scan_b_idx", + "columns": [ + { + "expression": "scan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id_b", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_duplicate_pairs_scan_id_book_duplicate_scans_id_fk": { + "name": "book_duplicate_pairs_scan_id_book_duplicate_scans_id_fk", + "tableFrom": "book_duplicate_pairs", + "tableTo": "book_duplicate_scans", + "columnsFrom": ["scan_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_duplicate_pairs_group_id_book_duplicate_groups_id_fk": { + "name": "book_duplicate_pairs_group_id_book_duplicate_groups_id_fk", + "tableFrom": "book_duplicate_pairs", + "tableTo": "book_duplicate_groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_duplicate_pairs_book_id_a_books_id_fk": { + "name": "book_duplicate_pairs_book_id_a_books_id_fk", + "tableFrom": "book_duplicate_pairs", + "tableTo": "books", + "columnsFrom": ["book_id_a"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_duplicate_pairs_book_id_b_books_id_fk": { + "name": "book_duplicate_pairs_book_id_b_books_id_fk", + "tableFrom": "book_duplicate_pairs", + "tableTo": "books", + "columnsFrom": ["book_id_b"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "book_duplicate_pairs_scan_id_book_id_a_book_id_b_pk": { + "name": "book_duplicate_pairs_scan_id_book_id_a_book_id_b_pk", + "columns": ["scan_id", "book_id_a", "book_id_b"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "book_duplicate_pairs_order_chk": { + "name": "book_duplicate_pairs_order_chk", + "value": "\"book_duplicate_pairs\".\"book_id_a\" < \"book_duplicate_pairs\".\"book_id_b\"" + } + }, + "isRLSEnabled": false + }, + "public.book_duplicate_scan_keys": { + "name": "book_duplicate_scan_keys", + "schema": "", + "columns": { + "scan_id": { + "name": "scan_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "book_duplicate_scan_keys_lookup_idx": { + "name": "book_duplicate_scan_keys_lookup_idx", + "columns": [ + { + "expression": "scan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_duplicate_scan_keys_scan_id_book_duplicate_scans_id_fk": { + "name": "book_duplicate_scan_keys_scan_id_book_duplicate_scans_id_fk", + "tableFrom": "book_duplicate_scan_keys", + "tableTo": "book_duplicate_scans", + "columnsFrom": ["scan_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_duplicate_scan_keys_book_id_books_id_fk": { + "name": "book_duplicate_scan_keys_book_id_books_id_fk", + "tableFrom": "book_duplicate_scan_keys", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "book_duplicate_scan_keys_scan_id_book_id_kind_value_pk": { + "name": "book_duplicate_scan_keys_scan_id_book_id_kind_value_pk", + "columns": ["scan_id", "book_id", "kind", "value"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "book_duplicate_scan_keys_kind_chk": { + "name": "book_duplicate_scan_keys_kind_chk", + "value": "\"book_duplicate_scan_keys\".\"kind\" in ('file_hash', 'isbn', 'exact_metadata')" + } + }, + "isRLSEnabled": false + }, + "public.book_duplicate_scans": { + "name": "book_duplicate_scans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "library_ids": { + "name": "library_ids", + "type": "integer[]", + "primaryKey": false, + "notNull": true + }, + "requested_library_id": { + "name": "requested_library_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "similarity_percent": { + "name": "similarity_percent", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "processed_books": { + "name": "processed_books", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_books": { + "name": "total_books", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_groups": { + "name": "total_groups", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "book_duplicate_scans_user_created_idx": { + "name": "book_duplicate_scans_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_duplicate_scans_status_idx": { + "name": "book_duplicate_scans_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_duplicate_scans_user_id_users_id_fk": { + "name": "book_duplicate_scans_user_id_users_id_fk", + "tableFrom": "book_duplicate_scans", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "book_duplicate_scans_similarity_chk": { + "name": "book_duplicate_scans_similarity_chk", + "value": "\"book_duplicate_scans\".\"similarity_percent\" between 70 and 100" + }, + "book_duplicate_scans_status_chk": { + "name": "book_duplicate_scans_status_chk", + "value": "\"book_duplicate_scans\".\"status\" in ('queued', 'running', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.collection_books": { + "name": "collection_books", + "schema": "", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "collection_books_book_id_idx": { + "name": "collection_books_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collection_books_collection_id_collections_id_fk": { + "name": "collection_books_collection_id_collections_id_fk", + "tableFrom": "collection_books", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_books_book_id_books_id_fk": { + "name": "collection_books_book_id_books_id_fk", + "tableFrom": "collection_books", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "collection_books_collection_id_book_id_pk": { + "name": "collection_books_collection_id_book_id_pk", + "columns": ["collection_id", "book_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_to_kobo": { + "name": "sync_to_kobo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "collections_user_name_uidx": { + "name": "collections_user_name_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collections_user_display_name_idx": { + "name": "collections_user_display_name_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collections_user_id_users_id_fk": { + "name": "collections_user_id_users_id_fk", + "tableFrom": "collections", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.smart_scopes": { + "name": "smart_scopes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "filter": { + "name": "filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "default_sort": { + "name": "default_sort", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sync_to_kobo": { + "name": "sync_to_kobo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "smart_scopes_user_id_users_id_fk": { + "name": "smart_scopes_user_id_users_id_fk", + "tableFrom": "smart_scopes", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "smart_scopes_user_id_name_unique": { + "name": "smart_scopes_user_id_name_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_series": { + "name": "book_series", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "normalized_name": { + "name": "normalized_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "book_series_normalized_name_uidx": { + "name": "book_series_normalized_name_uidx", + "columns": [ + { + "expression": "normalized_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_series_name_trgm_idx": { + "name": "book_series_name_trgm_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "book_series_name_unaccent_trgm_idx": { + "name": "book_series_name_unaccent_trgm_idx", + "columns": [ + { + "expression": "public.bookorbit_unaccent(\"name\") gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "book_series_name_lower_idx": { + "name": "book_series_name_lower_idx", + "columns": [ + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_series_memberships": { + "name": "book_series_memberships", + "schema": "", + "columns": { + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "series_id": { + "name": "series_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "series_index": { + "name": "series_index", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "book_series_memberships_book_display_uidx": { + "name": "book_series_memberships_book_display_uidx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_series_memberships_series_index_book_idx": { + "name": "book_series_memberships_series_index_book_idx", + "columns": [ + { + "expression": "series_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "series_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_series_memberships_book_display_idx": { + "name": "book_series_memberships_book_display_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_series_memberships_book_id_books_id_fk": { + "name": "book_series_memberships_book_id_books_id_fk", + "tableFrom": "book_series_memberships", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_series_memberships_series_id_book_series_id_fk": { + "name": "book_series_memberships_series_id_book_series_id_fk", + "tableFrom": "book_series_memberships", + "tableTo": "book_series", + "columnsFrom": ["series_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "book_series_memberships_book_id_series_id_pk": { + "name": "book_series_memberships_book_id_series_id_pk", + "columns": ["book_id", "series_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "book_series_memberships_display_order_nonnegative_chk": { + "name": "book_series_memberships_display_order_nonnegative_chk", + "value": "\"book_series_memberships\".\"display_order\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.author_enrichment_queue": { + "name": "author_enrichment_queue", + "schema": "", + "columns": { + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "reason": { + "name": "reason", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_http_status": { + "name": "last_http_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "author_enrichment_queue_status_next_attempt_idx": { + "name": "author_enrichment_queue_status_next_attempt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "author_enrichment_queue_next_attempt_idx": { + "name": "author_enrichment_queue_next_attempt_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "author_enrichment_queue_single_processing_idx": { + "name": "author_enrichment_queue_single_processing_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"author_enrichment_queue\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "author_enrichment_queue_author_id_authors_id_fk": { + "name": "author_enrichment_queue_author_id_authors_id_fk", + "tableFrom": "author_enrichment_queue", + "tableTo": "authors", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "author_enrichment_queue_status_chk": { + "name": "author_enrichment_queue_status_chk", + "value": "\"author_enrichment_queue\".\"status\" in ('queued', 'processing', 'rate_limited', 'failed', 'done')" + }, + "author_enrichment_queue_reason_chk": { + "name": "author_enrichment_queue_reason_chk", + "value": "\"author_enrichment_queue\".\"reason\" in ('unknown', 'metadata_replace', 'manual_backfill', 'manual_backfill_all', 'author_rename', 'author_merge_target')" + }, + "author_enrichment_queue_attempt_count_nonnegative_chk": { + "name": "author_enrichment_queue_attempt_count_nonnegative_chk", + "value": "\"author_enrichment_queue\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.authors": { + "name": "authors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "sort_name": { + "name": "sort_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_photo": { + "name": "has_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_enriched_at": { + "name": "last_enriched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "authors_name_trgm_idx": { + "name": "authors_name_trgm_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "authors_name_unaccent_trgm_idx": { + "name": "authors_name_unaccent_trgm_idx", + "columns": [ + { + "expression": "public.bookorbit_unaccent(\"name\") gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "authors_name_unique": { + "name": "authors_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_authors": { + "name": "book_authors", + "schema": "", + "columns": { + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "book_authors_author_id_idx": { + "name": "book_authors_author_id_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_authors_book_display_idx": { + "name": "book_authors_book_display_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_authors_book_display_author_idx": { + "name": "book_authors_book_display_author_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_authors_book_id_books_id_fk": { + "name": "book_authors_book_id_books_id_fk", + "tableFrom": "book_authors", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_authors_author_id_authors_id_fk": { + "name": "book_authors_author_id_authors_id_fk", + "tableFrom": "book_authors", + "tableTo": "authors", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "book_authors_book_id_author_id_pk": { + "name": "book_authors_book_id_author_id_pk", + "columns": ["book_id", "author_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_community_ratings": { + "name": "book_community_ratings", + "schema": "", + "columns": { + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "rating_count": { + "name": "rating_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "book_community_ratings_provider_idx": { + "name": "book_community_ratings_provider_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_community_ratings_rating_book_idx": { + "name": "book_community_ratings_rating_book_idx", + "columns": [ + { + "expression": "rating", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_community_ratings_provider_rating_book_idx": { + "name": "book_community_ratings_provider_rating_book_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_community_ratings_book_id_books_id_fk": { + "name": "book_community_ratings_book_id_books_id_fk", + "tableFrom": "book_community_ratings", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "book_community_ratings_book_id_provider_pk": { + "name": "book_community_ratings_book_id_provider_pk", + "columns": ["book_id", "provider"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "book_community_ratings_rating_range_chk": { + "name": "book_community_ratings_rating_range_chk", + "value": "\"book_community_ratings\".\"rating\" >= 0 and \"book_community_ratings\".\"rating\" <= 5" + }, + "book_community_ratings_count_nonnegative_chk": { + "name": "book_community_ratings_count_nonnegative_chk", + "value": "\"book_community_ratings\".\"rating_count\" is null or \"book_community_ratings\".\"rating_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.book_genres": { + "name": "book_genres", + "schema": "", + "columns": { + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "genre_id": { + "name": "genre_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "book_genres_genre_id_idx": { + "name": "book_genres_genre_id_idx", + "columns": [ + { + "expression": "genre_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_genres_book_id_books_id_fk": { + "name": "book_genres_book_id_books_id_fk", + "tableFrom": "book_genres", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_genres_genre_id_genres_id_fk": { + "name": "book_genres_genre_id_genres_id_fk", + "tableFrom": "book_genres", + "tableTo": "genres", + "columnsFrom": ["genre_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "book_genres_book_id_genre_id_pk": { + "name": "book_genres_book_id_genre_id_pk", + "columns": ["book_id", "genre_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_metadata": { + "name": "book_metadata", + "schema": "", + "columns": { + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false + }, + "subtitle": { + "name": "subtitle", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isbn10": { + "name": "isbn10", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "isbn13": { + "name": "isbn13", + "type": "varchar(13)", + "primaryKey": false, + "notNull": false + }, + "publisher": { + "name": "publisher", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "published_date": { + "name": "published_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "published_year": { + "name": "published_year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "series_id": { + "name": "series_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "series_name": { + "name": "series_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "series_index": { + "name": "series_index", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cover_source": { + "name": "cover_source", + "type": "varchar(9)", + "primaryKey": false, + "notNull": false + }, + "google_books_id": { + "name": "google_books_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "goodreads_id": { + "name": "goodreads_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "amazon_id": { + "name": "amazon_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "hardcover_id": { + "name": "hardcover_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "hardcover_edition_id": { + "name": "hardcover_edition_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "open_library_id": { + "name": "open_library_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "itunes_id": { + "name": "itunes_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "kobo_id": { + "name": "kobo_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "metadata_score": { + "name": "metadata_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_metadata_fetch_at": { + "name": "last_metadata_fetch_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(256)", + "primaryKey": false, + "notNull": false + }, + "last_written_at": { + "name": "last_written_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "abridged": { + "name": "abridged", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "audible_id": { + "name": "audible_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "librofm_id": { + "name": "librofm_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "comicvine_id": { + "name": "comicvine_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "ranobedb_id": { + "name": "ranobedb_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "lubimyczytac_id": { + "name": "lubimyczytac_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aladin_id": { + "name": "aladin_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "chapters": { + "name": "chapters", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "locked_fields": { + "name": "locked_fields", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bm_title_trgm_idx": { + "name": "bm_title_trgm_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "bm_title_unaccent_trgm_idx": { + "name": "bm_title_unaccent_trgm_idx", + "columns": [ + { + "expression": "public.bookorbit_unaccent(\"title\") gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "bm_title_lower_idx": { + "name": "bm_title_lower_idx", + "columns": [ + { + "expression": "lower(\"title\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bm_title_book_id_idx": { + "name": "bm_title_book_id_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bm_series_trgm_idx": { + "name": "bm_series_trgm_idx", + "columns": [ + { + "expression": "series_name", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "bm_series_unaccent_trgm_idx": { + "name": "bm_series_unaccent_trgm_idx", + "columns": [ + { + "expression": "public.bookorbit_unaccent(\"series_name\") gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "bm_series_id_idx": { + "name": "bm_series_id_idx", + "columns": [ + { + "expression": "series_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bm_series_id_index_book_id_idx": { + "name": "bm_series_id_index_book_id_idx", + "columns": [ + { + "expression": "series_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "series_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bm_series_name_lower_btrim_idx": { + "name": "bm_series_name_lower_btrim_idx", + "columns": [ + { + "expression": "lower(btrim(\"series_name\"))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bm_publisher_trgm_idx": { + "name": "bm_publisher_trgm_idx", + "columns": [ + { + "expression": "publisher", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "bm_publisher_unaccent_trgm_idx": { + "name": "bm_publisher_unaccent_trgm_idx", + "columns": [ + { + "expression": "public.bookorbit_unaccent(\"publisher\") gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "bm_language_idx": { + "name": "bm_language_idx", + "columns": [ + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bm_language_trgm_idx": { + "name": "bm_language_trgm_idx", + "columns": [ + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "bm_language_unaccent_trgm_idx": { + "name": "bm_language_unaccent_trgm_idx", + "columns": [ + { + "expression": "public.bookorbit_unaccent(\"language\") gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "bm_published_date_idx": { + "name": "bm_published_date_idx", + "columns": [ + { + "expression": "published_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bm_published_year_idx": { + "name": "bm_published_year_idx", + "columns": [ + { + "expression": "published_year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bm_published_date_sort_idx": { + "name": "bm_published_date_sort_idx", + "columns": [ + { + "expression": "coalesce(\"published_date\", make_date(\"published_year\", 1, 1))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bm_series_name_index_idx": { + "name": "bm_series_name_index_idx", + "columns": [ + { + "expression": "series_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "series_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bm_isbn10_idx": { + "name": "bm_isbn10_idx", + "columns": [ + { + "expression": "isbn10", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bm_isbn13_idx": { + "name": "bm_isbn13_idx", + "columns": [ + { + "expression": "isbn13", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bm_embedding_hnsw_cosine_idx": { + "name": "bm_embedding_hnsw_cosine_idx", + "columns": [ + { + "expression": "\"embedding\" vector_cosine_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": { + "book_metadata_book_id_books_id_fk": { + "name": "book_metadata_book_id_books_id_fk", + "tableFrom": "book_metadata", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_metadata_series_id_book_series_id_fk": { + "name": "book_metadata_series_id_book_series_id_fk", + "tableFrom": "book_metadata", + "tableTo": "book_series", + "columnsFrom": ["series_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "book_metadata_rating_range_chk": { + "name": "book_metadata_rating_range_chk", + "value": "\"book_metadata\".\"rating\" is null or (\"book_metadata\".\"rating\" >= 1 and \"book_metadata\".\"rating\" <= 10)" + }, + "book_metadata_published_date_range_chk": { + "name": "book_metadata_published_date_range_chk", + "value": "\"book_metadata\".\"published_date\" is null or (extract(year from \"book_metadata\".\"published_date\") >= 1000 and extract(year from \"book_metadata\".\"published_date\") <= 2200)" + }, + "book_metadata_published_year_range_chk": { + "name": "book_metadata_published_year_range_chk", + "value": "\"book_metadata\".\"published_year\" is null or (\"book_metadata\".\"published_year\" >= 1000 and \"book_metadata\".\"published_year\" <= 2200)" + }, + "book_metadata_page_count_nonnegative_chk": { + "name": "book_metadata_page_count_nonnegative_chk", + "value": "\"book_metadata\".\"page_count\" is null or \"book_metadata\".\"page_count\" >= 0" + }, + "book_metadata_duration_seconds_nonnegative_chk": { + "name": "book_metadata_duration_seconds_nonnegative_chk", + "value": "\"book_metadata\".\"duration_seconds\" is null or \"book_metadata\".\"duration_seconds\" >= 0" + }, + "book_metadata_cover_source_chk": { + "name": "book_metadata_cover_source_chk", + "value": "\"book_metadata\".\"cover_source\" is null or \"book_metadata\".\"cover_source\" in ('extracted', 'custom')" + } + }, + "isRLSEnabled": false + }, + "public.book_metadata_fetch_queue": { + "name": "book_metadata_fetch_queue", + "schema": "", + "columns": { + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "reason": { + "name": "reason", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'manual_trigger'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_http_status": { + "name": "last_http_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bmfq_status_idx": { + "name": "bmfq_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bmfq_created_at_idx": { + "name": "bmfq_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bmfq_status_created_book_idx": { + "name": "bmfq_status_created_book_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_metadata_fetch_queue_book_id_books_id_fk": { + "name": "book_metadata_fetch_queue_book_id_books_id_fk", + "tableFrom": "book_metadata_fetch_queue", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "book_metadata_fetch_queue_status_chk": { + "name": "book_metadata_fetch_queue_status_chk", + "value": "\"book_metadata_fetch_queue\".\"status\" in ('queued', 'processing', 'failed')" + }, + "book_metadata_fetch_queue_reason_chk": { + "name": "book_metadata_fetch_queue_reason_chk", + "value": "\"book_metadata_fetch_queue\".\"reason\" in ('event_import', 'manual_trigger', 'manual_retry')" + }, + "book_metadata_fetch_queue_attempt_count_nonnegative_chk": { + "name": "book_metadata_fetch_queue_attempt_count_nonnegative_chk", + "value": "\"book_metadata_fetch_queue\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.book_tags": { + "name": "book_tags", + "schema": "", + "columns": { + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "book_tags_tag_id_idx": { + "name": "book_tags_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_tags_book_id_books_id_fk": { + "name": "book_tags_book_id_books_id_fk", + "tableFrom": "book_tags", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_tags_tag_id_tags_id_fk": { + "name": "book_tags_tag_id_tags_id_fk", + "tableFrom": "book_tags", + "tableTo": "tags", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "book_tags_book_id_tag_id_pk": { + "name": "book_tags_book_id_tag_id_pk", + "columns": ["book_id", "tag_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.genres": { + "name": "genres", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "genres_name_trgm_idx": { + "name": "genres_name_trgm_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "genres_name_unaccent_trgm_idx": { + "name": "genres_name_unaccent_trgm_idx", + "columns": [ + { + "expression": "public.bookorbit_unaccent(\"name\") gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "genres_name_unique": { + "name": "genres_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tags_name_trgm_idx": { + "name": "tags_name_trgm_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "tags_name_unaccent_trgm_idx": { + "name": "tags_name_unaccent_trgm_idx", + "columns": [ + { + "expression": "public.bookorbit_unaccent(\"name\") gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_narrators": { + "name": "book_narrators", + "schema": "", + "columns": { + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "narrator_id": { + "name": "narrator_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "book_narrators_narrator_id_idx": { + "name": "book_narrators_narrator_id_idx", + "columns": [ + { + "expression": "narrator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_narrators_book_id_books_id_fk": { + "name": "book_narrators_book_id_books_id_fk", + "tableFrom": "book_narrators", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_narrators_narrator_id_narrators_id_fk": { + "name": "book_narrators_narrator_id_narrators_id_fk", + "tableFrom": "book_narrators", + "tableTo": "narrators", + "columnsFrom": ["narrator_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "book_narrators_book_id_narrator_id_pk": { + "name": "book_narrators_book_id_narrator_id_pk", + "columns": ["book_id", "narrator_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.narrators": { + "name": "narrators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "sort_name": { + "name": "sort_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "narrators_name_trgm_idx": { + "name": "narrators_name_trgm_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "narrators_name_unaccent_trgm_idx": { + "name": "narrators_name_unaccent_trgm_idx", + "columns": [ + { + "expression": "public.bookorbit_unaccent(\"name\") gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "narrators_name_unique": { + "name": "narrators_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.library_dir_scan_state": { + "name": "library_dir_scan_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "library_folder_id": { + "name": "library_folder_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "dir_path": { + "name": "dir_path", + "type": "varchar(4096)", + "primaryKey": false, + "notNull": true + }, + "last_seen_mtime_ms": { + "name": "last_seen_mtime_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "library_dir_scan_state_folder_dir_uidx": { + "name": "library_dir_scan_state_folder_dir_uidx", + "columns": [ + { + "expression": "library_folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dir_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "library_dir_scan_state_folder_idx": { + "name": "library_dir_scan_state_folder_idx", + "columns": [ + { + "expression": "library_folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "library_dir_scan_state_library_folder_id_library_folders_id_fk": { + "name": "library_dir_scan_state_library_folder_id_library_folders_id_fk", + "tableFrom": "library_dir_scan_state", + "tableTo": "library_folders", + "columnsFrom": ["library_folder_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scan_jobs": { + "name": "scan_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "library_id": { + "name": "library_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "triggered_by": { + "name": "triggered_by", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "added_count": { + "name": "added_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_count": { + "name": "updated_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "missing_count": { + "name": "missing_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scan_jobs_library_status_idx": { + "name": "scan_jobs_library_status_idx", + "columns": [ + { + "expression": "library_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scan_jobs_library_id_libraries_id_fk": { + "name": "scan_jobs_library_id_libraries_id_fk", + "tableFrom": "scan_jobs", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "scan_jobs_status_chk": { + "name": "scan_jobs_status_chk", + "value": "\"scan_jobs\".\"status\" in ('running', 'completed', 'failed')" + }, + "scan_jobs_triggered_by_chk": { + "name": "scan_jobs_triggered_by_chk", + "value": "\"scan_jobs\".\"triggered_by\" in ('manual', 'watcher', 'schedule')" + }, + "scan_jobs_added_count_nonnegative_chk": { + "name": "scan_jobs_added_count_nonnegative_chk", + "value": "\"scan_jobs\".\"added_count\" >= 0" + }, + "scan_jobs_updated_count_nonnegative_chk": { + "name": "scan_jobs_updated_count_nonnegative_chk", + "value": "\"scan_jobs\".\"updated_count\" >= 0" + }, + "scan_jobs_missing_count_nonnegative_chk": { + "name": "scan_jobs_missing_count_nonnegative_chk", + "value": "\"scan_jobs\".\"missing_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.annotation_positions": { + "name": "annotation_positions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "annotation_id": { + "name": "annotation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_file_id": { + "name": "book_file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true + }, + "pos0": { + "name": "pos0", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pos1": { + "name": "pos1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'exact'" + }, + "converter_version": { + "name": "converter_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "extras": { + "name": "extras", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "annotation_positions_annotation_format_uidx": { + "name": "annotation_positions_annotation_format_uidx", + "columns": [ + { + "expression": "annotation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "format", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "annotation_positions_user_idx": { + "name": "annotation_positions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "annotation_positions_book_file_id_idx": { + "name": "annotation_positions_book_file_id_idx", + "columns": [ + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "annotation_positions_format_status_idx": { + "name": "annotation_positions_format_status_idx", + "columns": [ + { + "expression": "format", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "annotation_positions_annotation_id_annotations_id_fk": { + "name": "annotation_positions_annotation_id_annotations_id_fk", + "tableFrom": "annotation_positions", + "tableTo": "annotations", + "columnsFrom": ["annotation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "annotation_positions_user_id_users_id_fk": { + "name": "annotation_positions_user_id_users_id_fk", + "tableFrom": "annotation_positions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "annotation_positions_book_file_id_book_files_id_fk": { + "name": "annotation_positions_book_file_id_book_files_id_fk", + "tableFrom": "annotation_positions", + "tableTo": "book_files", + "columnsFrom": ["book_file_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "annotation_positions_format_chk": { + "name": "annotation_positions_format_chk", + "value": "\"annotation_positions\".\"format\" in ('cfi', 'xpointer', 'pdf', 'kobo_span')" + }, + "annotation_positions_status_chk": { + "name": "annotation_positions_status_chk", + "value": "\"annotation_positions\".\"status\" in ('exact', 'repaired', 'failed', 'pending')" + }, + "annotation_positions_pos0_chk": { + "name": "annotation_positions_pos0_chk", + "value": "\"annotation_positions\".\"status\" in ('failed', 'pending') or \"annotation_positions\".\"pos0\" is not null" + } + }, + "isRLSEnabled": false + }, + "public.annotation_sync_state": { + "name": "annotation_sync_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "annotation_id": { + "name": "annotation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "external_key": { + "name": "external_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "external_created_at": { + "name": "external_created_at", + "type": "varchar(19)", + "primaryKey": false, + "notNull": false + }, + "last_applied_version": { + "name": "last_applied_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "delete_acked_at": { + "name": "delete_acked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "first_synced_at": { + "name": "first_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "annotation_sync_state_annotation_source_device_uidx": { + "name": "annotation_sync_state_annotation_source_device_uidx", + "columns": [ + { + "expression": "annotation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "annotation_sync_state_user_source_device_key_idx": { + "name": "annotation_sync_state_user_source_device_key_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "annotation_sync_state_user_key_idx": { + "name": "annotation_sync_state_user_key_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "annotation_sync_state_annotation_id_idx": { + "name": "annotation_sync_state_annotation_id_idx", + "columns": [ + { + "expression": "annotation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "annotation_sync_state_annotation_id_annotations_id_fk": { + "name": "annotation_sync_state_annotation_id_annotations_id_fk", + "tableFrom": "annotation_sync_state", + "tableTo": "annotations", + "columnsFrom": ["annotation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "annotation_sync_state_user_id_users_id_fk": { + "name": "annotation_sync_state_user_id_users_id_fk", + "tableFrom": "annotation_sync_state", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "annotation_sync_state_source_chk": { + "name": "annotation_sync_state_source_chk", + "value": "\"annotation_sync_state\".\"source\" in ('koreader', 'kobo')" + } + }, + "isRLSEnabled": false + }, + "public.annotations": { + "name": "annotations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'yellow'" + }, + "style": { + "name": "style", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'highlight'" + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chapter_title": { + "name": "chapter_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'web'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "device_created_at": { + "name": "device_created_at", + "type": "varchar(19)", + "primaryKey": false, + "notNull": false + }, + "device_updated_at": { + "name": "device_updated_at", + "type": "varchar(19)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "annotations_user_id_idx": { + "name": "annotations_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "annotations_user_id_id_idx": { + "name": "annotations_user_id_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "annotations_user_book_idx": { + "name": "annotations_user_book_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "annotations_book_id_idx": { + "name": "annotations_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "annotations_user_book_active_idx": { + "name": "annotations_user_book_active_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"annotations\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "annotations_user_id_users_id_fk": { + "name": "annotations_user_id_users_id_fk", + "tableFrom": "annotations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "annotations_book_id_books_id_fk": { + "name": "annotations_book_id_books_id_fk", + "tableFrom": "annotations", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "annotations_style_chk": { + "name": "annotations_style_chk", + "value": "\"annotations\".\"style\" in ('highlight', 'underline', 'strikethrough', 'squiggly', 'invert')" + }, + "annotations_origin_chk": { + "name": "annotations_origin_chk", + "value": "\"annotations\".\"origin\" in ('web', 'koreader', 'kobo')" + } + }, + "isRLSEnabled": false + }, + "public.audiobook_progress": { + "name": "audiobook_progress", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "percentage": { + "name": "percentage", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "current_file_id": { + "name": "current_file_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position_seconds": { + "name": "position_seconds", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "abp_user_id_idx": { + "name": "abp_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audiobook_progress_book_id_idx": { + "name": "audiobook_progress_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audiobook_progress_current_file_id_idx": { + "name": "audiobook_progress_current_file_id_idx", + "columns": [ + { + "expression": "current_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audiobook_progress_user_id_users_id_fk": { + "name": "audiobook_progress_user_id_users_id_fk", + "tableFrom": "audiobook_progress", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audiobook_progress_book_id_books_id_fk": { + "name": "audiobook_progress_book_id_books_id_fk", + "tableFrom": "audiobook_progress", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audiobook_progress_current_file_id_book_files_id_fk": { + "name": "audiobook_progress_current_file_id_book_files_id_fk", + "tableFrom": "audiobook_progress", + "tableTo": "book_files", + "columnsFrom": ["current_file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobook_progress_user_id_book_id_pk": { + "name": "audiobook_progress_user_id_book_id_pk", + "columns": ["user_id", "book_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "audiobook_progress_percentage_range_chk": { + "name": "audiobook_progress_percentage_range_chk", + "value": "\"audiobook_progress\".\"percentage\" >= 0 and \"audiobook_progress\".\"percentage\" <= 100" + }, + "audiobook_progress_position_seconds_nonnegative_chk": { + "name": "audiobook_progress_position_seconds_nonnegative_chk", + "value": "\"audiobook_progress\".\"position_seconds\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.bookmarks": { + "name": "bookmarks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cfi": { + "name": "cfi", + "type": "varchar(2000)", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "position_seconds": { + "name": "position_seconds", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'web'" + }, + "device_pos": { + "name": "device_pos", + "type": "varchar(4000)", + "primaryKey": false, + "notNull": false + }, + "pageno": { + "name": "pageno", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "bookmarks_user_book_idx": { + "name": "bookmarks_user_book_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bookmarks_book_id_idx": { + "name": "bookmarks_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bookmarks_deleted_at_idx": { + "name": "bookmarks_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"bookmarks\".\"deleted_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "bookmarks_user_book_cfi_uidx": { + "name": "bookmarks_user_book_cfi_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cfi", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"bookmarks\".\"cfi\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "bookmarks_user_book_pos_uidx": { + "name": "bookmarks_user_book_pos_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position_seconds", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"bookmarks\".\"position_seconds\" is not null and \"bookmarks\".\"cfi\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bookmarks_user_id_users_id_fk": { + "name": "bookmarks_user_id_users_id_fk", + "tableFrom": "bookmarks", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarks_book_id_books_id_fk": { + "name": "bookmarks_book_id_books_id_fk", + "tableFrom": "bookmarks", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "bookmarks_origin_chk": { + "name": "bookmarks_origin_chk", + "value": "\"bookmarks\".\"origin\" in ('web', 'koreader')" + } + }, + "isRLSEnabled": false + }, + "public.reader_default_preferences": { + "name": "reader_default_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "format_group": { + "name": "format_group", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rdp_user_format_idx": { + "name": "rdp_user_format_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "format_group", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reader_default_preferences_user_id_users_id_fk": { + "name": "reader_default_preferences_user_id_users_id_fk", + "tableFrom": "reader_default_preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "reader_default_preferences_format_group_chk": { + "name": "reader_default_preferences_format_group_chk", + "value": "\"reader_default_preferences\".\"format_group\" in ('epub', 'pdf', 'cbx', 'audio')" + } + }, + "isRLSEnabled": false + }, + "public.reader_preferences": { + "name": "reader_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_file_id": { + "name": "book_file_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rp_user_file_idx": { + "name": "rp_user_file_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reader_preferences_book_file_id_idx": { + "name": "reader_preferences_book_file_id_idx", + "columns": [ + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reader_preferences_user_id_users_id_fk": { + "name": "reader_preferences_user_id_users_id_fk", + "tableFrom": "reader_preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reader_preferences_book_file_id_book_files_id_fk": { + "name": "reader_preferences_book_file_id_book_files_id_fk", + "tableFrom": "reader_preferences", + "tableTo": "book_files", + "columnsFrom": ["book_file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reading_attempts": { + "name": "reading_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "started_on": { + "name": "started_on", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "ended_on": { + "name": "ended_on", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "external_provider": { + "name": "external_provider", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reading_attempts_user_book_idx": { + "name": "reading_attempts_user_book_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reading_attempts_book_id_idx": { + "name": "reading_attempts_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reading_attempts_user_outcome_ended_idx": { + "name": "reading_attempts_user_outcome_ended_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "outcome", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ended_on", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reading_attempts_one_active_uidx": { + "name": "reading_attempts_one_active_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"reading_attempts\".\"outcome\" is null and \"reading_attempts\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "reading_attempts_external_uidx": { + "name": "reading_attempts_external_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"reading_attempts\".\"external_provider\" is not null and \"reading_attempts\".\"external_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reading_attempts_user_id_users_id_fk": { + "name": "reading_attempts_user_id_users_id_fk", + "tableFrom": "reading_attempts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reading_attempts_book_id_books_id_fk": { + "name": "reading_attempts_book_id_books_id_fk", + "tableFrom": "reading_attempts", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "reading_attempts_outcome_chk": { + "name": "reading_attempts_outcome_chk", + "value": "\"reading_attempts\".\"outcome\" is null or \"reading_attempts\".\"outcome\" in ('completed', 'skimmed', 'abandoned')" + }, + "reading_attempts_origin_chk": { + "name": "reading_attempts_origin_chk", + "value": "\"reading_attempts\".\"origin\" in ('manual', 'bookorbit', 'kobo', 'koreader', 'hardcover', 'migration')" + }, + "reading_attempts_end_after_start_chk": { + "name": "reading_attempts_end_after_start_chk", + "value": "\"reading_attempts\".\"ended_on\" is null or \"reading_attempts\".\"started_on\" is null or \"reading_attempts\".\"ended_on\" >= \"reading_attempts\".\"started_on\"" + }, + "reading_attempts_closed_has_outcome_chk": { + "name": "reading_attempts_closed_has_outcome_chk", + "value": "\"reading_attempts\".\"ended_on\" is null or \"reading_attempts\".\"outcome\" is not null" + } + }, + "isRLSEnabled": false + }, + "public.reading_progress": { + "name": "reading_progress", + "schema": "", + "columns": { + "book_file_id": { + "name": "book_file_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "percentage": { + "name": "percentage", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cfi": { + "name": "cfi", + "type": "varchar(2000)", + "primaryKey": false, + "notNull": false + }, + "page_number": { + "name": "page_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "position_seconds": { + "name": "position_seconds", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "kobo_location_source": { + "name": "kobo_location_source", + "type": "varchar(4096)", + "primaryKey": false, + "notNull": false + }, + "kobo_location_type": { + "name": "kobo_location_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "kobo_location_value": { + "name": "kobo_location_value", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "kobo_content_source_progress_percent": { + "name": "kobo_content_source_progress_percent", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "koreader_progress": { + "name": "koreader_progress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reading_progress_user_id_idx": { + "name": "reading_progress_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reading_progress_user_updated_at_idx": { + "name": "reading_progress_user_updated_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reading_progress_book_file_id_book_files_id_fk": { + "name": "reading_progress_book_file_id_book_files_id_fk", + "tableFrom": "reading_progress", + "tableTo": "book_files", + "columnsFrom": ["book_file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reading_progress_user_id_users_id_fk": { + "name": "reading_progress_user_id_users_id_fk", + "tableFrom": "reading_progress", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "reading_progress_book_file_id_user_id_pk": { + "name": "reading_progress_book_file_id_user_id_pk", + "columns": ["book_file_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "reading_progress_percentage_range_chk": { + "name": "reading_progress_percentage_range_chk", + "value": "\"reading_progress\".\"percentage\" >= 0 and \"reading_progress\".\"percentage\" <= 100" + }, + "reading_progress_page_number_nonnegative_chk": { + "name": "reading_progress_page_number_nonnegative_chk", + "value": "\"reading_progress\".\"page_number\" is null or \"reading_progress\".\"page_number\" >= 0" + }, + "reading_progress_position_seconds_nonnegative_chk": { + "name": "reading_progress_position_seconds_nonnegative_chk", + "value": "\"reading_progress\".\"position_seconds\" is null or \"reading_progress\".\"position_seconds\" >= 0" + }, + "reading_progress_kobo_content_source_progress_range_chk": { + "name": "reading_progress_kobo_content_source_progress_range_chk", + "value": "\"reading_progress\".\"kobo_content_source_progress_percent\" is null or (\"reading_progress\".\"kobo_content_source_progress_percent\" >= 0 and \"reading_progress\".\"kobo_content_source_progress_percent\" <= 100)" + } + }, + "isRLSEnabled": false + }, + "public.reading_sessions": { + "name": "reading_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_file_id": { + "name": "book_file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "attempt_id": { + "name": "attempt_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "progress_delta": { + "name": "progress_delta", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "end_progress": { + "name": "end_progress", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "rs_user_session_id_uidx": { + "name": "rs_user_session_id_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rs_user_started_at_idx": { + "name": "rs_user_started_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rs_book_file_started_at_idx": { + "name": "rs_book_file_started_at_idx", + "columns": [ + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rs_user_book_file_idx": { + "name": "rs_user_book_file_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rs_user_book_started_at_idx": { + "name": "rs_user_book_started_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reading_sessions_book_id_idx": { + "name": "reading_sessions_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rs_attempt_started_at_idx": { + "name": "rs_attempt_started_at_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reading_sessions_user_id_users_id_fk": { + "name": "reading_sessions_user_id_users_id_fk", + "tableFrom": "reading_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reading_sessions_book_file_id_book_files_id_fk": { + "name": "reading_sessions_book_file_id_book_files_id_fk", + "tableFrom": "reading_sessions", + "tableTo": "book_files", + "columnsFrom": ["book_file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reading_sessions_book_id_books_id_fk": { + "name": "reading_sessions_book_id_books_id_fk", + "tableFrom": "reading_sessions", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reading_sessions_attempt_id_reading_attempts_id_fk": { + "name": "reading_sessions_attempt_id_reading_attempts_id_fk", + "tableFrom": "reading_sessions", + "tableTo": "reading_attempts", + "columnsFrom": ["attempt_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "reading_sessions_source_chk": { + "name": "reading_sessions_source_chk", + "value": "\"reading_sessions\".\"source\" in ('web', 'koreader', 'manual', 'kobo')" + }, + "reading_sessions_duration_seconds_nonnegative_chk": { + "name": "reading_sessions_duration_seconds_nonnegative_chk", + "value": "\"reading_sessions\".\"duration_seconds\" >= 0" + }, + "reading_sessions_end_progress_range_chk": { + "name": "reading_sessions_end_progress_range_chk", + "value": "\"reading_sessions\".\"end_progress\" is null or (\"reading_sessions\".\"end_progress\" >= 0 and \"reading_sessions\".\"end_progress\" <= 100)" + }, + "reading_sessions_ended_after_started_chk": { + "name": "reading_sessions_ended_after_started_chk", + "value": "\"reading_sessions\".\"ended_at\" >= \"reading_sessions\".\"started_at\"" + } + }, + "isRLSEnabled": false + }, + "public.user_book_notes": { + "name": "user_book_notes", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ubn_user_id_idx": { + "name": "ubn_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ubn_book_id_idx": { + "name": "ubn_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ubn_book_user_idx": { + "name": "ubn_book_user_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_book_notes_user_id_users_id_fk": { + "name": "user_book_notes_user_id_users_id_fk", + "tableFrom": "user_book_notes", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_book_notes_book_id_books_id_fk": { + "name": "user_book_notes_book_id_books_id_fk", + "tableFrom": "user_book_notes", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_book_notes_user_id_book_id_pk": { + "name": "user_book_notes_user_id_book_id_pk", + "columns": ["user_id", "book_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_book_notes_note_length_chk": { + "name": "user_book_notes_note_length_chk", + "value": "\"user_book_notes\".\"note\" is null or char_length(\"user_book_notes\".\"note\") <= 10000" + } + }, + "isRLSEnabled": false + }, + "public.user_book_ratings": { + "name": "user_book_ratings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ubr_user_id_idx": { + "name": "ubr_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ubr_book_id_idx": { + "name": "ubr_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ubr_book_user_rating_idx": { + "name": "ubr_book_user_rating_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_book_ratings_user_id_users_id_fk": { + "name": "user_book_ratings_user_id_users_id_fk", + "tableFrom": "user_book_ratings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_book_ratings_book_id_books_id_fk": { + "name": "user_book_ratings_book_id_books_id_fk", + "tableFrom": "user_book_ratings", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_book_ratings_user_id_book_id_pk": { + "name": "user_book_ratings_user_id_book_id_pk", + "columns": ["user_id", "book_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_book_ratings_rating_range_chk": { + "name": "user_book_ratings_rating_range_chk", + "value": "\"user_book_ratings\".\"rating\" is null or (\"user_book_ratings\".\"rating\" >= 1 and \"user_book_ratings\".\"rating\" <= 5)" + } + }, + "isRLSEnabled": false + }, + "public.user_book_status": { + "name": "user_book_status", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'unread'" + }, + "source": { + "name": "source", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'auto'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ubs_user_id_idx": { + "name": "ubs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ubs_user_status_idx": { + "name": "ubs_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ubs_book_user_idx": { + "name": "ubs_book_user_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_book_status_user_id_users_id_fk": { + "name": "user_book_status_user_id_users_id_fk", + "tableFrom": "user_book_status", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_book_status_book_id_books_id_fk": { + "name": "user_book_status_book_id_books_id_fk", + "tableFrom": "user_book_status", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_book_status_user_id_book_id_pk": { + "name": "user_book_status_user_id_book_id_pk", + "columns": ["user_id", "book_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_book_status_status_chk": { + "name": "user_book_status_status_chk", + "value": "\"user_book_status\".\"status\" in ('unread', 'want_to_read', 'reading', 'on_hold', 'rereading', 'read', 'skimmed', 'abandoned')" + }, + "user_book_status_source_chk": { + "name": "user_book_status_source_chk", + "value": "\"user_book_status\".\"source\" in ('auto', 'manual')" + }, + "user_book_status_finished_after_started_chk": { + "name": "user_book_status_finished_after_started_chk", + "value": "\"user_book_status\".\"finished_at\" is null or \"user_book_status\".\"started_at\" is null or \"user_book_status\".\"finished_at\" >= \"user_book_status\".\"started_at\"" + } + }, + "isRLSEnabled": false + }, + "public.user_reading_daily_stats": { + "name": "user_reading_daily_stats", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "library_id": { + "name": "library_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "reading_seconds": { + "name": "reading_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "progress_delta": { + "name": "progress_delta", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sessions_count": { + "name": "sessions_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "urds_user_day_idx": { + "name": "urds_user_day_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_reading_daily_stats_library_id_idx": { + "name": "user_reading_daily_stats_library_id_idx", + "columns": [ + { + "expression": "library_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_reading_daily_stats_user_id_users_id_fk": { + "name": "user_reading_daily_stats_user_id_users_id_fk", + "tableFrom": "user_reading_daily_stats", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_reading_daily_stats_library_id_libraries_id_fk": { + "name": "user_reading_daily_stats_library_id_libraries_id_fk", + "tableFrom": "user_reading_daily_stats", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_reading_daily_stats_user_id_library_id_day_pk": { + "name": "user_reading_daily_stats_user_id_library_id_day_pk", + "columns": ["user_id", "library_id", "day"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_reading_daily_stats_reading_seconds_nonnegative_chk": { + "name": "user_reading_daily_stats_reading_seconds_nonnegative_chk", + "value": "\"user_reading_daily_stats\".\"reading_seconds\" >= 0" + }, + "user_reading_daily_stats_sessions_count_nonnegative_chk": { + "name": "user_reading_daily_stats_sessions_count_nonnegative_chk", + "value": "\"user_reading_daily_stats\".\"sessions_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.oidc_group_mappings": { + "name": "oidc_group_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "oidc_group_claim": { + "name": "oidc_group_claim", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_name": { + "name": "permission_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oidc_group_mappings_provider_claim_uidx": { + "name": "oidc_group_mappings_provider_claim_uidx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "oidc_group_claim", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oidc_group_mappings_provider_id_oidc_providers_id_fk": { + "name": "oidc_group_mappings_provider_id_oidc_providers_id_fk", + "tableFrom": "oidc_group_mappings", + "tableTo": "oidc_providers", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oidc_identities": { + "name": "oidc_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "oidc_subject": { + "name": "oidc_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_issuer": { + "name": "oidc_issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oidc_identities_provider_subject_uidx": { + "name": "oidc_identities_provider_subject_uidx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "oidc_subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oidc_identities_user_provider_uidx": { + "name": "oidc_identities_user_provider_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oidc_identities_issuer_subject_uidx": { + "name": "oidc_identities_issuer_subject_uidx", + "columns": [ + { + "expression": "oidc_issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "oidc_subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oidc_identities_user_id_idx": { + "name": "oidc_identities_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oidc_identities_user_id_users_id_fk": { + "name": "oidc_identities_user_id_users_id_fk", + "tableFrom": "oidc_identities", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oidc_identities_provider_id_oidc_providers_id_fk": { + "name": "oidc_identities_provider_id_oidc_providers_id_fk", + "tableFrom": "oidc_identities", + "tableTo": "oidc_providers", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oidc_permission_grants": { + "name": "oidc_permission_grants", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "permission_name": { + "name": "permission_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oidc_permission_grants_user_id_users_id_fk": { + "name": "oidc_permission_grants_user_id_users_id_fk", + "tableFrom": "oidc_permission_grants", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oidc_permission_grants_provider_id_oidc_providers_id_fk": { + "name": "oidc_permission_grants_provider_id_oidc_providers_id_fk", + "tableFrom": "oidc_permission_grants", + "tableTo": "oidc_providers", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "oidc_permission_grants_user_id_provider_id_permission_name_pk": { + "name": "oidc_permission_grants_user_id_provider_id_permission_name_pk", + "columns": ["user_id", "provider_id", "permission_name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oidc_providers": { + "name": "oidc_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "issuer_uri": { + "name": "issuer_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'openid profile email'" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_mapping": { + "name": "claim_mapping", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"username\":\"preferred_username\",\"name\":\"name\",\"email\":\"email\",\"groups\":\"groups\"}'::jsonb" + }, + "auto_provision": { + "name": "auto_provision", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"enabled\":false,\"allowLocalLinking\":false,\"defaultPermissionNames\":[]}'::jsonb" + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oidc_providers_enabled_order_idx": { + "name": "oidc_providers_enabled_order_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oidc_providers_slug_unique": { + "name": "oidc_providers_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "oidc_providers_issuer_uri_unique": { + "name": "oidc_providers_issuer_uri_unique", + "nullsNotDistinct": false, + "columns": ["issuer_uri"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oidc_sessions": { + "name": "oidc_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "oidc_subject": { + "name": "oidc_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_issuer": { + "name": "oidc_issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_session_id": { + "name": "oidc_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token_hint": { + "name": "id_token_hint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idp_refresh_token": { + "name": "idp_refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oidc_sessions_user_active_idx": { + "name": "oidc_sessions_user_active_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"oidc_sessions\".\"revoked\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "oidc_sessions_subject_issuer_idx": { + "name": "oidc_sessions_subject_issuer_idx", + "columns": [ + { + "expression": "oidc_subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "oidc_issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oidc_sessions_sid_idx": { + "name": "oidc_sessions_sid_idx", + "columns": [ + { + "expression": "oidc_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oidc_sessions_expires_at_idx": { + "name": "oidc_sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oidc_sessions_provider_id_idx": { + "name": "oidc_sessions_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oidc_sessions_user_id_users_id_fk": { + "name": "oidc_sessions_user_id_users_id_fk", + "tableFrom": "oidc_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oidc_sessions_provider_id_oidc_providers_id_fk": { + "name": "oidc_sessions_provider_id_oidc_providers_id_fk", + "tableFrom": "oidc_sessions", + "tableTo": "oidc_providers", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oidc_states": { + "name": "oidc_states", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oidc_states_expires_at_idx": { + "name": "oidc_states_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oidc_states_provider_id_oidc_providers_id_fk": { + "name": "oidc_states_provider_id_oidc_providers_id_fk", + "tableFrom": "oidc_states", + "tableTo": "oidc_providers", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oidc_used_jtis": { + "name": "oidc_used_jtis", + "schema": "", + "columns": { + "jti": { + "name": "jti", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oidc_used_jtis_expires_at_idx": { + "name": "oidc_used_jtis_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.opds_users": { + "name": "opds_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "opds_sort_order", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'recent'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "opds_users_username_lower_uidx": { + "name": "opds_users_username_lower_uidx", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "opds_users_user_id_users_id_fk": { + "name": "opds_users_user_id_users_id_fk", + "tableFrom": "opds_users", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "opds_users_username_unique": { + "name": "opds_users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kobo_book_entitlements": { + "name": "kobo_book_entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cover_image_id": { + "name": "cover_image_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "needs_legacy_numeric_removal": { + "name": "needs_legacy_numeric_removal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kobo_book_entitlements_user_id_idx": { + "name": "kobo_book_entitlements_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kobo_book_entitlements_book_id_idx": { + "name": "kobo_book_entitlements_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kobo_book_entitlements_user_id_users_id_fk": { + "name": "kobo_book_entitlements_user_id_users_id_fk", + "tableFrom": "kobo_book_entitlements", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kobo_book_entitlements_book_id_books_id_fk": { + "name": "kobo_book_entitlements_book_id_books_id_fk", + "tableFrom": "kobo_book_entitlements", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kobo_book_entitlements_user_book_unique": { + "name": "kobo_book_entitlements_user_book_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "book_id"] + }, + "kobo_book_entitlements_user_entitlement_unique": { + "name": "kobo_book_entitlements_user_entitlement_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "entitlement_id"] + }, + "kobo_book_entitlements_user_cover_unique": { + "name": "kobo_book_entitlements_user_cover_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "cover_image_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kobo_devices": { + "name": "kobo_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "client_device_id": { + "name": "client_device_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kobo_devices_user_id_idx": { + "name": "kobo_devices_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kobo_devices_user_id_users_id_fk": { + "name": "kobo_devices_user_id_users_id_fk", + "tableFrom": "kobo_devices", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kobo_devices_token_unique": { + "name": "kobo_devices_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + }, + "kobo_devices_client_device_id_unique": { + "name": "kobo_devices_client_device_id_unique", + "nullsNotDistinct": false, + "columns": ["client_device_id"] + }, + "kobo_devices_id_user_id_unique": { + "name": "kobo_devices_id_user_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kobo_library_snapshots": { + "name": "kobo_library_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "legacy_device_cutoff_at": { + "name": "legacy_device_cutoff_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kobo_library_snapshots_user_id_users_id_fk": { + "name": "kobo_library_snapshots_user_id_users_id_fk", + "tableFrom": "kobo_library_snapshots", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kobo_library_snapshots_user_id_unique": { + "name": "kobo_library_snapshots_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kobo_snapshot_books": { + "name": "kobo_snapshot_books", + "schema": "", + "columns": { + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "synced": { + "name": "synced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pending_delete": { + "name": "pending_delete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_new": { + "name": "is_new", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "removed_by_device": { + "name": "removed_by_device", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "file_hash": { + "name": "file_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "delivery_hash": { + "name": "delivery_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "metadata_hash": { + "name": "metadata_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kobo_snapshot_books_book_id_idx": { + "name": "kobo_snapshot_books_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kobo_snapshot_books_snapshot_synced_book_idx": { + "name": "kobo_snapshot_books_snapshot_synced_book_idx", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "synced", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kobo_snapshot_books_snapshot_id_kobo_library_snapshots_id_fk": { + "name": "kobo_snapshot_books_snapshot_id_kobo_library_snapshots_id_fk", + "tableFrom": "kobo_snapshot_books", + "tableTo": "kobo_library_snapshots", + "columnsFrom": ["snapshot_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kobo_snapshot_books_book_id_books_id_fk": { + "name": "kobo_snapshot_books_book_id_books_id_fk", + "tableFrom": "kobo_snapshot_books", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "kobo_snapshot_books_snapshot_id_book_id_pk": { + "name": "kobo_snapshot_books_snapshot_id_book_id_pk", + "columns": ["snapshot_id", "book_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kobo_device_library_snapshots": { + "name": "kobo_device_library_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kobo_device_library_snapshots_user_id_idx": { + "name": "kobo_device_library_snapshots_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kobo_device_library_snapshots_user_id_users_id_fk": { + "name": "kobo_device_library_snapshots_user_id_users_id_fk", + "tableFrom": "kobo_device_library_snapshots", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kobo_device_library_snapshots_device_owner_fk": { + "name": "kobo_device_library_snapshots_device_owner_fk", + "tableFrom": "kobo_device_library_snapshots", + "tableTo": "kobo_devices", + "columnsFrom": ["device_id", "user_id"], + "columnsTo": ["id", "user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kobo_device_library_snapshots_device_id_unique": { + "name": "kobo_device_library_snapshots_device_id_unique", + "nullsNotDistinct": false, + "columns": ["device_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kobo_reading_states": { + "name": "kobo_reading_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at_kobo": { + "name": "created_at_kobo", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "last_modified_kobo": { + "name": "last_modified_kobo", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "priority_timestamp": { + "name": "priority_timestamp", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "current_bookmark": { + "name": "current_bookmark", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "statistics": { + "name": "statistics", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_info": { + "name": "status_info", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kobo_reading_states_book_id_idx": { + "name": "kobo_reading_states_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kobo_reading_states_user_id_users_id_fk": { + "name": "kobo_reading_states_user_id_users_id_fk", + "tableFrom": "kobo_reading_states", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kobo_reading_states_book_id_books_id_fk": { + "name": "kobo_reading_states_book_id_books_id_fk", + "tableFrom": "kobo_reading_states", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kobo_reading_states_user_id_book_id_unique": { + "name": "kobo_reading_states_user_id_book_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "book_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kobo_device_snapshot_books": { + "name": "kobo_device_snapshot_books", + "schema": "", + "columns": { + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "synced": { + "name": "synced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pending_delete": { + "name": "pending_delete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_new": { + "name": "is_new", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "removed_by_device": { + "name": "removed_by_device", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "needs_legacy_numeric_removal": { + "name": "needs_legacy_numeric_removal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "file_hash": { + "name": "file_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "delivery_hash": { + "name": "delivery_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "metadata_hash": { + "name": "metadata_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kobo_device_snapshot_books_book_id_idx": { + "name": "kobo_device_snapshot_books_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kobo_device_snapshot_books_snapshot_synced_book_idx": { + "name": "kobo_device_snapshot_books_snapshot_synced_book_idx", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "synced", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kobo_device_snapshot_books_snapshot_id_kobo_device_library_snapshots_id_fk": { + "name": "kobo_device_snapshot_books_snapshot_id_kobo_device_library_snapshots_id_fk", + "tableFrom": "kobo_device_snapshot_books", + "tableTo": "kobo_device_library_snapshots", + "columnsFrom": ["snapshot_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kobo_device_snapshot_books_book_id_books_id_fk": { + "name": "kobo_device_snapshot_books_book_id_books_id_fk", + "tableFrom": "kobo_device_snapshot_books", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "kobo_device_snapshot_books_snapshot_id_book_id_pk": { + "name": "kobo_device_snapshot_books_snapshot_id_book_id_pk", + "columns": ["snapshot_id", "book_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kobo_sync_history": { + "name": "kobo_sync_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "counts": { + "name": "counts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "error_class": { + "name": "error_class", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kobo_sync_history_user_created_idx": { + "name": "kobo_sync_history_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kobo_sync_history_device_created_idx": { + "name": "kobo_sync_history_device_created_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kobo_sync_history_user_id_users_id_fk": { + "name": "kobo_sync_history_user_id_users_id_fk", + "tableFrom": "kobo_sync_history", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kobo_sync_history_device_id_kobo_devices_id_fk": { + "name": "kobo_sync_history_device_id_kobo_devices_id_fk", + "tableFrom": "kobo_sync_history", + "tableTo": "kobo_devices", + "columnsFrom": ["device_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kobo_sync_history_event_chk": { + "name": "kobo_sync_history_event_chk", + "value": "\"kobo_sync_history\".\"event\" in ('library_sync', 'book_download', 'progress_update', 'annotations_pull', 'annotations_push')" + }, + "kobo_sync_history_status_chk": { + "name": "kobo_sync_history_status_chk", + "value": "\"kobo_sync_history\".\"status\" in ('success', 'failed')" + }, + "kobo_sync_history_duration_nonnegative_chk": { + "name": "kobo_sync_history_duration_nonnegative_chk", + "value": "\"kobo_sync_history\".\"duration_ms\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.kobo_sync_settings": { + "name": "kobo_sync_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reading_threshold": { + "name": "reading_threshold", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "finished_threshold": { + "name": "finished_threshold", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 99 + }, + "convert_to_kepub": { + "name": "convert_to_kepub", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "force_enable_hyphenation": { + "name": "force_enable_hyphenation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "kepub_conversion_limit_mb": { + "name": "kepub_conversion_limit_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "two_way_progress_sync": { + "name": "two_way_progress_sync", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sync_bookorbit_annotations_to_kobo": { + "name": "sync_bookorbit_annotations_to_kobo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kobo_sync_settings_user_id_users_id_fk": { + "name": "kobo_sync_settings_user_id_users_id_fk", + "tableFrom": "kobo_sync_settings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kobo_sync_settings_user_id_unique": { + "name": "kobo_sync_settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": { + "kobo_sync_settings_reading_threshold_range_chk": { + "name": "kobo_sync_settings_reading_threshold_range_chk", + "value": "\"kobo_sync_settings\".\"reading_threshold\" >= 0 and \"kobo_sync_settings\".\"reading_threshold\" <= 100" + }, + "kobo_sync_settings_finished_threshold_range_chk": { + "name": "kobo_sync_settings_finished_threshold_range_chk", + "value": "\"kobo_sync_settings\".\"finished_threshold\" >= 0 and \"kobo_sync_settings\".\"finished_threshold\" <= 100" + }, + "kobo_sync_settings_conversion_limit_nonnegative_chk": { + "name": "kobo_sync_settings_conversion_limit_nonnegative_chk", + "value": "\"kobo_sync_settings\".\"kepub_conversion_limit_mb\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.book_dock_files": { + "name": "book_dock_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "absolute_path": { + "name": "absolute_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "embedded_metadata": { + "name": "embedded_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "selected_metadata": { + "name": "selected_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "fetched_metadata": { + "name": "fetched_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cover_path": { + "name": "cover_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_library_id": { + "name": "target_library_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "target_folder_id": { + "name": "target_folder_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fetched_metadata_sources": { + "name": "fetched_metadata_sources", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_edited_at": { + "name": "metadata_edited_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "book_dock_files_status_idx": { + "name": "book_dock_files_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_dock_files_target_library_id_idx": { + "name": "book_dock_files_target_library_id_idx", + "columns": [ + { + "expression": "target_library_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_dock_files_uploaded_by_idx": { + "name": "book_dock_files_uploaded_by_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_dock_files_target_library_id_libraries_id_fk": { + "name": "book_dock_files_target_library_id_libraries_id_fk", + "tableFrom": "book_dock_files", + "tableTo": "libraries", + "columnsFrom": ["target_library_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "book_dock_files_target_folder_id_library_folders_id_fk": { + "name": "book_dock_files_target_folder_id_library_folders_id_fk", + "tableFrom": "book_dock_files", + "tableTo": "library_folders", + "columnsFrom": ["target_folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "book_dock_files_uploaded_by_users_id_fk": { + "name": "book_dock_files_uploaded_by_users_id_fk", + "tableFrom": "book_dock_files", + "tableTo": "users", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "book_dock_files_absolute_path_unique": { + "name": "book_dock_files_absolute_path_unique", + "nullsNotDistinct": false, + "columns": ["absolute_path"] + } + }, + "policies": {}, + "checkConstraints": { + "book_dock_files_status_chk": { + "name": "book_dock_files_status_chk", + "value": "\"book_dock_files\".\"status\" in ('pending', 'extracting', 'fetching', 'ready', 'error')" + }, + "book_dock_files_confidence_range_chk": { + "name": "book_dock_files_confidence_range_chk", + "value": "\"book_dock_files\".\"confidence\" is null or (\"book_dock_files\".\"confidence\" >= 0 and \"book_dock_files\".\"confidence\" <= 100)" + } + }, + "isRLSEnabled": false + }, + "public.file_write_log": { + "name": "file_write_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_file_id": { + "name": "book_file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "fields_written": { + "name": "fields_written", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "triggered_by": { + "name": "triggered_by", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "written_at": { + "name": "written_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "fwl_book_id_written_at_idx": { + "name": "fwl_book_id_written_at_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "written_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_write_log_book_file_id_idx": { + "name": "file_write_log_book_file_id_idx", + "columns": [ + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "file_write_log_book_id_books_id_fk": { + "name": "file_write_log_book_id_books_id_fk", + "tableFrom": "file_write_log", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_write_log_book_file_id_book_files_id_fk": { + "name": "file_write_log_book_file_id_book_files_id_fk", + "tableFrom": "file_write_log", + "tableTo": "book_files", + "columnsFrom": ["book_file_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "file_write_log_user_id_users_id_fk": { + "name": "file_write_log_user_id_users_id_fk", + "tableFrom": "file_write_log", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "file_write_log_status_chk": { + "name": "file_write_log_status_chk", + "value": "\"file_write_log\".\"status\" in ('success', 'skipped', 'failed')" + }, + "file_write_log_triggered_by_chk": { + "name": "file_write_log_triggered_by_chk", + "value": "\"file_write_log\".\"triggered_by\" in ('auto', 'sync')" + }, + "file_write_log_duration_ms_nonnegative_chk": { + "name": "file_write_log_duration_ms_nonnegative_chk", + "value": "\"file_write_log\".\"duration_ms\" is null or \"file_write_log\".\"duration_ms\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.email_templates": { + "name": "email_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_templates_one_default_per_user_uidx": { + "name": "email_templates_one_default_per_user_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_templates\".\"is_default\" = true and \"email_templates\".\"user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_templates_system_name_unique": { + "name": "email_templates_system_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_templates\".\"user_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_templates_user_id_users_id_fk": { + "name": "email_templates_user_id_users_id_fk", + "tableFrom": "email_templates", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_templates_user_id_name_unique": { + "name": "email_templates_user_id_name_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_providers": { + "name": "email_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_name": { + "name": "from_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "from_address": { + "name": "from_address", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "auth": { + "name": "auth", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "ssl": { + "name": "ssl", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "start_tls": { + "name": "start_tls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_shared": { + "name": "is_shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_system_provider": { + "name": "is_system_provider", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tls_reject_unauthorized": { + "name": "tls_reject_unauthorized", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_providers_one_default_per_user_uidx": { + "name": "email_providers_one_default_per_user_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_providers\".\"is_default\" = true and \"email_providers\".\"user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_providers_one_system_provider_uidx": { + "name": "email_providers_one_system_provider_uidx", + "columns": [ + { + "expression": "is_system_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_providers\".\"is_system_provider\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_providers_user_id_users_id_fk": { + "name": "email_providers_user_id_users_id_fk", + "tableFrom": "email_providers", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_providers_user_id_name_unique": { + "name": "email_providers_user_id_name_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "name"] + }, + "email_providers_id_user_id_unique": { + "name": "email_providers_id_user_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": { + "email_providers_port_range_chk": { + "name": "email_providers_port_range_chk", + "value": "\"email_providers\".\"port\" >= 1 and \"email_providers\".\"port\" <= 65535" + } + }, + "isRLSEnabled": false + }, + "public.email_recipient_group_members": { + "name": "email_recipient_group_members", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_recipient_group_members_user_id_users_id_fk": { + "name": "email_recipient_group_members_user_id_users_id_fk", + "tableFrom": "email_recipient_group_members", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_recipient_group_members_group_id_email_recipient_groups_id_fk": { + "name": "email_recipient_group_members_group_id_email_recipient_groups_id_fk", + "tableFrom": "email_recipient_group_members", + "tableTo": "email_recipient_groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_recipient_group_members_recipient_id_email_recipients_id_fk": { + "name": "email_recipient_group_members_recipient_id_email_recipients_id_fk", + "tableFrom": "email_recipient_group_members", + "tableTo": "email_recipients", + "columnsFrom": ["recipient_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_recipient_group_members_group_user_fk": { + "name": "email_recipient_group_members_group_user_fk", + "tableFrom": "email_recipient_group_members", + "tableTo": "email_recipient_groups", + "columnsFrom": ["group_id", "user_id"], + "columnsTo": ["id", "user_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "email_recipient_group_members_recipient_user_fk": { + "name": "email_recipient_group_members_recipient_user_fk", + "tableFrom": "email_recipient_group_members", + "tableTo": "email_recipients", + "columnsFrom": ["recipient_id", "user_id"], + "columnsTo": ["id", "user_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_recipient_group_members_group_id_recipient_id_pk": { + "name": "email_recipient_group_members_group_id_recipient_id_pk", + "columns": ["group_id", "recipient_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_recipient_groups": { + "name": "email_recipient_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "default_template_id": { + "name": "default_template_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "email_recipient_groups_user_id_users_id_fk": { + "name": "email_recipient_groups_user_id_users_id_fk", + "tableFrom": "email_recipient_groups", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_recipient_groups_default_template_id_email_templates_id_fk": { + "name": "email_recipient_groups_default_template_id_email_templates_id_fk", + "tableFrom": "email_recipient_groups", + "tableTo": "email_templates", + "columnsFrom": ["default_template_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_recipient_groups_user_id_name_unique": { + "name": "email_recipient_groups_user_id_name_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "name"] + }, + "email_recipient_groups_id_user_id_unique": { + "name": "email_recipient_groups_id_user_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_recipients": { + "name": "email_recipients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "device_type": { + "name": "device_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "preferred_format": { + "name": "preferred_format", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "default_template_id": { + "name": "default_template_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_recipients_user_email_lower_uidx": { + "name": "email_recipients_user_email_lower_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_recipients_one_default_per_user_uidx": { + "name": "email_recipients_one_default_per_user_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_recipients\".\"is_default\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_recipients_user_id_users_id_fk": { + "name": "email_recipients_user_id_users_id_fk", + "tableFrom": "email_recipients", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_recipients_default_template_id_email_templates_id_fk": { + "name": "email_recipients_default_template_id_email_templates_id_fk", + "tableFrom": "email_recipients", + "tableTo": "email_templates", + "columnsFrom": ["default_template_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_recipients_user_id_email_unique": { + "name": "email_recipients_user_id_email_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "email"] + }, + "email_recipients_id_user_id_unique": { + "name": "email_recipients_id_user_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": { + "email_recipients_device_type_chk": { + "name": "email_recipients_device_type_chk", + "value": "\"email_recipients\".\"device_type\" is null or \"email_recipients\".\"device_type\" in ('kindle', 'kobo', 'other')" + }, + "email_recipients_preferred_format_chk": { + "name": "email_recipients_preferred_format_chk", + "value": "\"email_recipients\".\"preferred_format\" is null or \"email_recipients\".\"preferred_format\" in ('epub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr')" + } + }, + "isRLSEnabled": false + }, + "public.email_preferences": { + "name": "email_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "default_provider_id": { + "name": "default_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "default_recipient_id": { + "name": "default_recipient_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "default_template_id": { + "name": "default_template_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "email_preferences_user_id_users_id_fk": { + "name": "email_preferences_user_id_users_id_fk", + "tableFrom": "email_preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_preferences_default_provider_id_email_providers_id_fk": { + "name": "email_preferences_default_provider_id_email_providers_id_fk", + "tableFrom": "email_preferences", + "tableTo": "email_providers", + "columnsFrom": ["default_provider_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "email_preferences_default_recipient_id_email_recipients_id_fk": { + "name": "email_preferences_default_recipient_id_email_recipients_id_fk", + "tableFrom": "email_preferences", + "tableTo": "email_recipients", + "columnsFrom": ["default_recipient_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "email_preferences_default_template_id_email_templates_id_fk": { + "name": "email_preferences_default_template_id_email_templates_id_fk", + "tableFrom": "email_preferences", + "tableTo": "email_templates", + "columnsFrom": ["default_template_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "email_preferences_default_recipient_owner_fk": { + "name": "email_preferences_default_recipient_owner_fk", + "tableFrom": "email_preferences", + "tableTo": "email_recipients", + "columnsFrom": ["default_recipient_id", "user_id"], + "columnsTo": ["id", "user_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_preferences_user_id_unique": { + "name": "email_preferences_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_send_log": { + "name": "email_send_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "book_file_id": { + "name": "book_file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "template_id": { + "name": "template_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "to_email": { + "name": "to_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "to_name": { + "name": "to_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_send_log_user_created_at_idx": { + "name": "email_send_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_send_log_book_id_idx": { + "name": "email_send_log_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_send_log_book_file_id_idx": { + "name": "email_send_log_book_file_id_idx", + "columns": [ + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_send_log_created_at_idx": { + "name": "email_send_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_send_log_status_next_retry_idx": { + "name": "email_send_log_status_next_retry_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_retry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_send_log_user_id_users_id_fk": { + "name": "email_send_log_user_id_users_id_fk", + "tableFrom": "email_send_log", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_send_log_book_id_books_id_fk": { + "name": "email_send_log_book_id_books_id_fk", + "tableFrom": "email_send_log", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "email_send_log_book_file_id_book_files_id_fk": { + "name": "email_send_log_book_file_id_book_files_id_fk", + "tableFrom": "email_send_log", + "tableTo": "book_files", + "columnsFrom": ["book_file_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "email_send_log_provider_id_email_providers_id_fk": { + "name": "email_send_log_provider_id_email_providers_id_fk", + "tableFrom": "email_send_log", + "tableTo": "email_providers", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "email_send_log_template_id_email_templates_id_fk": { + "name": "email_send_log_template_id_email_templates_id_fk", + "tableFrom": "email_send_log", + "tableTo": "email_templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_send_log_status_chk": { + "name": "email_send_log_status_chk", + "value": "\"email_send_log\".\"status\" in ('pending', 'sent', 'failed')" + }, + "email_send_log_attempt_count_nonnegative_chk": { + "name": "email_send_log_attempt_count_nonnegative_chk", + "value": "\"email_send_log\".\"attempt_count\" >= 0" + }, + "email_send_log_sent_after_created_chk": { + "name": "email_send_log_sent_after_created_chk", + "value": "\"email_send_log\".\"sent_at\" is null or \"email_send_log\".\"sent_at\" >= \"email_send_log\".\"created_at\"" + } + }, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_url": { + "name": "action_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_user_id_idx": { + "name": "notifications_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_user_unread_idx": { + "name": "notifications_user_unread_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_created_at_idx": { + "name": "notifications_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dismissed_duplicate_pairs": { + "name": "dismissed_duplicate_pairs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "entity_id_a": { + "name": "entity_id_a", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "entity_id_b": { + "name": "entity_id_b", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dismissed_by": { + "name": "dismissed_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dismissed_duplicate_pairs_entity_a_idx": { + "name": "dismissed_duplicate_pairs_entity_a_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id_a", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dismissed_duplicate_pairs_entity_b_idx": { + "name": "dismissed_duplicate_pairs_entity_b_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id_b", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dismissed_duplicate_pairs_dismissed_by_users_id_fk": { + "name": "dismissed_duplicate_pairs_dismissed_by_users_id_fk", + "tableFrom": "dismissed_duplicate_pairs", + "tableTo": "users", + "columnsFrom": ["dismissed_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dismissed_duplicate_pairs_unique": { + "name": "dismissed_duplicate_pairs_unique", + "nullsNotDistinct": false, + "columns": ["entity_type", "entity_id_a", "entity_id_b"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dismissed_inline_duplicate_pairs": { + "name": "dismissed_inline_duplicate_pairs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "value_a": { + "name": "value_a", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "value_b": { + "name": "value_b", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dismissed_by": { + "name": "dismissed_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dismissed_inline_pairs_entity_a_idx": { + "name": "dismissed_inline_pairs_entity_a_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value_a", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dismissed_inline_pairs_entity_b_idx": { + "name": "dismissed_inline_pairs_entity_b_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value_b", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dismissed_inline_duplicate_pairs_dismissed_by_users_id_fk": { + "name": "dismissed_inline_duplicate_pairs_dismissed_by_users_id_fk", + "tableFrom": "dismissed_inline_duplicate_pairs", + "tableTo": "users", + "columnsFrom": ["dismissed_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dismissed_inline_duplicate_pairs_unique": { + "name": "dismissed_inline_duplicate_pairs_unique", + "nullsNotDistinct": false, + "columns": ["entity_type", "value_a", "value_b"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entity_duplicate_candidates": { + "name": "entity_duplicate_candidates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "entity_id_a": { + "name": "entity_id_a", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "entity_id_b": { + "name": "entity_id_b", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sim_score": { + "name": "sim_score", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "entity_dup_candidates_type_sim_idx": { + "name": "entity_dup_candidates_type_sim_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sim_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "entity_dup_candidates_entity_a_idx": { + "name": "entity_dup_candidates_entity_a_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id_a", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "entity_dup_candidates_entity_b_idx": { + "name": "entity_dup_candidates_entity_b_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id_b", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entity_duplicate_candidates_unique": { + "name": "entity_duplicate_candidates_unique", + "nullsNotDistinct": false, + "columns": ["entity_type", "entity_id_a", "entity_id_b"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entity_duplicate_scan_status": { + "name": "entity_duplicate_scan_status", + "schema": "", + "columns": { + "entity_type": { + "name": "entity_type", + "type": "varchar(50)", + "primaryKey": true, + "notNull": true + }, + "is_computing": { + "name": "is_computing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "total_pairs": { + "name": "total_pairs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "threshold": { + "name": "threshold", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_count": { + "name": "total_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_fonts": { + "name": "user_fonts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "family_name": { + "name": "family_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "original_file_name": { + "name": "original_file_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "stored_file_name": { + "name": "stored_file_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 400 + }, + "style": { + "name": "style", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "file_hash": { + "name": "file_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uf_user_hash_uidx": { + "name": "uf_user_hash_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uf_user_family_weight_style_uidx": { + "name": "uf_user_family_weight_style_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "family_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "style", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_fonts_user_id_users_id_fk": { + "name": "user_fonts_user_id_users_id_fk", + "tableFrom": "user_fonts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_fonts_format_chk": { + "name": "user_fonts_format_chk", + "value": "\"user_fonts\".\"format\" in ('ttf', 'otf', 'woff', 'woff2')" + }, + "user_fonts_weight_chk": { + "name": "user_fonts_weight_chk", + "value": "\"user_fonts\".\"weight\" >= 100 and \"user_fonts\".\"weight\" <= 900 and \"user_fonts\".\"weight\" % 100 = 0" + }, + "user_fonts_style_chk": { + "name": "user_fonts_style_chk", + "value": "\"user_fonts\".\"style\" in ('normal', 'italic')" + } + }, + "isRLSEnabled": false + }, + "public.book_file_chapters": { + "name": "book_file_chapters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "book_file_id": { + "name": "book_file_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chapter_index": { + "name": "chapter_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "href": { + "name": "href", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false + }, + "spine_index": { + "name": "spine_index", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "book_file_chapters_book_file_id_chapter_index_uidx": { + "name": "book_file_chapters_book_file_id_chapter_index_uidx", + "columns": [ + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chapter_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_file_chapters_book_file_id_idx": { + "name": "book_file_chapters_book_file_id_idx", + "columns": [ + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_file_chapters_book_file_id_book_files_id_fk": { + "name": "book_file_chapters_book_file_id_book_files_id_fk", + "tableFrom": "book_file_chapters", + "tableTo": "book_files", + "columnsFrom": ["book_file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_file_hash_history": { + "name": "book_file_hash_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "book_file_id": { + "name": "book_file_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "file_hash": { + "name": "file_hash", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "book_file_hash_history_book_file_id_file_hash_idx": { + "name": "book_file_hash_history_book_file_id_file_hash_idx", + "columns": [ + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_file_hash_history_file_hash_idx": { + "name": "book_file_hash_history_file_hash_idx", + "columns": [ + { + "expression": "file_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_file_hash_history_book_file_id_book_files_id_fk": { + "name": "book_file_hash_history_book_file_id_book_files_id_fk", + "tableFrom": "book_file_hash_history", + "tableTo": "book_files", + "columnsFrom": ["book_file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "book_file_hash_history_reason_chk": { + "name": "book_file_hash_history_reason_chk", + "value": "\"book_file_hash_history\".\"reason\" in ('file_write', 'external_change', 'rescan')" + } + }, + "isRLSEnabled": false + }, + "public.koreader_book_hash_links": { + "name": "koreader_book_hash_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "book_file_id": { + "name": "book_file_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "koreader_title": { + "name": "koreader_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "koreader_authors": { + "name": "koreader_authors", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "koreader_last_open": { + "name": "koreader_last_open", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "koreader_book_hash_links_user_hash_uidx": { + "name": "koreader_book_hash_links_user_hash_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "koreader_book_hash_links_user_updated_idx": { + "name": "koreader_book_hash_links_user_updated_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"updated_at\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "koreader_book_hash_links_book_file_id_idx": { + "name": "koreader_book_hash_links_book_file_id_idx", + "columns": [ + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "koreader_book_hash_links_user_id_users_id_fk": { + "name": "koreader_book_hash_links_user_id_users_id_fk", + "tableFrom": "koreader_book_hash_links", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "koreader_book_hash_links_book_file_id_book_files_id_fk": { + "name": "koreader_book_hash_links_book_file_id_book_files_id_fk", + "tableFrom": "koreader_book_hash_links", + "tableTo": "book_files", + "columnsFrom": ["book_file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "koreader_book_hash_links_hash_chk": { + "name": "koreader_book_hash_links_hash_chk", + "value": "\"koreader_book_hash_links\".\"hash\" ~ '^[0-9a-f]{32}$'" + } + }, + "isRLSEnabled": false + }, + "public.koreader_bookmark_links": { + "name": "koreader_bookmark_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "bookmark_id": { + "name": "bookmark_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "koreader_key": { + "name": "koreader_key", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "device_datetime": { + "name": "device_datetime", + "type": "varchar(19)", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "koreader_bookmark_links_bookmark_device_uidx": { + "name": "koreader_bookmark_links_bookmark_device_uidx", + "columns": [ + { + "expression": "bookmark_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "koreader_bookmark_links_user_device_key_idx": { + "name": "koreader_bookmark_links_user_device_key_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "koreader_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "koreader_bookmark_links_user_device_idx": { + "name": "koreader_bookmark_links_user_device_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "koreader_bookmark_links_bookmark_id_idx": { + "name": "koreader_bookmark_links_bookmark_id_idx", + "columns": [ + { + "expression": "bookmark_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "koreader_bookmark_links_bookmark_id_bookmarks_id_fk": { + "name": "koreader_bookmark_links_bookmark_id_bookmarks_id_fk", + "tableFrom": "koreader_bookmark_links", + "tableTo": "bookmarks", + "columnsFrom": ["bookmark_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "koreader_bookmark_links_user_id_users_id_fk": { + "name": "koreader_bookmark_links_user_id_users_id_fk", + "tableFrom": "koreader_bookmark_links", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.koreader_device_progress": { + "name": "koreader_device_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "book_file_id": { + "name": "book_file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device": { + "name": "device", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'KOReader'" + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "percentage": { + "name": "percentage", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "progress": { + "name": "progress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chapter_index": { + "name": "chapter_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sync_timestamp": { + "name": "sync_timestamp", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "orphaned": { + "name": "orphaned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "orphaned_hash": { + "name": "orphaned_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "koreader_device_progress_book_user_device_uidx": { + "name": "koreader_device_progress_book_user_device_uidx", + "columns": [ + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"koreader_device_progress\".\"orphaned\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "koreader_device_progress_orphaned_hash_idx": { + "name": "koreader_device_progress_orphaned_hash_idx", + "columns": [ + { + "expression": "orphaned_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"koreader_device_progress\".\"orphaned\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "koreader_device_progress_user_updated_at_idx": { + "name": "koreader_device_progress_user_updated_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "koreader_device_progress_book_file_id_idx": { + "name": "koreader_device_progress_book_file_id_idx", + "columns": [ + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"koreader_device_progress\".\"book_file_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "koreader_device_progress_book_file_id_book_files_id_fk": { + "name": "koreader_device_progress_book_file_id_book_files_id_fk", + "tableFrom": "koreader_device_progress", + "tableTo": "book_files", + "columnsFrom": ["book_file_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "koreader_device_progress_user_id_users_id_fk": { + "name": "koreader_device_progress_user_id_users_id_fk", + "tableFrom": "koreader_device_progress", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "koreader_device_progress_percentage_range_chk": { + "name": "koreader_device_progress_percentage_range_chk", + "value": "\"koreader_device_progress\".\"percentage\" is null or (\"koreader_device_progress\".\"percentage\" >= 0 and \"koreader_device_progress\".\"percentage\" <= 1)" + } + }, + "isRLSEnabled": false + }, + "public.koreader_device_settings": { + "name": "koreader_device_settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "file_naming_pattern": { + "name": "file_naming_pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "series_file_naming_pattern": { + "name": "series_file_naming_pattern", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "standalone_file_naming_pattern": { + "name": "standalone_file_naming_pattern", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "koreader_device_settings_user_id_idx": { + "name": "koreader_device_settings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "koreader_device_settings_user_id_users_id_fk": { + "name": "koreader_device_settings_user_id_users_id_fk", + "tableFrom": "koreader_device_settings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "koreader_device_settings_user_id_device_id_pk": { + "name": "koreader_device_settings_user_id_device_id_pk", + "columns": ["user_id", "device_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.koreader_device_sweeps": { + "name": "koreader_device_sweeps", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "device_model": { + "name": "device_model", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'KOReader'" + }, + "plugin_version": { + "name": "plugin_version", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "last_sweep_at": { + "name": "last_sweep_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_sweep_books_matched": { + "name": "last_sweep_books_matched", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_sweep_page_stats": { + "name": "last_sweep_page_stats", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_sweep_annotations": { + "name": "last_sweep_annotations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kds_user_id_idx": { + "name": "kds_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "koreader_device_sweeps_user_id_users_id_fk": { + "name": "koreader_device_sweeps_user_id_users_id_fk", + "tableFrom": "koreader_device_sweeps", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "koreader_device_sweeps_user_id_device_id_pk": { + "name": "koreader_device_sweeps_user_id_device_id_pk", + "columns": ["user_id", "device_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.koreader_page_stats": { + "name": "koreader_page_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_file_id": { + "name": "book_file_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "page": { + "name": "page", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_pages": { + "name": "total_pages", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kps_user_file_device_page_start_uidx": { + "name": "kps_user_file_device_page_start_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "page", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "start_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kps_user_file_device_start_idx": { + "name": "kps_user_file_device_start_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "start_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kps_user_id_idx": { + "name": "kps_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "koreader_page_stats_book_file_id_idx": { + "name": "koreader_page_stats_book_file_id_idx", + "columns": [ + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "koreader_page_stats_user_id_users_id_fk": { + "name": "koreader_page_stats_user_id_users_id_fk", + "tableFrom": "koreader_page_stats", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "koreader_page_stats_book_file_id_book_files_id_fk": { + "name": "koreader_page_stats_book_file_id_book_files_id_fk", + "tableFrom": "koreader_page_stats", + "tableTo": "book_files", + "columnsFrom": ["book_file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "koreader_page_stats_duration_nonnegative_chk": { + "name": "koreader_page_stats_duration_nonnegative_chk", + "value": "\"koreader_page_stats\".\"duration_seconds\" >= 0" + }, + "koreader_page_stats_page_nonnegative_chk": { + "name": "koreader_page_stats_page_nonnegative_chk", + "value": "\"koreader_page_stats\".\"page\" >= 0" + }, + "koreader_page_stats_total_pages_positive_chk": { + "name": "koreader_page_stats_total_pages_positive_chk", + "value": "\"koreader_page_stats\".\"total_pages\" > 0" + }, + "koreader_page_stats_start_time_positive_chk": { + "name": "koreader_page_stats_start_time_positive_chk", + "value": "\"koreader_page_stats\".\"start_time\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.koreader_unmatched_book_devices": { + "name": "koreader_unmatched_book_devices", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "koreader_unmatched_book_devices_user_device_idx": { + "name": "koreader_unmatched_book_devices_user_device_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "koreader_unmatched_book_devices_user_id_users_id_fk": { + "name": "koreader_unmatched_book_devices_user_id_users_id_fk", + "tableFrom": "koreader_unmatched_book_devices", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "koreader_unmatched_book_devices_user_hash_fk": { + "name": "koreader_unmatched_book_devices_user_hash_fk", + "tableFrom": "koreader_unmatched_book_devices", + "tableTo": "koreader_unmatched_books", + "columnsFrom": ["user_id", "hash"], + "columnsTo": ["user_id", "hash"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "koreader_unmatched_book_devices_user_id_hash_device_id_pk": { + "name": "koreader_unmatched_book_devices_user_id_hash_device_id_pk", + "columns": ["user_id", "hash", "device_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "koreader_unmatched_book_devices_hash_chk": { + "name": "koreader_unmatched_book_devices_hash_chk", + "value": "\"koreader_unmatched_book_devices\".\"hash\" ~ '^[0-9a-f]{32}$'" + } + }, + "isRLSEnabled": false + }, + "public.koreader_unmatched_books": { + "name": "koreader_unmatched_books", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "authors": { + "name": "authors", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_open": { + "name": "last_open", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'statistics'" + }, + "metadata_ambiguous": { + "name": "metadata_ambiguous", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "koreader_unmatched_books_user_seen_idx": { + "name": "koreader_unmatched_books_user_seen_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"last_seen_at\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "koreader_unmatched_books_user_id_users_id_fk": { + "name": "koreader_unmatched_books_user_id_users_id_fk", + "tableFrom": "koreader_unmatched_books", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "koreader_unmatched_books_user_id_hash_pk": { + "name": "koreader_unmatched_books_user_id_hash_pk", + "columns": ["user_id", "hash"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "koreader_unmatched_books_hash_chk": { + "name": "koreader_unmatched_books_hash_chk", + "value": "\"koreader_unmatched_books\".\"hash\" ~ '^[0-9a-f]{32}$'" + }, + "koreader_unmatched_books_source_chk": { + "name": "koreader_unmatched_books_source_chk", + "value": "\"koreader_unmatched_books\".\"source\" in ('current_file', 'file', 'statistics')" + } + }, + "isRLSEnabled": false + }, + "public.koreader_user_settings": { + "name": "koreader_user_settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "default_file_naming_pattern": { + "name": "default_file_naming_pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "koreader_user_settings_user_id_users_id_fk": { + "name": "koreader_user_settings_user_id_users_id_fk", + "tableFrom": "koreader_user_settings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.koreader_users": { + "name": "koreader_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_md5": { + "name": "password_md5", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "sync_enabled": { + "name": "sync_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "koreader_users_user_id_uidx": { + "name": "koreader_users_user_id_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "koreader_users_username_uidx": { + "name": "koreader_users_username_uidx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "koreader_users_user_id_users_id_fk": { + "name": "koreader_users_user_id_users_id_fk", + "tableFrom": "koreader_users", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.achievements": { + "name": "achievements", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "group_key": { + "name": "group_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "icon_name": { + "name": "icon_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "threshold": { + "name": "threshold", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "hidden": { + "name": "hidden", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "achievements_category_sort_idx": { + "name": "achievements_category_sort_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "achievements_group_key_idx": { + "name": "achievements_group_key_idx", + "columns": [ + { + "expression": "group_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "achievements_category_chk": { + "name": "achievements_category_chk", + "value": "\"achievements\".\"category\" in ('reading', 'library', 'exploration', 'dedication', 'devices')" + }, + "achievements_rarity_chk": { + "name": "achievements_rarity_chk", + "value": "\"achievements\".\"rarity\" in ('common', 'rare', 'epic', 'legendary')" + }, + "achievements_tier_chk": { + "name": "achievements_tier_chk", + "value": "\"achievements\".\"tier\" is null or (\"achievements\".\"tier\" >= 1 and \"achievements\".\"tier\" <= 4)" + } + }, + "isRLSEnabled": false + }, + "public.user_achievements": { + "name": "user_achievements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "achievement_key": { + "name": "achievement_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "awarded_at": { + "name": "awarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "context_json": { + "name": "context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_achievements_user_key_uidx": { + "name": "user_achievements_user_key_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "achievement_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_achievements_user_id_idx": { + "name": "user_achievements_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_achievements_user_awarded_at_idx": { + "name": "user_achievements_user_awarded_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "awarded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_achievements_user_id_users_id_fk": { + "name": "user_achievements_user_id_users_id_fk", + "tableFrom": "user_achievements", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_achievements_achievement_key_achievements_key_fk": { + "name": "user_achievements_achievement_key_achievements_key_fk", + "tableFrom": "user_achievements", + "tableTo": "achievements", + "columnsFrom": ["achievement_key"], + "columnsTo": ["key"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hardcover_book_state": { + "name": "hardcover_book_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "hardcover_book_id": { + "name": "hardcover_book_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "hardcover_edition_id": { + "name": "hardcover_edition_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "hardcover_user_book_id": { + "name": "hardcover_user_book_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "hardcover_read_id": { + "name": "hardcover_read_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "match_method": { + "name": "match_method", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "match_error": { + "name": "match_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_synced_status": { + "name": "last_synced_status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "last_synced_progress": { + "name": "last_synced_progress", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "last_synced_rating": { + "name": "last_synced_rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_synced_started_at": { + "name": "last_synced_started_at", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "last_synced_finished_at": { + "name": "last_synced_finished_at", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_override": { + "name": "sync_override", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "sync_excluded": { + "name": "sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hardcover_book_state_book_id_idx": { + "name": "hardcover_book_state_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hardcover_book_state_user_id_users_id_fk": { + "name": "hardcover_book_state_user_id_users_id_fk", + "tableFrom": "hardcover_book_state", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hardcover_book_state_book_id_books_id_fk": { + "name": "hardcover_book_state_book_id_books_id_fk", + "tableFrom": "hardcover_book_state", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hardcover_book_state_user_book_uidx": { + "name": "hardcover_book_state_user_book_uidx", + "nullsNotDistinct": false, + "columns": ["user_id", "book_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hardcover_user_settings": { + "name": "hardcover_user_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "api_token": { + "name": "api_token", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "book_sync_mode": { + "name": "book_sync_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'all_eligible'" + }, + "auto_sync_on_status_change": { + "name": "auto_sync_on_status_change", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_sync_on_progress_update": { + "name": "auto_sync_on_progress_update", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_sync_on_rating_change": { + "name": "auto_sync_on_rating_change", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "privacy_setting_id": { + "name": "privacy_setting_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hardcover_user_settings_user_id_users_id_fk": { + "name": "hardcover_user_settings_user_id_users_id_fk", + "tableFrom": "hardcover_user_settings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hardcover_user_settings_user_id_unique": { + "name": "hardcover_user_settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.readwise_user_settings": { + "name": "readwise_user_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "api_token": { + "name": "api_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_synced_annotation_id": { + "name": "last_synced_annotation_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "disabled_reason": { + "name": "disabled_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "readwise_user_settings_user_id_users_id_fk": { + "name": "readwise_user_settings_user_id_users_id_fk", + "tableFrom": "readwise_user_settings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "readwise_user_settings_user_id_unique": { + "name": "readwise_user_settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.storygraph_book_state": { + "name": "storygraph_book_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storygraph_book_id": { + "name": "storygraph_book_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "match_method": { + "name": "match_method", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "match_error": { + "name": "match_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_synced_status": { + "name": "last_synced_status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "last_synced_progress": { + "name": "last_synced_progress", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_override": { + "name": "sync_override", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "storygraph_book_state_book_id_idx": { + "name": "storygraph_book_state_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "storygraph_book_state_user_sync_override_idx": { + "name": "storygraph_book_state_user_sync_override_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sync_override", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "storygraph_book_state_user_sync_error_idx": { + "name": "storygraph_book_state_user_sync_error_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"storygraph_book_state\".\"sync_error\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "storygraph_book_state_user_id_users_id_fk": { + "name": "storygraph_book_state_user_id_users_id_fk", + "tableFrom": "storygraph_book_state", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "storygraph_book_state_book_id_books_id_fk": { + "name": "storygraph_book_state_book_id_books_id_fk", + "tableFrom": "storygraph_book_state", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "storygraph_book_state_user_book_uidx": { + "name": "storygraph_book_state_user_book_uidx", + "nullsNotDistinct": false, + "columns": ["user_id", "book_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.storygraph_user_settings": { + "name": "storygraph_user_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "session_cookie": { + "name": "session_cookie", + "type": "varchar(4096)", + "primaryKey": false, + "notNull": true + }, + "remember_token": { + "name": "remember_token", + "type": "varchar(4096)", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "book_sync_mode": { + "name": "book_sync_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'all_eligible'" + }, + "auto_sync_on_status_change": { + "name": "auto_sync_on_status_change", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_sync_on_progress_update": { + "name": "auto_sync_on_progress_update", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "storygraph_user_settings_user_id_users_id_fk": { + "name": "storygraph_user_settings_user_id_users_id_fk", + "tableFrom": "storygraph_user_settings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "storygraph_user_settings_user_id_unique": { + "name": "storygraph_user_settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_reading_insight_view_sessions": { + "name": "shared_reading_insight_view_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "viewer_user_id": { + "name": "viewer_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sharing_level": { + "name": "sharing_level", + "type": "reading_insights_sharing_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shared_insight_view_subject_created_idx": { + "name": "shared_insight_view_subject_created_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shared_insight_view_expires_idx": { + "name": "shared_insight_view_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_reading_insight_view_sessions_viewer_user_id_users_id_fk": { + "name": "shared_reading_insight_view_sessions_viewer_user_id_users_id_fk", + "tableFrom": "shared_reading_insight_view_sessions", + "tableTo": "users", + "columnsFrom": ["viewer_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_reading_insight_view_sessions_subject_user_id_users_id_fk": { + "name": "shared_reading_insight_view_sessions_subject_user_id_users_id_fk", + "tableFrom": "shared_reading_insight_view_sessions", + "tableTo": "users", + "columnsFrom": ["subject_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "up_user_category_idx": { + "name": "up_user_category_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.content_filter_type": { + "name": "content_filter_type", + "schema": "public", + "values": ["include", "exclude"] + }, + "public.library_access_level": { + "name": "library_access_level", + "schema": "public", + "values": ["viewer", "editor", "owner"] + }, + "public.reading_insights_sharing_level": { + "name": "reading_insights_sharing_level", + "schema": "public", + "values": ["private", "summary", "detailed"] + }, + "public.user_avatar_source": { + "name": "user_avatar_source", + "schema": "public", + "values": ["none", "external", "uploaded"] + }, + "public.opds_sort_order": { + "name": "opds_sort_order", + "schema": "public", + "values": ["recent", "title_asc", "title_desc", "author_asc", "author_desc", "series_asc", "series_desc"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/server/src/db/migrations/meta/_journal.json b/server/src/db/migrations/meta/_journal.json index b49fb2a58..92bf1a3fb 100644 --- a/server/src/db/migrations/meta/_journal.json +++ b/server/src/db/migrations/meta/_journal.json @@ -407,6 +407,13 @@ "when": 1785215769241, "tag": "0057_add_kindle_file_write_settings", "breakpoints": true + }, + { + "idx": 58, + "version": "7", + "when": 1785219732532, + "tag": "0058_add_fb2_file_write_settings", + "breakpoints": true } ] } diff --git a/server/src/db/schema/libraries.ts b/server/src/db/schema/libraries.ts index 8a9b5269e..85529804c 100644 --- a/server/src/db/schema/libraries.ts +++ b/server/src/db/schema/libraries.ts @@ -51,6 +51,8 @@ export const libraries = pgTable( fileWriteWriteCover: boolean('file_write_write_cover').notNull().default(true), fileWriteEpubEnabled: boolean('file_write_epub_enabled').notNull().default(true), fileWriteEpubMaxFileSizeMb: integer('file_write_epub_max_file_size_mb').notNull().default(100), + fileWriteFb2Enabled: boolean('file_write_fb2_enabled').notNull().default(false), + fileWriteFb2MaxFileSizeMb: integer('file_write_fb2_max_file_size_mb').notNull().default(100), fileWritePdfEnabled: boolean('file_write_pdf_enabled').notNull().default(true), fileWritePdfMaxFileSizeMb: integer('file_write_pdf_max_file_size_mb').notNull().default(100), fileWriteCbxEnabled: boolean('file_write_cbx_enabled').notNull().default(false), diff --git a/server/src/modules/file-write/file-write.constants.ts b/server/src/modules/file-write/file-write.constants.ts index ae15d22a6..d5ed4cd51 100644 --- a/server/src/modules/file-write/file-write.constants.ts +++ b/server/src/modules/file-write/file-write.constants.ts @@ -2,6 +2,7 @@ import { BOOK_FILE_WRITE_FIELDS } from '@bookorbit/types'; import type { BookWritePayloadKey } from './interfaces/book-write-payload.interface'; export const FORMAT_EPUB = 'epub'; +export const FORMAT_FB2 = 'fb2'; export const FORMAT_PDF = 'pdf'; export const FORMAT_CBZ = 'cbz'; export const FORMAT_CB7 = 'cb7'; diff --git a/server/src/modules/file-write/file-write.module.test.ts b/server/src/modules/file-write/file-write.module.test.ts index a0473c5ff..a0490d9b1 100644 --- a/server/src/modules/file-write/file-write.module.test.ts +++ b/server/src/modules/file-write/file-write.module.test.ts @@ -14,6 +14,7 @@ import { AudioMetadataEmbedder } from './formats/audio/audio-metadata-embedder'; import { Cb7FormatWriter } from './formats/cbx/cb7-format-writer'; import { CbzFormatWriter } from './formats/cbx/cbz-format-writer'; import { EpubFormatWriter } from './formats/epub/epub-format-writer'; +import { Fb2FormatWriter } from './formats/fb2/fb2-format-writer'; import { Azw3FormatWriter, AzwFormatWriter, MobiEbookFormatWriter } from './formats/mobi/mobi-format-writer'; import { PdfFormatWriter } from './formats/pdf/pdf-format-writer'; import { FORMAT_WRITERS } from './interfaces/format-writer.interface'; @@ -34,6 +35,7 @@ describe('FileWriteModule', () => { FileLockService, AudioMetadataEmbedder, EpubFormatWriter, + Fb2FormatWriter, PdfFormatWriter, CbzFormatWriter, Cb7FormatWriter, @@ -58,6 +60,7 @@ describe('FileWriteModule', () => { expect(writerProvider).toBeDefined(); expect(writerProvider.inject).toEqual([ EpubFormatWriter, + Fb2FormatWriter, PdfFormatWriter, CbzFormatWriter, Cb7FormatWriter, @@ -71,6 +74,7 @@ describe('FileWriteModule', () => { ]); const epub = { format: 'epub' }; + const fb2 = { format: 'fb2' }; const pdf = { format: 'pdf' }; const cbz = { format: 'cbz' }; const cb7 = { format: 'cb7' }; @@ -81,8 +85,9 @@ describe('FileWriteModule', () => { const m4a = { format: 'm4a' }; const mp3 = { format: 'mp3' }; const flac = { format: 'flac' }; - expect(writerProvider.useFactory(epub, pdf, cbz, cb7, mobi, azw3, azw, m4b, m4a, mp3, flac)).toEqual([ + expect(writerProvider.useFactory(epub, fb2, pdf, cbz, cb7, mobi, azw3, azw, m4b, m4a, mp3, flac)).toEqual([ epub, + fb2, pdf, cbz, cb7, diff --git a/server/src/modules/file-write/file-write.module.ts b/server/src/modules/file-write/file-write.module.ts index 2a1b8c9c9..d02fb0b88 100644 --- a/server/src/modules/file-write/file-write.module.ts +++ b/server/src/modules/file-write/file-write.module.ts @@ -15,6 +15,7 @@ import { AudioMetadataEmbedder } from './formats/audio/audio-metadata-embedder'; import { Cb7FormatWriter } from './formats/cbx/cb7-format-writer'; import { CbzFormatWriter } from './formats/cbx/cbz-format-writer'; import { EpubFormatWriter } from './formats/epub/epub-format-writer'; +import { Fb2FormatWriter } from './formats/fb2/fb2-format-writer'; import { Azw3FormatWriter, AzwFormatWriter, MobiEbookFormatWriter } from './formats/mobi/mobi-format-writer'; import { PdfFormatWriter } from './formats/pdf/pdf-format-writer'; import { FORMAT_WRITERS } from './interfaces/format-writer.interface'; @@ -30,6 +31,7 @@ import { FORMAT_WRITERS } from './interfaces/format-writer.interface'; BulkRenameRepository, AudioMetadataEmbedder, EpubFormatWriter, + Fb2FormatWriter, PdfFormatWriter, CbzFormatWriter, Cb7FormatWriter, @@ -44,6 +46,7 @@ import { FORMAT_WRITERS } from './interfaces/format-writer.interface'; provide: FORMAT_WRITERS, useFactory: ( epub: EpubFormatWriter, + fb2: Fb2FormatWriter, pdf: PdfFormatWriter, cbz: CbzFormatWriter, cb7: Cb7FormatWriter, @@ -54,9 +57,10 @@ import { FORMAT_WRITERS } from './interfaces/format-writer.interface'; m4a: M4aAudioFormatWriter, mp3: Mp3AudioFormatWriter, flac: FlacAudioFormatWriter, - ) => [epub, pdf, cbz, cb7, mobi, azw3, azw, m4b, m4a, mp3, flac], + ) => [epub, fb2, pdf, cbz, cb7, mobi, azw3, azw, m4b, m4a, mp3, flac], inject: [ EpubFormatWriter, + Fb2FormatWriter, PdfFormatWriter, CbzFormatWriter, Cb7FormatWriter, diff --git a/server/src/modules/file-write/file-write.repository.ts b/server/src/modules/file-write/file-write.repository.ts index 5ff696e3c..881272db2 100644 --- a/server/src/modules/file-write/file-write.repository.ts +++ b/server/src/modules/file-write/file-write.repository.ts @@ -68,6 +68,8 @@ export class FileWriteRepository { fileWriteWriteCover: libraries.fileWriteWriteCover, fileWriteEpubEnabled: libraries.fileWriteEpubEnabled, fileWriteEpubMaxFileSizeMb: libraries.fileWriteEpubMaxFileSizeMb, + fileWriteFb2Enabled: libraries.fileWriteFb2Enabled, + fileWriteFb2MaxFileSizeMb: libraries.fileWriteFb2MaxFileSizeMb, fileWritePdfEnabled: libraries.fileWritePdfEnabled, fileWritePdfMaxFileSizeMb: libraries.fileWritePdfMaxFileSizeMb, fileWriteCbxEnabled: libraries.fileWriteCbxEnabled, diff --git a/server/src/modules/file-write/file-write.service.test.ts b/server/src/modules/file-write/file-write.service.test.ts index 7f7d9eb7d..5985f2858 100644 --- a/server/src/modules/file-write/file-write.service.test.ts +++ b/server/src/modules/file-write/file-write.service.test.ts @@ -1,7 +1,13 @@ import { ConfigService } from '@nestjs/config'; import type { MockedFunction } from 'vitest'; import { readdir, readFile, stat } from 'fs/promises'; -import { AUDIO_BOOK_FILE_WRITE_FIELDS, EPUB_BOOK_FILE_WRITE_FIELDS, MOBI_BOOK_FILE_WRITE_FIELDS, NotificationType } from '@bookorbit/types'; +import { + AUDIO_BOOK_FILE_WRITE_FIELDS, + EPUB_BOOK_FILE_WRITE_FIELDS, + FB2_BOOK_FILE_WRITE_FIELDS, + MOBI_BOOK_FILE_WRITE_FIELDS, + NotificationType, +} from '@bookorbit/types'; import { SelfWriteRegistry } from '../../common/services/self-write-registry.service'; const { computeFileHashMock } = vi.hoisted(() => ({ @@ -35,6 +41,8 @@ const DEFAULT_LIB_CONFIG = { fileWriteWriteCover: true, fileWriteEpubEnabled: true, fileWriteEpubMaxFileSizeMb: 100, + fileWriteFb2Enabled: true, + fileWriteFb2MaxFileSizeMb: 100, fileWritePdfEnabled: true, fileWritePdfMaxFileSizeMb: 100, fileWriteCbxEnabled: true, @@ -177,6 +185,64 @@ describe('FileWriteService', () => { }); }); + it('returns the FB2 field set for fb2 files', () => { + const { service, registry } = makeService(); + registry.supports.mockImplementation((value: string) => value === 'fb2'); + + expect(service.resolveBookFileWriteStatus(DEFAULT_LIB_CONFIG, [{ id: 1, format: 'fb2', sizeBytes: 1024 }], 1)).toEqual({ + enabled: true, + reason: null, + writableFormats: ['fb2'], + writableFields: [...FB2_BOOK_FILE_WRITE_FIELDS], + }); + }); + + it('honours the FB2 enable toggle', () => { + const { service, registry } = makeService(); + registry.supports.mockImplementation((value: string) => value === 'fb2'); + + expect( + service.resolveBookFileWriteStatus({ ...DEFAULT_LIB_CONFIG, fileWriteFb2Enabled: false }, [{ id: 1, format: 'fb2', sizeBytes: 1024 }], 1), + ).toEqual({ + enabled: false, + reason: 'format_disabled', + writableFormats: [], + writableFields: [], + }); + }); + + it('honours the FB2 size limit', () => { + const { service, registry } = makeService(); + registry.supports.mockImplementation((value: string) => value === 'fb2'); + + expect( + service.resolveBookFileWriteStatus( + { ...DEFAULT_LIB_CONFIG, fileWriteFb2MaxFileSizeMb: 1 }, + [{ id: 1, format: 'fb2', sizeBytes: 2 * 1024 * 1024 }], + 1, + ), + ).toEqual({ + enabled: false, + reason: 'file_exceeds_size_limit', + writableFormats: [], + writableFields: [], + }); + }); + + it('drops the cover field for FB2 when the library disables cover writing', () => { + const { service, registry } = makeService(); + registry.supports.mockImplementation((value: string) => value === 'fb2'); + + const status = service.resolveBookFileWriteStatus( + { ...DEFAULT_LIB_CONFIG, fileWriteWriteCover: false }, + [{ id: 1, format: 'fb2', sizeBytes: 1024 }], + 1, + ); + + expect(status.writableFields).not.toContain('coverBytes'); + expect(status.writableFields).toContain('seriesName'); + }); + it.each(['mobi', 'azw3', 'azw'])('returns the MOBI field set for %s files', (format) => { const { service, registry } = makeService(); registry.supports.mockImplementation((value: string) => value === format); diff --git a/server/src/modules/file-write/file-write.service.ts b/server/src/modules/file-write/file-write.service.ts index 4228c65ee..837bd8862 100644 --- a/server/src/modules/file-write/file-write.service.ts +++ b/server/src/modules/file-write/file-write.service.ts @@ -17,6 +17,7 @@ import { FORMAT_CB7, FORMAT_CBZ, FORMAT_EPUB, + FORMAT_FB2, FORMAT_MOBI, FORMAT_PDF, createBookWriteFieldMask, @@ -579,6 +580,8 @@ type LibraryFileWriteConfig = { fileWriteWriteCover: boolean; fileWriteEpubEnabled: boolean; fileWriteEpubMaxFileSizeMb: number; + fileWriteFb2Enabled: boolean; + fileWriteFb2MaxFileSizeMb: number; fileWritePdfEnabled: boolean; fileWritePdfMaxFileSizeMb: number; fileWriteCbxEnabled: boolean; @@ -593,6 +596,8 @@ function resolveFormatSettings(config: LibraryFileWriteConfig, format: string): switch (format) { case FORMAT_EPUB: return { enabled: config.fileWriteEpubEnabled, maxFileSizeBytes: config.fileWriteEpubMaxFileSizeMb * 1024 * 1024 }; + case FORMAT_FB2: + return { enabled: config.fileWriteFb2Enabled, maxFileSizeBytes: config.fileWriteFb2MaxFileSizeMb * 1024 * 1024 }; case FORMAT_PDF: return { enabled: config.fileWritePdfEnabled, maxFileSizeBytes: config.fileWritePdfMaxFileSizeMb * 1024 * 1024 }; case FORMAT_CBZ: @@ -658,6 +663,8 @@ function isCompleteLibraryFileWriteConfig(config: FileWriteCapabilityLibraryConf typeof config.fileWriteWriteCover === 'boolean' && typeof config.fileWriteEpubEnabled === 'boolean' && typeof config.fileWriteEpubMaxFileSizeMb === 'number' && + typeof config.fileWriteFb2Enabled === 'boolean' && + typeof config.fileWriteFb2MaxFileSizeMb === 'number' && typeof config.fileWritePdfEnabled === 'boolean' && typeof config.fileWritePdfMaxFileSizeMb === 'number' && typeof config.fileWriteCbxEnabled === 'boolean' && diff --git a/server/src/modules/file-write/formats/epub/epub-cover-image.ts b/server/src/modules/file-write/formats/epub/epub-cover-image.ts index 05cc2dba3..fd64ba834 100644 --- a/server/src/modules/file-write/formats/epub/epub-cover-image.ts +++ b/server/src/modules/file-write/formats/epub/epub-cover-image.ts @@ -1,34 +1,2 @@ -import sharp from 'sharp'; - -const FORMAT_DETAILS = { - jpeg: { mediaType: 'image/jpeg', extension: 'jpg' }, - png: { mediaType: 'image/png', extension: 'png' }, - gif: { mediaType: 'image/gif', extension: 'gif' }, - webp: { mediaType: 'image/webp', extension: 'webp' }, - tiff: { mediaType: 'image/tiff', extension: 'tiff' }, - svg: { mediaType: 'image/svg+xml', extension: 'svg' }, - jp2: { mediaType: 'image/jp2', extension: 'jp2' }, - jxl: { mediaType: 'image/jxl', extension: 'jxl' }, -} as const; - -export interface EpubCoverImage { - mediaType: string; - extension: string; -} - -export async function inspectCoverImage(bytes: Buffer): Promise { - const metadata = await sharp(bytes, { failOn: 'error' }).metadata(); - const knownFormat = metadata.format ? FORMAT_DETAILS[metadata.format as keyof typeof FORMAT_DETAILS] : undefined; - if (knownFormat) return knownFormat; - - if (metadata.format === 'heif') { - return metadata.compression === 'av1' ? { mediaType: 'image/avif', extension: 'avif' } : { mediaType: 'image/heic', extension: 'heic' }; - } - - if (isBitmap(bytes)) return { mediaType: 'image/bmp', extension: 'bmp' }; - throw new Error(`Unsupported EPUB cover image format: ${metadata.format || 'unknown'}`); -} - -function isBitmap(bytes: Buffer): boolean { - return bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d; -} +export type { CoverImageInfo as EpubCoverImage } from '../shared/cover-image'; +export { inspectCoverImage } from '../shared/cover-image'; diff --git a/server/src/modules/file-write/formats/fb2/fb2-binary-locator.test.ts b/server/src/modules/file-write/formats/fb2/fb2-binary-locator.test.ts new file mode 100644 index 000000000..f352ca5e5 --- /dev/null +++ b/server/src/modules/file-write/formats/fb2/fb2-binary-locator.test.ts @@ -0,0 +1,162 @@ +import { mkdtemp, open, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { buildBinaryElement, findBinarySpan, findRootCloseOffset } from './fb2-binary-locator'; + +const CHUNK_BYTES = 256 * 1024; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'fb2-binary-')); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +async function withFile(content: string, run: (handle: Awaited>, size: number) => Promise): Promise { + const path = join(dir, 'book.fb2'); + const bytes = Buffer.from(content, 'latin1'); + await writeFile(path, bytes); + const handle = await open(path, 'r'); + try { + return await run(handle, bytes.length); + } finally { + await handle.close(); + } +} + +async function spanOf(content: string, id: string, from = 0): Promise { + return withFile(content, async (handle, size) => { + const span = await findBinarySpan(handle, id, from, size); + return span ? content.slice(span.start, span.end) : null; + }); +} + +describe('findBinarySpan', () => { + it('finds a binary element by id', async () => { + const content = 'AAA'; + expect(await spanOf(content, 'cover.jpg')).toBe('AAA'); + }); + + it('finds a binary whose attributes are in the opposite order', async () => { + const content = 'AAA'; + expect(await spanOf(content, 'cover.jpg')).toBe(content); + }); + + it('finds a binary with a single-quoted id', async () => { + const content = "AAA"; + expect(await spanOf(content, 'cover.jpg')).toBe(content); + }); + + it('finds an id containing regex special characters', async () => { + const content = 'AAA'; + expect(await spanOf(content, 'cover(1).jpg')).toBe(content); + }); + + it('picks the requested binary out of several', async () => { + const content = + 'ONE\n' + + 'TWO\n' + + 'THREE'; + expect(await spanOf(content, 'cover.jpg')).toContain('TWO'); + expect(await spanOf(content, 'img_2')).toContain('THREE'); + }); + + it('returns null when no binary carries the id', async () => { + const content = 'AAA'; + expect(await spanOf(content, 'cover.jpg')).toBeNull(); + }); + + it('returns null for a file with no binaries at all', async () => { + expect(await spanOf('', 'cover.jpg')).toBeNull(); + }); + + it('ignores binaries before the requested start offset', async () => { + const first = 'EARLY'; + const content = `${first}LATE`; + expect(await spanOf(content, 'cover.jpg', first.length)).toContain('LATE'); + }); + + it('finds a start tag that straddles a chunk boundary', async () => { + const tag = ''; + // Place the tag so its closing '>' falls into the next chunk read. + const padding = 'x'.repeat(CHUNK_BYTES - 3); + const content = `${padding}${tag}PAYLOAD`; + expect(await spanOf(content, 'cover.jpg')).toBe(`${tag}PAYLOAD`); + }); + + it('finds a closing tag several chunks after the start tag', async () => { + const payload = 'A'.repeat(CHUNK_BYTES * 3); + const content = `${payload}`; + const span = await spanOf(content, 'cover.jpg'); + expect(span?.startsWith('')).toBe(true); + expect(span?.endsWith('')).toBe(true); + expect(span).toHaveLength(''.length + payload.length + ''.length); + }); + + it('finds a binary that sits past several megabytes of body text', async () => { + const body = `

${'word '.repeat(400_000)}

`; + const content = `${body}PAYLOAD`; + expect(await spanOf(content, 'cover.jpg')).toBe('PAYLOAD'); + }); + + it('does not match a different id that shares a prefix', async () => { + const content = 'AAA'; + expect(await spanOf(content, 'cover.jpg')).toBeNull(); + }); +}); + +describe('findRootCloseOffset', () => { + it('finds the offset of the root closing tag', async () => { + const content = '\n'; + const offset = await withFile(content, (handle, size) => findRootCloseOffset(handle, size)); + expect(offset).toBe(content.indexOf('')); + }); + + it('finds the root close after a large trailing binary', async () => { + const content = `${'A'.repeat(CHUNK_BYTES * 2)}\n\n`; + const offset = await withFile(content, (handle, size) => findRootCloseOffset(handle, size)); + expect(offset).toBe(content.lastIndexOf('')); + }); + + it('uses the last root close when the text mentions an earlier one', async () => { + const content = '

ends with </FictionBook>

'; + const offset = await withFile(content, (handle, size) => findRootCloseOffset(handle, size)); + expect(offset).toBe(content.lastIndexOf('')); + }); + + it('returns null when there is no root closing tag', async () => { + const offset = await withFile('', (handle, size) => findRootCloseOffset(handle, size)); + expect(offset).toBeNull(); + }); +}); + +describe('buildBinaryElement', () => { + it('emits base64 content with the given id and content type', () => { + const element = buildBinaryElement('cover.jpg', 'image/png', Buffer.from('hello')); + expect(element).toBe('aGVsbG8='); + }); + + it('wraps long base64 at 76 columns without a trailing newline', () => { + const element = buildBinaryElement('c', 'image/jpeg', Buffer.alloc(600, 7)); + const body = element.slice(element.indexOf('>') + 1, element.lastIndexOf('')); + const lines = body.split('\n'); + expect(lines.length).toBeGreaterThan(1); + expect(lines.every((line) => line.length <= 76)).toBe(true); + expect(body.endsWith('\n')).toBe(false); + }); + + it('round-trips the original bytes through base64', () => { + const bytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); + const element = buildBinaryElement('c', 'image/jpeg', bytes); + const body = element.slice(element.indexOf('>') + 1, element.lastIndexOf('')); + expect(Buffer.from(body.replace(/\s+/g, ''), 'base64').equals(bytes)).toBe(true); + }); + + it('escapes an id that contains markup characters', () => { + expect(buildBinaryElement('a"b&c elements after the book body, and a + * single cover can be hundreds of kilobytes. These helpers locate spans by + * scanning the file in fixed-size chunks so replacing a cover never needs the + * whole document in memory. + */ + +const CHUNK_BYTES = 256 * 1024; +const BINARY_OPEN = ' start tag that straddles + * a boundary appears whole in the following chunk. Re-seeing an element in the + * overlap is harmless: a non-matching id is skipped either way. + */ +const OVERLAP_BYTES = 1024; + +export type ByteSpan = { start: number; end: number }; + +/** + * Finds the full span of the element carrying the given id, searching + * from `from` to the end of the file. Returns null when no such element exists. + */ +export async function findBinarySpan(handle: FileHandle, id: string, from: number, fileSize: number): Promise { + const buffer = Buffer.alloc(Math.min(CHUNK_BYTES, Math.max(fileSize - from, 1))); + let position = from; + let carry = ''; + let carryStart = from; + let openAt: number | null = null; + + while (position < fileSize) { + const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, fileSize - position), position); + if (bytesRead === 0) break; + + // Markup is ASCII in every encoding this writer accepts, so latin1 keeps one + // character per byte and offsets stay byte-accurate. + const chunk = carry + buffer.subarray(0, bytesRead).toString('latin1'); + const chunkStart = carryStart; + const isLastChunk = position + bytesRead >= fileSize; + let cursor = 0; + + while (cursor < chunk.length) { + if (openAt === null) { + const at = chunk.indexOf(BINARY_OPEN, cursor); + if (at < 0) break; + const tagEnd = chunk.indexOf('>', at); + if (tagEnd < 0) break; + if (readAttribute(chunk.slice(at, tagEnd + 1), 'id') === id) { + openAt = chunkStart + at; + } + cursor = tagEnd + 1; + continue; + } + + const closeAt = chunk.indexOf(BINARY_CLOSE, cursor); + if (closeAt < 0) break; + return { start: openAt, end: chunkStart + closeAt + BINARY_CLOSE.length }; + } + + if (isLastChunk) break; + position += bytesRead; + carry = chunk.slice(Math.max(0, chunk.length - OVERLAP_BYTES)); + carryStart = position - carry.length; + } + + return null; +} + +/** Finds the offset of the root closing tag, scanning backwards from the end. */ +export async function findRootCloseOffset(handle: FileHandle, fileSize: number): Promise { + const window = Math.min(CHUNK_BYTES, fileSize); + const buffer = Buffer.alloc(Math.max(window, 1)); + const windowStart = Math.max(0, fileSize - window); + const { bytesRead } = await handle.read(buffer, 0, window, windowStart); + const at = buffer.subarray(0, bytesRead).toString('latin1').lastIndexOf(ROOT_CLOSE); + if (at < 0) return null; + return windowStart + at; +} + +/** Serializes cover bytes as a base64 element, wrapped at 76 columns. */ +export function buildBinaryElement(id: string, contentType: string, bytes: Buffer): string { + const base64 = bytes + .toString('base64') + .replace(/(.{76})/g, '$1\n') + .replace(/\n$/, ''); + const escapedId = id.replace(/&/g, '&').replace(/${base64}`; +} diff --git a/server/src/modules/file-write/formats/fb2/fb2-description-builder.test.ts b/server/src/modules/file-write/formats/fb2/fb2-description-builder.test.ts new file mode 100644 index 000000000..662b72449 --- /dev/null +++ b/server/src/modules/file-write/formats/fb2/fb2-description-builder.test.ts @@ -0,0 +1,456 @@ +import { FB2_BOOK_FILE_WRITE_FIELDS } from '@bookorbit/types'; + +import type { BookWritePayload, BookWritePayloadKey } from '../../interfaces/book-write-payload.interface'; +import { buildFb2Description, readCoverEntryId } from './fb2-description-builder'; +import { locateElement, scanChildElements } from './fb2-element-scanner'; + +const FULL_MASK = new Set(FB2_BOOK_FILE_WRITE_FIELDS); + +function description(titleInfo: string, extra = ''): string { + return `\n \n${titleInfo}\n \n${extra}`; +} + +const MINIMAL = description(' sf\n Old Title\n en'); + +function build( + xml: string, + payload: BookWritePayload, + mask: Set = FULL_MASK, + options: { xlinkPrefix?: string; coverEntryId?: string | null } = {}, +) { + const result = buildFb2Description(xml, payload, mask, { xlinkPrefix: options.xlinkPrefix ?? 'l', coverEntryId: options.coverEntryId ?? null }); + if (result.status !== 'built') throw new Error(`expected a built description, got skipped: ${result.reason}`); + return result; +} + +function maskOf(...fields: BookWritePayloadKey[]): Set { + return new Set(fields); +} + +function childOrder(xml: string, parent: string): string[] { + const element = locateElement(xml, parent); + if (!element || element.selfClosing) throw new Error(`missing <${parent}>`); + return scanChildElements(xml, element.contentStart, element.contentEnd).map((child) => child.name); +} + +describe('buildFb2Description field mapping', () => { + it('writes the title into book-title', () => { + const { descriptionXml, fieldsWritten } = build(MINIMAL, { title: 'New Title' }); + expect(descriptionXml).toContain('New Title'); + expect(descriptionXml).not.toContain('Old Title'); + expect(fieldsWritten).toEqual(['title']); + }); + + it('writes the language into lang', () => { + expect(build(MINIMAL, { language: 'ru' }).descriptionXml).toContain('ru'); + }); + + it('writes genres as one genre element each, replacing the originals', () => { + const { descriptionXml, fieldsWritten } = build(MINIMAL, { genres: ['Fantasy', 'Epic'] }); + expect(descriptionXml).toContain('Fantasy'); + expect(descriptionXml).toContain('Epic'); + expect(descriptionXml).not.toContain('sf'); + expect(fieldsWritten).toEqual(['genres']); + }); + + it('writes tags into keywords as a comma separated list', () => { + expect(build(MINIMAL, { tags: ['favourite', 'to-reread'] }).descriptionXml).toContain('favourite, to-reread'); + }); + + it('writes the description into an annotation with one paragraph per blank line', () => { + const { descriptionXml } = build(MINIMAL, { description: 'First para.\n\nSecond para.' }); + expect(descriptionXml).toContain('

First para.

Second para.

'); + }); + + it('collapses newlines inside a paragraph', () => { + const { descriptionXml } = build(MINIMAL, { description: 'One\nline\nwrapped' }); + expect(descriptionXml).toContain('

One line wrapped

'); + }); + + it('writes an iso published date to title-info date with a value attribute', () => { + const { descriptionXml, fieldsWritten } = build(MINIMAL, { publishedDate: '2018-03-14' }); + expect(descriptionXml).toContain('2018-03-14'); + expect(descriptionXml).toContain('2018'); + expect(fieldsWritten).toEqual(['publishedDate']); + }); + + it('writes a bare year when only publishedYear is known', () => { + const { descriptionXml, fieldsWritten } = build(MINIMAL, { publishedYear: 1999 }); + expect(descriptionXml).toContain('1999'); + expect(descriptionXml).toContain('1999'); + expect(fieldsWritten).toEqual(['publishedYear']); + }); + + it('ignores a non-iso published date for the date element but still writes the year', () => { + const { descriptionXml } = build(MINIMAL, { publishedDate: '2018' }); + expect(descriptionXml).not.toContain('2018'); + }); + + it('writes publisher and isbn13 into publish-info', () => { + const { descriptionXml, fieldsWritten } = build(MINIMAL, { publisher: 'Pub & Co', isbn13: '9780000000001' }); + expect(descriptionXml).toContain('Pub & Co'); + expect(descriptionXml).toContain('9780000000001'); + expect(fieldsWritten).toEqual(expect.arrayContaining(['publisher', 'isbn13'])); + }); + + it('creates publish-info when the source document has none', () => { + expect(MINIMAL).not.toContain('publish-info'); + const { descriptionXml } = build(MINIMAL, { publisher: 'New Publisher' }); + expect(descriptionXml).toContain(''); + expect(descriptionXml).toContain(''); + }); + + it('writes series name and index into a sequence element', () => { + const { descriptionXml, fieldsWritten } = build(MINIMAL, { seriesName: 'My "Series"', seriesIndex: 3 }); + expect(descriptionXml).toContain(''); + expect(fieldsWritten).toEqual(expect.arrayContaining(['seriesName', 'seriesIndex'])); + }); + + it('keeps a fractional series index, which real readers accept', () => { + const { descriptionXml, fieldsWritten } = build(MINIMAL, { seriesName: 'S', seriesIndex: 1.5 }); + expect(descriptionXml).toContain('number="1.5"'); + expect(fieldsWritten).toContain('seriesIndex'); + }); + + it('writes the sequence without a number when no index is known', () => { + const { descriptionXml, fieldsWritten } = build(MINIMAL, { seriesName: 'S', seriesIndex: null }); + expect(descriptionXml).toContain(''); + expect(fieldsWritten).not.toContain('seriesIndex'); + }); + + it('writes fields without an FB2 slot into namespaced custom-info', () => { + const { descriptionXml, fieldsWritten } = build(MINIMAL, { + subtitle: 'A Subtitle', + pageCount: 412, + rating: 4.5, + isbn10: '1729419001', + goodreadsId: '12345', + }); + expect(descriptionXml).toContain('A Subtitle'); + expect(descriptionXml).toContain('412'); + expect(descriptionXml).toContain('4.5'); + expect(descriptionXml).toContain('1729419001'); + expect(descriptionXml).toContain('12345'); + expect(fieldsWritten).toEqual(expect.arrayContaining(['subtitle', 'pageCount', 'rating', 'isbn10', 'goodreadsId'])); + }); + + it('writes custom metadata entries under a custom prefix', () => { + const { descriptionXml } = build(MINIMAL, { + customMetadata: [{ fieldId: 1, key: 'shelf', label: 'Shelf', type: 'text', displayOrder: 0, value: 'Attic' }], + }); + expect(descriptionXml).toContain('Attic'); + }); + + it('skips custom metadata entries with no value', () => { + const { descriptionXml } = build(MINIMAL, { + customMetadata: [{ fieldId: 1, key: 'shelf', label: 'Shelf', type: 'text', displayOrder: 0, value: null }], + }); + expect(descriptionXml).not.toContain('bookorbit:custom:shelf'); + }); +}); + +describe('buildFb2Description authors', () => { + it('splits a sort name into first, middle and last names', () => { + const { descriptionXml } = build(MINIMAL, { authors: [{ name: 'James David Victor', sortName: 'Victor, James David' }] }); + expect(descriptionXml).toContain('JamesDavidVictor'); + }); + + it('falls back to the display name when there is no sort name', () => { + const { descriptionXml } = build(MINIMAL, { authors: [{ name: 'Cat Bruno', sortName: null }] }); + expect(descriptionXml).toContain('CatBruno'); + }); + + it('writes a single-word author as a nickname, which FB2 allows', () => { + const { descriptionXml } = build(MINIMAL, { authors: [{ name: 'Prince', sortName: null }] }); + expect(descriptionXml).toContain('Prince'); + }); + + it('writes every author in payload order', () => { + const { descriptionXml } = build(MINIMAL, { + authors: [ + { name: 'Chuck DeVore', sortName: 'DeVore, Chuck' }, + { name: 'Steven Mosher', sortName: 'Mosher, Steven' }, + ], + }); + expect(descriptionXml.indexOf('DeVore')).toBeLessThan(descriptionXml.indexOf('Mosher')); + expect(descriptionXml.match(//g)).toHaveLength(2); + }); + + it('drops blank authors instead of emitting an empty author element', () => { + const { descriptionXml, fieldsWritten } = build(MINIMAL, { + authors: [ + { name: ' ', sortName: null }, + { name: 'Real Person', sortName: null }, + ], + }); + expect(descriptionXml).not.toContain(''); + expect(descriptionXml.match(//g)).toHaveLength(1); + expect(fieldsWritten).toContain('authors'); + }); + + it('reports nothing written when every author is blank', () => { + const { descriptionXml, fieldsWritten } = build(MINIMAL, { authors: [{ name: '', sortName: null }] }); + expect(fieldsWritten).not.toContain('authors'); + expect(descriptionXml).not.toContain(''); + }); + + it('ignores a sort name with an empty surname', () => { + const { descriptionXml } = build(MINIMAL, { authors: [{ name: 'Ada Lovelace', sortName: ', Ada' }] }); + expect(descriptionXml).toContain('AdaLovelace'); + }); +}); + +describe('buildFb2Description escaping', () => { + it('escapes xml significant characters in text', () => { + const { descriptionXml } = build(MINIMAL, { title: 'A & B < C > D' }); + expect(descriptionXml).toContain('A & B < C > D'); + }); + + it('escapes quotes in attribute values', () => { + const { descriptionXml } = build(MINIMAL, { seriesName: 'He said "hi" & left' }); + expect(descriptionXml).toContain('name="He said "hi" & left"'); + }); + + it('keeps non-ascii characters as-is for the encoder to handle', () => { + const { descriptionXml } = build(MINIMAL, { title: 'Мастер и Маргарита' }); + expect(descriptionXml).toContain('Мастер и Маргарита'); + }); +}); + +describe('buildFb2Description structure', () => { + it('emits title-info children in schema order even when the source is out of order', () => { + const outOfOrder = description(' T\n en\n antique'); + const { descriptionXml } = build(outOfOrder, { + title: 'T2', + authors: [{ name: 'A B', sortName: 'B, A' }], + description: 'Note.', + tags: ['x'], + publishedDate: '2001-02-03', + seriesName: 'S', + seriesIndex: 1, + }); + expect(childOrder(descriptionXml, 'title-info')).toEqual(['genre', 'author', 'book-title', 'annotation', 'keywords', 'date', 'lang', 'sequence']); + }); + + it('emits description children in schema order', () => { + const xml = description( + ' sf\n T', + ' x\n 2001\n abc\n', + ); + const { descriptionXml } = build(xml, { title: 'T2' }); + expect(childOrder(descriptionXml, 'description')).toEqual(['title-info', 'document-info', 'publish-info', 'custom-info']); + }); + + it('preserves document-info byte for byte', () => { + const documentInfo = + ' abc-1231.0]]>\n'; + const xml = description(' sf\n T', documentInfo); + const { descriptionXml } = build(xml, { title: 'T2' }); + expect(descriptionXml).toContain(documentInfo.trim()); + }); + + it('preserves src-title-info and unknown description children', () => { + const xml = description( + ' sf\n T', + ' Original\n \n', + ); + const { descriptionXml } = build(xml, { title: 'T2' }); + expect(descriptionXml).toContain('Original'); + expect(descriptionXml).toContain(''); + }); + + it('preserves foreign custom-info but replaces our own', () => { + const xml = description( + ' sf\n T', + ' keep-me\n 1\n', + ); + const { descriptionXml } = build(xml, { rating: 5 }); + expect(descriptionXml).toContain('keep-me'); + expect(descriptionXml).toContain('5'); + expect(descriptionXml).not.toContain('>1
'); + }); + + it('preserves publish-info elements it does not manage', () => { + const xml = description( + ' sf\n T', + ' Publisher TitleNew York1975\n', + ); + const { descriptionXml } = build(xml, { publisher: 'New Pub' }); + expect(descriptionXml).toContain('Publisher Title'); + expect(descriptionXml).toContain('New York'); + expect(descriptionXml).toContain('1975'); + expect(descriptionXml).toContain('New Pub'); + }); + + it('preserves attributes on the description and title-info start tags', () => { + const xml = '\n \n sf\n \n'; + const { descriptionXml } = build(xml, { title: 'T' }); + expect(descriptionXml.startsWith('')).toBe(true); + expect(descriptionXml).toContain(''); + }); + + it('preserves the crlf line style of the source block', () => { + const xml = MINIMAL.replace(/\n/g, '\r\n'); + const { descriptionXml } = build(xml, { title: 'T' }); + expect(descriptionXml).toContain('\r\n'); + expect(descriptionXml.replace(/\r\n/g, '')).not.toContain('\n'); + }); + + it('keeps the indentation style of the source block', () => { + const xml = '\n\t\n\t\tsf\n\t\n'; + const { descriptionXml } = build(xml, { title: 'T' }); + expect(descriptionXml).toContain('\n\t'); + expect(descriptionXml).toContain('\n\t\tT'); + }); + + it('falls back to two-space indentation when the block is on one line', () => { + const xml = 'sfT'; + const { descriptionXml } = build(xml, { title: 'T2' }); + expect(descriptionXml).toContain('\n '); + expect(descriptionXml).toContain('\n T2'); + expect(descriptionXml).not.toContain(''); + }); + + it('is idempotent: rebuilding its own output changes nothing', () => { + const payload: BookWritePayload = { + title: 'Stable', + authors: [{ name: 'A B', sortName: 'B, A' }], + genres: ['Fantasy'], + tags: ['x'], + language: 'en', + publishedDate: '2020-01-02', + publisher: 'Pub', + isbn13: '9780000000001', + seriesName: 'S', + seriesIndex: 2, + rating: 4, + description: 'Note.', + }; + const first = build(MINIMAL, payload).descriptionXml; + const second = build(first, payload).descriptionXml; + expect(second).toBe(first); + }); +}); + +describe('buildFb2Description masking', () => { + it('writes only fields present in the mask', () => { + const { descriptionXml, fieldsWritten } = build(MINIMAL, { title: 'New', language: 'de' }, maskOf('title')); + expect(descriptionXml).toContain('New'); + expect(descriptionXml).toContain('en'); + expect(fieldsWritten).toEqual(['title']); + }); + + it('ignores fields FB2 cannot represent even when masked in', () => { + const { fieldsWritten } = build( + MINIMAL, + { comicIssueNumber: '3', narrators: ['Reader'] }, + new Set(['comicIssueNumber', 'narrators'] as BookWritePayloadKey[]), + ); + expect(fieldsWritten).toEqual([]); + }); + + it('leaves existing values untouched for null payload fields', () => { + const { descriptionXml, fieldsWritten } = build(MINIMAL, { title: null, language: undefined }); + expect(descriptionXml).toContain('Old Title'); + expect(descriptionXml).toContain('en'); + expect(fieldsWritten).toEqual([]); + }); + + it('treats blank strings as no value', () => { + const { descriptionXml, fieldsWritten } = build(MINIMAL, { title: ' ' }); + expect(descriptionXml).toContain('Old Title'); + expect(fieldsWritten).toEqual([]); + }); + + it('treats empty arrays as no value', () => { + const { descriptionXml, fieldsWritten } = build(MINIMAL, { genres: [], tags: [] }); + expect(descriptionXml).toContain('sf'); + expect(fieldsWritten).toEqual([]); + }); + + it('trims values before writing them', () => { + expect(build(MINIMAL, { title: ' Trimmed ' }).descriptionXml).toContain('Trimmed'); + }); +}); + +describe('buildFb2Description coverpage', () => { + it('adds a coverpage pointing at the requested binary id', () => { + const { descriptionXml } = build(MINIMAL, {}, FULL_MASK, { coverEntryId: 'cover.jpg' }); + expect(descriptionXml).toContain(''); + }); + + it('uses the document xlink prefix', () => { + const { descriptionXml } = build(MINIMAL, {}, FULL_MASK, { coverEntryId: 'c.jpg', xlinkPrefix: 'xlink' }); + expect(descriptionXml).toContain(''); + }); + + it('replaces an existing coverpage rather than adding a second one', () => { + const xml = description(' sf\n \n en'); + const { descriptionXml } = build(xml, {}, FULL_MASK, { coverEntryId: 'new.jpg' }); + expect(descriptionXml.match(//g)).toHaveLength(1); + expect(descriptionXml).toContain('#new.jpg'); + expect(descriptionXml).not.toContain('#old.jpg'); + }); + + it('leaves the coverpage alone when no cover is being written', () => { + const xml = description(' sf\n '); + expect(build(xml, { title: 'T' }).descriptionXml).toContain(''); + }); +}); + +describe('buildFb2Description refusals', () => { + it('skips a description without title-info', () => { + const result = buildFb2Description('1', { title: 'T' }, FULL_MASK, { + xlinkPrefix: 'l', + }); + expect(result).toEqual({ status: 'skipped', reason: 'description has no title-info' }); + }); + + it('skips a self-closing description', () => { + const result = buildFb2Description('', { title: 'T' }, FULL_MASK, { xlinkPrefix: 'l' }); + expect(result.status).toBe('skipped'); + }); + + it('skips an unclosed description instead of throwing', () => { + const result = buildFb2Description('T', { title: 'T' }, FULL_MASK, { xlinkPrefix: 'l' }); + expect(result).toMatchObject({ status: 'skipped' }); + expect(result.status === 'skipped' && result.reason).toMatch(/unclosed/); + }); +}); + +describe('readCoverEntryId', () => { + it('reads the id from a coverpage href', () => { + const xml = description(' '); + expect(readCoverEntryId(xml, 'l')).toBe('img_0'); + }); + + it('reads a single-quoted href', () => { + const xml = description(" "); + expect(readCoverEntryId(xml, 'l')).toBe('cover(1).jpg'); + }); + + it('reads an href written with a different prefix than the document declares', () => { + const xml = description(' '); + expect(readCoverEntryId(xml, 'l')).toBe('c.jpg'); + }); + + it('reads an unprefixed href', () => { + const xml = description(' '); + expect(readCoverEntryId(xml, 'l')).toBe('c.jpg'); + }); + + it('returns the first image when a coverpage lists several', () => { + const xml = description(' '); + expect(readCoverEntryId(xml, 'l')).toBe('first.jpg'); + }); + + it('returns null when there is no coverpage', () => { + expect(readCoverEntryId(MINIMAL, 'l')).toBeNull(); + }); + + it('ignores a coverpage that belongs to src-title-info', () => { + const xml = description(' sf', ' \n'); + expect(readCoverEntryId(xml, 'l')).toBeNull(); + }); +}); diff --git a/server/src/modules/file-write/formats/fb2/fb2-description-builder.ts b/server/src/modules/file-write/formats/fb2/fb2-description-builder.ts new file mode 100644 index 000000000..0c73d4933 --- /dev/null +++ b/server/src/modules/file-write/formats/fb2/fb2-description-builder.ts @@ -0,0 +1,400 @@ +import { FB2_BOOK_FILE_WRITE_FIELDS } from '@bookorbit/types'; + +import { BOOKORBIT_NS_PREFIX } from '../shared/bookorbit-ns'; +import type { BookWritePayload, BookWritePayloadKey } from '../../interfaces/book-write-payload.interface'; +import { + type Fb2Element, + Fb2StructureError, + elementText, + findElementClose, + findTagEnd, + locateElement, + scanChildElements, + startTagOf, +} from './fb2-element-scanner'; + +/** + * Child order required by the FB2 schema. Real-world files frequently deviate, + * so the builder reads in any order and always emits in this one. + */ +const TITLE_INFO_ORDER = [ + 'genre', + 'author', + 'book-title', + 'annotation', + 'keywords', + 'date', + 'coverpage', + 'lang', + 'src-lang', + 'translator', + 'sequence', +] as const; + +const DESCRIPTION_ORDER = ['title-info', 'src-title-info', 'document-info', 'publish-info', 'custom-info', 'output'] as const; + +const PUBLISH_INFO_ORDER = ['book-name', 'publisher', 'city', 'year', 'isbn', 'sequence'] as const; + +/** Fields with no standard FB2 slot, carried in . */ +const CUSTOM_INFO_FIELDS = [ + 'subtitle', + 'pageCount', + 'rating', + 'isbn10', + 'googleBooksId', + 'goodreadsId', + 'amazonId', + 'hardcoverId', + 'hardcoverEditionId', + 'openLibraryId', + 'ranobedbId', + 'koboId', + 'lubimyczytacId', + 'aladinId', + 'itunesId', +] as const satisfies readonly BookWritePayloadKey[]; + +const PUBLISH_INFO_FIELDS = ['publisher', 'publishedDate', 'publishedYear', 'isbn13'] as const satisfies readonly BookWritePayloadKey[]; + +const FB2_WRITABLE_FIELDS = new Set(FB2_BOOK_FILE_WRITE_FIELDS); +const CUSTOM_INFO_PREFIX = `${BOOKORBIT_NS_PREFIX}:`; +const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +export type Fb2DescriptionBuild = + | { status: 'built'; descriptionXml: string; fieldsWritten: BookWritePayloadKey[]; coverEntryId: string | null } + | { status: 'skipped'; reason: string }; + +export type BuildFb2DescriptionOptions = { + /** Namespace prefix bound to xlink in the source document, e.g. 'l' or 'xlink'. */ + xlinkPrefix: string; + /** Binary id the coverpage must point at, or null to leave the coverpage alone. */ + coverEntryId?: string | null; +}; + +function escapeText(value: string | number): string { + return String(value).replace(/&/g, '&').replace(//g, '>'); +} + +function escapeAttribute(value: string | number): string { + return escapeText(value).replace(/"/g, '"'); +} + +function hasValue(value: unknown): boolean { + if (value === null || value === undefined) return false; + if (typeof value === 'string') return value.trim().length > 0; + if (Array.isArray(value)) return value.length > 0; + return true; +} + +function splitAuthorName( + name: string, + sortName: string | null, +): { + first: string | null; + middle: string | null; + last: string | null; + nickname: string | null; +} { + // "Last, First Middle" is the most reliable split we have; fall back to the + // display name when no sort name was stored. + if (sortName && sortName.includes(',')) { + const commaAt = sortName.indexOf(','); + const last = sortName.slice(0, commaAt).trim(); + const rest = sortName + .slice(commaAt + 1) + .trim() + .split(/\s+/) + .filter(Boolean); + if (last) { + return { first: rest[0] ?? null, middle: rest.slice(1).join(' ') || null, last, nickname: null }; + } + } + + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return { first: null, middle: null, last: null, nickname: null }; + if (parts.length === 1) return { first: null, middle: null, last: null, nickname: parts[0]! }; + return { first: parts[0]!, middle: parts.slice(1, -1).join(' ') || null, last: parts[parts.length - 1]!, nickname: null }; +} + +function buildAuthorElement(author: { name: string; sortName: string | null }, indent: string): string { + const { first, middle, last, nickname } = splitAuthorName(author.name, author.sortName); + const parts: string[] = []; + if (first) parts.push(`${escapeText(first)}`); + if (middle) parts.push(`${escapeText(middle)}`); + if (last) parts.push(`${escapeText(last)}`); + if (nickname) parts.push(`${escapeText(nickname)}`); + return `${indent}${parts.join('')}`; +} + +function buildAnnotationElement(description: string, indent: string): string | null { + const paragraphs = description + .split(/\r?\n\s*\r?\n/) + .map((paragraph) => paragraph.replace(/\s+/g, ' ').trim()) + .filter(Boolean); + if (paragraphs.length === 0) return null; + return `${indent}${paragraphs.map((paragraph) => `

${escapeText(paragraph)}

`).join('')}
`; +} + +function resolveIndent(descriptionXml: string, firstChild: Fb2Element | undefined): string { + if (!firstChild) return ' '; + const lineStart = descriptionXml.lastIndexOf('\n', firstChild.start) + 1; + const lead = descriptionXml.slice(lineStart, firstChild.start); + return /^[ \t]+$/.test(lead) ? lead : ' '; +} + +function groupByName(children: Fb2Element[]): Map { + const grouped = new Map(); + for (const child of children) { + const existing = grouped.get(child.name); + if (existing) existing.push(child); + else grouped.set(child.name, [child]); + } + return grouped; +} + +function reindent(raw: string, indent: string): string { + return indent + raw.trim(); +} + +/** + * Rewrites an FB2 block from a write payload. + * + * Managed elements are regenerated; everything else (document-info, src-title-info, + * foreign custom-info, publish-info extras) is copied verbatim from the source so + * unrelated metadata keeps its exact bytes. + */ +export function buildFb2Description( + descriptionXml: string, + payload: BookWritePayload, + fieldMask: Set, + options: BuildFb2DescriptionOptions, +): Fb2DescriptionBuild { + const mask = new Set([...fieldMask].filter((field) => FB2_WRITABLE_FIELDS.has(field))); + const writes = (field: BookWritePayloadKey): boolean => mask.has(field) && hasValue(payload[field]); + const fieldsWritten: BookWritePayloadKey[] = []; + const record = (field: BookWritePayloadKey): void => { + if (!fieldsWritten.includes(field)) fieldsWritten.push(field); + }; + + let openTagEnd: number; + let closeTagStart: number; + try { + openTagEnd = findTagEnd(descriptionXml, 0); + closeTagStart = findElementClose(descriptionXml, 'description', openTagEnd + 1); + } catch (error) { + return { status: 'skipped', reason: error instanceof Fb2StructureError ? error.message : 'unreadable description block' }; + } + + const children = scanChildElements(descriptionXml, openTagEnd + 1, closeTagStart); + const grouped = groupByName(children); + const titleInfo = grouped.get('title-info')?.[0]; + if (!titleInfo || titleInfo.selfClosing) { + return { status: 'skipped', reason: 'description has no title-info' }; + } + + const indent = resolveIndent(descriptionXml, children[0]); + const childIndent = indent + indent; + const newline = descriptionXml.includes('\r\n') ? '\r\n' : '\n'; + + // ── title-info ───────────────────────────────────────────────────────────── + const titleInfoChildren = scanChildElements(descriptionXml, titleInfo.contentStart, titleInfo.contentEnd); + const titleInfoGroups = groupByName(titleInfoChildren); + const slots = new Map(); + for (const name of TITLE_INFO_ORDER) { + slots.set( + name, + (titleInfoGroups.get(name) ?? []).map((child) => reindent(elementText(descriptionXml, child), childIndent)), + ); + } + const foreignTitleInfoChildren = titleInfoChildren + .filter((child) => !TITLE_INFO_ORDER.includes(child.name as (typeof TITLE_INFO_ORDER)[number])) + .map((child) => reindent(elementText(descriptionXml, child), childIndent)); + + if (writes('title')) { + slots.set('book-title', [`${childIndent}${escapeText(payload.title!.trim())}`]); + record('title'); + } + + if (writes('authors')) { + const authors = payload.authors!.filter((author) => author?.name?.trim()); + if (authors.length > 0) { + slots.set( + 'author', + authors.map((author) => buildAuthorElement(author, childIndent)), + ); + record('authors'); + } + } + + if (writes('genres')) { + const genres = payload.genres!.map((genre) => genre.trim()).filter(Boolean); + if (genres.length > 0) { + slots.set( + 'genre', + genres.map((genre) => `${childIndent}${escapeText(genre)}`), + ); + record('genres'); + } + } + + if (writes('description')) { + const annotation = buildAnnotationElement(payload.description!, childIndent); + if (annotation) { + slots.set('annotation', [annotation]); + record('description'); + } + } + + if (writes('tags')) { + const tags = payload.tags!.map((tag) => tag.trim()).filter(Boolean); + if (tags.length > 0) { + slots.set('keywords', [`${childIndent}${escapeText(tags.join(', '))}`]); + record('tags'); + } + } + + if (writes('language')) { + slots.set('lang', [`${childIndent}${escapeText(payload.language!.trim())}`]); + record('language'); + } + + const isoDate = writes('publishedDate') && ISO_DATE_RE.test(payload.publishedDate!.trim()) ? payload.publishedDate!.trim() : null; + const publishedYear = resolvePublishedYear(payload, mask); + const yearFromPayload = mask.has('publishedYear') && typeof payload.publishedYear === 'number' && Number.isFinite(payload.publishedYear); + if (isoDate) { + slots.set('date', [`${childIndent}${escapeText(isoDate)}`]); + record('publishedDate'); + } else if (publishedYear !== null) { + slots.set('date', [`${childIndent}${escapeText(publishedYear)}`]); + } + if (publishedYear !== null) { + record(yearFromPayload ? 'publishedYear' : 'publishedDate'); + } + + if (writes('seriesName')) { + const seriesName = payload.seriesName!.trim(); + const index = payload.seriesIndex; + const writeIndex = mask.has('seriesIndex') && typeof index === 'number' && Number.isFinite(index); + const numberAttribute = writeIndex ? ` number="${escapeAttribute(String(index))}"` : ''; + slots.set('sequence', [`${childIndent}`]); + record('seriesName'); + if (writeIndex) record('seriesIndex'); + } + + if (options.coverEntryId) { + const href = `#${escapeAttribute(options.coverEntryId)}`; + slots.set('coverpage', [`${childIndent}`]); + } + + const titleInfoBody = TITLE_INFO_ORDER.flatMap((name) => slots.get(name) ?? []).concat(foreignTitleInfoChildren); + const newTitleInfo = [`${indent}${startTagOf(descriptionXml, titleInfo)}`, ...titleInfoBody, `${indent}
`].join(newline); + + // ── publish-info ─────────────────────────────────────────────────────────── + const publishInfo = grouped.get('publish-info')?.[0]; + let newPublishInfo = publishInfo ? reindent(elementText(descriptionXml, publishInfo), indent) : null; + if (PUBLISH_INFO_FIELDS.some((field) => writes(field))) { + const publishInfoChildren = + publishInfo && !publishInfo.selfClosing ? scanChildElements(descriptionXml, publishInfo.contentStart, publishInfo.contentEnd) : []; + const publishGroups = groupByName(publishInfoChildren); + const publishSlots = new Map(); + for (const name of PUBLISH_INFO_ORDER) { + publishSlots.set( + name, + (publishGroups.get(name) ?? []).map((child) => reindent(elementText(descriptionXml, child), childIndent)), + ); + } + + if (writes('publisher')) { + publishSlots.set('publisher', [`${childIndent}${escapeText(payload.publisher!.trim())}`]); + record('publisher'); + } + if (publishedYear !== null) { + publishSlots.set('year', [`${childIndent}${escapeText(publishedYear)}`]); + } + if (writes('isbn13')) { + publishSlots.set('isbn', [`${childIndent}${escapeText(payload.isbn13!.trim())}`]); + record('isbn13'); + } + + const publishBody = PUBLISH_INFO_ORDER.flatMap((name) => publishSlots.get(name) ?? []); + const publishOpenTag = publishInfo && !publishInfo.selfClosing ? startTagOf(descriptionXml, publishInfo) : ''; + newPublishInfo = [`${indent}${publishOpenTag}`, ...publishBody, `${indent}`].join(newline); + } + + // ── custom-info ──────────────────────────────────────────────────────────── + const foreignCustomInfo = (grouped.get('custom-info') ?? []) + .map((child) => elementText(descriptionXml, child)) + .filter((raw) => !raw.includes(`info-type="${CUSTOM_INFO_PREFIX}`) && !raw.includes(`info-type='${CUSTOM_INFO_PREFIX}`)) + .map((raw) => reindent(raw, indent)); + + const managedCustomInfo: string[] = []; + for (const field of CUSTOM_INFO_FIELDS) { + if (!writes(field)) continue; + managedCustomInfo.push(customInfoElement(field, payload[field] as string | number, indent)); + record(field); + } + for (const entry of payload.customMetadata ?? []) { + if (entry.value === null || entry.value === undefined) continue; + managedCustomInfo.push(customInfoElement(`custom:${entry.key}`, String(entry.value), indent)); + } + + // ── reassemble ───────────────────────────────────────────────────────────── + const blocks: string[] = []; + for (const name of DESCRIPTION_ORDER) { + if (name === 'title-info') { + blocks.push(newTitleInfo); + } else if (name === 'publish-info') { + if (newPublishInfo) blocks.push(newPublishInfo); + } else if (name === 'custom-info') { + blocks.push(...foreignCustomInfo, ...managedCustomInfo); + } else { + blocks.push(...(grouped.get(name) ?? []).map((child) => reindent(elementText(descriptionXml, child), indent))); + } + } + blocks.push( + ...children + .filter((child) => !DESCRIPTION_ORDER.includes(child.name as (typeof DESCRIPTION_ORDER)[number])) + .map((child) => reindent(elementText(descriptionXml, child), indent)), + ); + + const openTag = descriptionXml.slice(0, openTagEnd + 1); + const closeTag = descriptionXml.slice(closeTagStart); + const descriptionOut = [openTag, ...blocks, closeTag].join(newline); + + return { status: 'built', descriptionXml: descriptionOut, fieldsWritten, coverEntryId: options.coverEntryId ?? null }; +} + +function customInfoElement(infoType: string, value: string | number, indent: string): string { + return `${indent}${escapeText(value)}`; +} + +function resolvePublishedYear(payload: BookWritePayload, mask: Set): number | null { + if (mask.has('publishedYear') && typeof payload.publishedYear === 'number' && Number.isFinite(payload.publishedYear)) { + return payload.publishedYear; + } + if (mask.has('publishedDate') && payload.publishedDate) { + const year = Number.parseInt(payload.publishedDate.slice(0, 4), 10); + if (Number.isFinite(year)) return year; + } + return null; +} + +/** Reads the binary id referenced by , when the document has one. */ +export function readCoverEntryId(descriptionXml: string, xlinkPrefix: string): string | null { + const titleInfo = locateElement(descriptionXml, 'title-info'); + if (!titleInfo || titleInfo.selfClosing) return null; + + const coverpage = locateElement(descriptionXml, 'coverpage', titleInfo.contentStart); + if (!coverpage || coverpage.end > titleInfo.contentEnd) return null; + + const image = descriptionXml.slice(coverpage.start, coverpage.end).match(/]*>/)?.[0]; + if (!image) return null; + + const href = + image.match(new RegExp(`(?:^|\\s)${xlinkPrefix}:href\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i')) ?? + image.match(/(?:^|\s)(?:[\w-]+:)?href\s*=\s*(?:"([^"]*)"|'([^']*)')/i); + const value = href?.[1] ?? href?.[2] ?? null; + if (!value) return null; + + return value.startsWith('#') ? value.slice(1) : value; +} diff --git a/server/src/modules/file-write/formats/fb2/fb2-element-scanner.test.ts b/server/src/modules/file-write/formats/fb2/fb2-element-scanner.test.ts new file mode 100644 index 000000000..4e11b7ba9 --- /dev/null +++ b/server/src/modules/file-write/formats/fb2/fb2-element-scanner.test.ts @@ -0,0 +1,166 @@ +import { + Fb2StructureError, + elementText, + findElementClose, + findTagEnd, + locateElement, + readAttribute, + scanChildElements, + startTagOf, +} from './fb2-element-scanner'; + +function childNames(xml: string, parent: string): string[] { + const element = locateElement(xml, parent); + if (!element) throw new Error(`missing <${parent}>`); + return scanChildElements(xml, element.contentStart, element.contentEnd).map((child) => child.name); +} + +describe('findTagEnd', () => { + it('finds the end of a simple tag', () => { + const xml = 'x'; + expect(findTagEnd(xml, 0)).toBe(11); + }); + + it('ignores a > inside a double-quoted attribute value', () => { + const xml = ''; + expect(findTagEnd(xml, 0)).toBe(xml.length - 1); + }); + + it('ignores a > inside a single-quoted attribute value', () => { + const xml = ""; + expect(findTagEnd(xml, 0)).toBe(xml.length - 1); + }); + + it('throws when the tag never closes', () => { + expect(() => findTagEnd(' { + it('matches the outer close when the same element nests', () => { + const xml = ''; + const close = findElementClose(xml, 'sequence', xml.indexOf('>') + 1); + expect(xml.slice(close)).toBe(''); + }); + + it('does not count self-closing tags as nesting', () => { + const xml = '

'; + const close = findElementClose(xml, 'annotation', xml.indexOf('>') + 1); + expect(xml.slice(close)).toBe(''); + }); + + it('tolerates whitespace inside the closing tag', () => { + const xml = 'en'; + expect(findElementClose(xml, 'lang', 6)).toBe(8); + }); + + it('ignores markup that only appears inside a comment', () => { + const xml = '

real

'; + const close = findElementClose(xml, 'annotation', xml.indexOf('>') + 1); + expect(xml.slice(close)).toBe(''); + }); + + it('ignores markup that only appears inside CDATA', () => { + const xml = ']]>'; + const close = findElementClose(xml, 'program-used', xml.indexOf('>') + 1); + expect(xml.slice(close)).toBe(''); + }); + + it('throws when the element never closes', () => { + expect(() => findElementClose('', 'description', 13)).toThrow(/unclosed /); + }); + + it('throws on an unterminated comment', () => { + expect(() => findElementClose(''; + expect(childNames(xml, 'description')).toEqual(['title-info', 'document-info']); + }); + + it('does not descend into grandchildren', () => { + const xml = 'sf'; + expect(childNames(xml, 'description')).toEqual(['title-info']); + }); + + it('reports spans that slice back to the original text', () => { + const xml = 'sfT & U'; + const parent = locateElement(xml, 'title-info')!; + const children = scanChildElements(xml, parent.contentStart, parent.contentEnd); + expect(elementText(xml, children[1])).toBe('T & U'); + expect(xml.slice(children[1].contentStart, children[1].contentEnd)).toBe('T & U'); + }); + + it('stops at the parent closing tag', () => { + const xml = '
'; + expect(childNames(xml, 'description')).toEqual(['title-info']); + }); +}); + +describe('locateElement', () => { + it('does not match an element whose name only shares a prefix', () => { + const xml = ''; + const found = locateElement(xml, 'description')!; + expect(found.start).toBe(xml.indexOf('')); + }); + + it('handles a self-closing element', () => { + const found = locateElement('', 'image')!; + expect(found.selfClosing).toBe(true); + expect(found.contentStart).toBe(-1); + }); + + it('returns null when the element is absent', () => { + expect(locateElement('', 'publish-info')).toBeNull(); + }); + + it('searches from the requested offset', () => { + const xml = 'enru'; + const second = locateElement(xml, 'lang', 15)!; + expect(xml.slice(second.contentStart, second.contentEnd)).toBe('ru'); + }); +}); + +describe('readAttribute', () => { + it('reads double and single quoted values', () => { + expect(readAttribute('', 'l:href')).toBe('#img_0'); + expect(readAttribute("", 'l:href')).toBe('#img_0'); + }); + + it('is case insensitive on the attribute name', () => { + expect(readAttribute('', 'id')).toBe('cover.jpg'); + }); + + it('does not confuse an attribute whose name ends with the requested one', () => { + expect(readAttribute('', 'id')).toBe('b'); + }); + + it('returns null for a missing attribute', () => { + expect(readAttribute('', 'content-type')).toBeNull(); + }); + + it('reads an empty value', () => { + expect(readAttribute('', 'name')).toBe(''); + }); +}); + +describe('startTagOf', () => { + it('returns only the opening tag with its attributes', () => { + const xml = 'sf'; + const element = locateElement(xml, 'title-info')!; + expect(startTagOf(xml, element)).toBe(''); + }); +}); diff --git a/server/src/modules/file-write/formats/fb2/fb2-element-scanner.ts b/server/src/modules/file-write/formats/fb2/fb2-element-scanner.ts new file mode 100644 index 000000000..72c530d75 --- /dev/null +++ b/server/src/modules/file-write/formats/fb2/fb2-element-scanner.ts @@ -0,0 +1,171 @@ +/** + * Minimal element scanner for FB2 metadata blocks. + * + * The FB2 is rewritten by splicing raw text, so the scanner + * reports byte-accurate spans instead of building a document tree. Comments, + * CDATA sections and processing instructions are skipped rather than parsed, + * and quoted attribute values may contain '>' without ending a tag. + */ + +export class Fb2StructureError extends Error { + constructor(message: string) { + super(message); + this.name = 'Fb2StructureError'; + } +} + +export type Fb2Element = { + name: string; + /** Offset of '<' that opens the element. */ + start: number; + /** Offset just past the element's closing tag. */ + end: number; + /** Offset of the first content character, or -1 when self-closing. */ + contentStart: number; + /** Offset of '<' that opens the closing tag, or -1 when self-closing. */ + contentEnd: number; + selfClosing: boolean; +}; + +const COMMENT_OPEN = ''; +const CDATA_OPEN = ''; +const PI_OPEN = ''; + +/** Returns the offset of the '>' that closes the tag starting at tagStart. */ +export function findTagEnd(text: string, tagStart: number): number { + let quote: string | null = null; + for (let i = tagStart + 1; i < text.length; i++) { + const char = text[i]; + if (quote) { + if (char === quote) quote = null; + continue; + } + if (char === '"' || char === "'") quote = char; + else if (char === '>') return i; + } + throw new Fb2StructureError(`unterminated tag at offset ${tagStart}`); +} + +function skipNonElement(text: string, at: number): number | null { + if (text.startsWith(COMMENT_OPEN, at)) { + const close = text.indexOf(COMMENT_CLOSE, at + COMMENT_OPEN.length); + if (close < 0) throw new Fb2StructureError('unterminated comment'); + return close + COMMENT_CLOSE.length; + } + if (text.startsWith(CDATA_OPEN, at)) { + const close = text.indexOf(CDATA_CLOSE, at + CDATA_OPEN.length); + if (close < 0) throw new Fb2StructureError('unterminated CDATA section'); + return close + CDATA_CLOSE.length; + } + if (text.startsWith(PI_OPEN, at)) { + const close = text.indexOf(PI_CLOSE, at + PI_OPEN.length); + if (close < 0) throw new Fb2StructureError('unterminated processing instruction'); + return close + PI_CLOSE.length; + } + return null; +} + +function tagNameAt(text: string, tagStart: number): string { + const rest = text.slice(tagStart + 1); + const boundary = /[\s/>]/.exec(rest)?.index ?? -1; + return boundary < 0 ? '' : rest.slice(0, boundary); +} + +/** Returns the offset of '<' opening the tag that closes the element named `name`. */ +export function findElementClose(text: string, name: string, contentStart: number): number { + let depth = 1; + let cursor = contentStart; + for (;;) { + const open = text.indexOf('<', cursor); + if (open < 0) throw new Fb2StructureError(`unclosed <${name}>`); + + const skipped = skipNonElement(text, open); + if (skipped !== null) { + cursor = skipped; + continue; + } + + const tagEnd = findTagEnd(text, open); + if (text.startsWith('= to) break; + + const skipped = skipNonElement(text, open); + if (skipped !== null) { + cursor = skipped; + continue; + } + if (text.startsWith('])`); + const match = pattern.exec(text.slice(from)); + if (!match) return null; + + const start = from + match.index; + const tagEnd = findTagEnd(text, start); + if (text[tagEnd - 1] === '/') { + return { name, start, end: tagEnd + 1, contentStart: -1, contentEnd: -1, selfClosing: true }; + } + + const contentStart = tagEnd + 1; + const contentEnd = findElementClose(text, name, contentStart); + return { name, start, end: findTagEnd(text, contentEnd) + 1, contentStart, contentEnd, selfClosing: false }; +} + +/** Reads an attribute value from a start tag, tolerating either quote style. */ +export function readAttribute(startTag: string, attributeName: string): string | null { + const escaped = attributeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = startTag.match(new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i')); + if (!match) return null; + return match[1] ?? match[2] ?? null; +} + +export function elementText(text: string, element: Fb2Element): string { + return text.slice(element.start, element.end); +} + +export function startTagOf(text: string, element: Fb2Element): string { + return text.slice(element.start, findTagEnd(text, element.start) + 1); +} diff --git a/server/src/modules/file-write/formats/fb2/fb2-format-writer.test.ts b/server/src/modules/file-write/formats/fb2/fb2-format-writer.test.ts new file mode 100644 index 000000000..dc4871d7b --- /dev/null +++ b/server/src/modules/file-write/formats/fb2/fb2-format-writer.test.ts @@ -0,0 +1,100 @@ +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { FB2_BOOK_FILE_WRITE_FIELDS } from '@bookorbit/types'; +import type { BookWritePayloadKey } from '../../interfaces/book-write-payload.interface'; +import { Fb2FormatWriter } from './fb2-format-writer'; + +const FULL_MASK = new Set(FB2_BOOK_FILE_WRITE_FIELDS); + +const CONTENT = [ + '', + '', + '', + ' ', + ' antique', + ' Old Title', + ' en', + ' ', + '', + '

Text

', + '
', + '', +].join('\n'); + +let testRoot: string; +let writer: Fb2FormatWriter; + +beforeEach(async () => { + testRoot = await mkdtemp(join(tmpdir(), 'bookorbit-fb2-writer-')); + writer = new Fb2FormatWriter(); +}); + +afterEach(async () => { + await rm(testRoot, { recursive: true, force: true }); +}); + +async function makeFile(content = CONTENT): Promise { + const path = join(testRoot, 'book.fb2'); + await writeFile(path, content, 'utf8'); + return path; +} + +describe('Fb2FormatWriter', () => { + it('registers itself for the fb2 format', () => { + expect(writer.format).toBe('fb2'); + }); + + it('returns success with the fields it wrote', async () => { + const path = await makeFile(); + + const result = await writer.write(path, { title: 'New Title', language: 'de' }, { fieldMask: FULL_MASK, dryRun: false }); + + expect(result.status).toBe('success'); + expect(result.fieldsWritten).toEqual(['title', 'language']); + expect(result.durationMs).toBeGreaterThanOrEqual(0); + expect(await readFile(path, 'utf8')).toContain('New Title'); + }); + + it('reports a skip reason and the fields a real write would touch on dry-run', async () => { + const path = await makeFile(); + const before = await readFile(path, 'utf8'); + + const result = await writer.write(path, { title: 'New Title' }, { fieldMask: FULL_MASK, dryRun: true }); + + expect(result).toMatchObject({ status: 'skipped', reason: 'dry-run', fieldsWritten: ['title'] }); + expect(await readFile(path, 'utf8')).toBe(before); + }); + + it('skips a document it cannot write and leaves it unchanged', async () => { + const path = await makeFile('\n

x

'); + const before = await readFile(path, 'utf8'); + + const result = await writer.write(path, { title: 'New Title' }, { fieldMask: FULL_MASK, dryRun: false }); + + expect(result).toMatchObject({ status: 'skipped', reason: 'no description block found', fieldsWritten: [] }); + expect(await readFile(path, 'utf8')).toBe(before); + }); + + it('propagates errors for a missing file so the service can log a failure', async () => { + await expect(writer.write(join(testRoot, 'nope.fb2'), { title: 'T' }, { fieldMask: FULL_MASK, dryRun: false })).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + it('honours the field mask handed down by the service', async () => { + const path = await makeFile(); + + const result = await writer.write( + path, + { title: 'New Title', language: 'de' }, + { fieldMask: new Set(['language']), dryRun: false }, + ); + + expect(result.fieldsWritten).toEqual(['language']); + const updated = await readFile(path, 'utf8'); + expect(updated).toContain('Old Title'); + expect(updated).toContain('de'); + }); +}); diff --git a/server/src/modules/file-write/formats/fb2/fb2-format-writer.ts b/server/src/modules/file-write/formats/fb2/fb2-format-writer.ts new file mode 100644 index 000000000..2989ffea8 --- /dev/null +++ b/server/src/modules/file-write/formats/fb2/fb2-format-writer.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; + +import type { WriteResult } from '@bookorbit/types'; +import { FORMAT_FB2 } from '../../file-write.constants'; +import type { BookWritePayload } from '../../interfaces/book-write-payload.interface'; +import type { FormatWriteOptions } from '../../interfaces/format-write-options.interface'; +import type { FormatWriter } from '../../interfaces/format-writer.interface'; +import { writeFb2Metadata } from './fb2-write-core'; + +@Injectable() +export class Fb2FormatWriter implements FormatWriter { + readonly format = FORMAT_FB2; + + async write(filePath: string, payload: BookWritePayload, options: FormatWriteOptions): Promise { + const start = Date.now(); + const outcome = await writeFb2Metadata(filePath, payload, options.fieldMask, { dryRun: options.dryRun }); + + if (outcome.status === 'skipped') { + return { status: 'skipped', reason: outcome.reason, fieldsWritten: outcome.fieldsWritten, durationMs: Date.now() - start }; + } + + return { status: 'success', fieldsWritten: outcome.fieldsWritten, durationMs: Date.now() - start }; + } +} diff --git a/server/src/modules/file-write/formats/fb2/fb2-write-core.test.ts b/server/src/modules/file-write/formats/fb2/fb2-write-core.test.ts new file mode 100644 index 000000000..14eb17312 --- /dev/null +++ b/server/src/modules/file-write/formats/fb2/fb2-write-core.test.ts @@ -0,0 +1,525 @@ +import { mkdtemp, readdir, readFile, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { XMLValidator } from 'fast-xml-parser'; +import iconv from 'iconv-lite'; +import sharp from 'sharp'; + +import { FB2_BOOK_FILE_WRITE_FIELDS } from '@bookorbit/types'; +import type { BookWritePayload, BookWritePayloadKey } from '../../interfaces/book-write-payload.interface'; +import { writeFb2Metadata } from './fb2-write-core'; + +const FULL_MASK = new Set(FB2_BOOK_FILE_WRITE_FIELDS); +const NBSP = ' '; + +let testRoot: string; +let jpegCover: Buffer; +let pngCover: Buffer; + +beforeAll(async () => { + jpegCover = await sharp({ create: { width: 12, height: 18, channels: 3, background: { r: 200, g: 40, b: 90 } } }) + .jpeg() + .toBuffer(); + pngCover = await sharp({ create: { width: 8, height: 8, channels: 3, background: { r: 10, g: 20, b: 30 } } }) + .png() + .toBuffer(); +}); + +beforeEach(async () => { + testRoot = await mkdtemp(join(tmpdir(), 'bookorbit-fb2-write-')); +}); + +afterEach(async () => { + await rm(testRoot, { recursive: true, force: true }); +}); + +type FileOptions = { + declaration?: string; + rootAttributes?: string; + titleInfo?: string; + extraDescription?: string; + body?: string; + binaries?: string; + encoding?: string; + newline?: '\n' | '\r\n'; +}; + +const DEFAULT_TITLE_INFO = [ + ' antique', + ' OldAuthor', + ' Old Title', + ' en', +].join('\n'); + +/** Body text carrying the characters a naive XML round-trip is known to damage. */ +const DEFAULT_BODY = [ + '', + '
', + `

Text with${NBSP}a non-breaking space and an & entity.

`, + ' ', + '

Second paragraph with runs of spaces.

', + '
', + '', +].join('\n'); + +function fb2Content(options: FileOptions = {}): string { + const declaration = options.declaration ?? ''; + const rootAttributes = options.rootAttributes ?? 'xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:l="http://www.w3.org/1999/xlink"'; + const parts = [ + declaration, + ``, + '', + ' ', + options.titleInfo ?? DEFAULT_TITLE_INFO, + ' ', + ' doc-11.0', + options.extraDescription ?? '', + '', + options.body ?? DEFAULT_BODY, + options.binaries ?? '', + '', + '', + ].filter((part) => part !== ''); + const content = parts.join('\n'); + return options.newline === '\r\n' ? content.replace(/\n/g, '\r\n') : content; +} + +async function makeFile(content: string, encoding = 'utf8'): Promise { + const path = join(testRoot, 'book.fb2'); + await writeFile(path, encoding === 'utf8' ? Buffer.from(content, 'utf8') : iconv.encode(content, encoding)); + return path; +} + +async function write(path: string, payload: BookWritePayload, mask: Set = FULL_MASK, dryRun = false) { + return writeFb2Metadata(path, payload, mask, { dryRun }); +} + +function bodyRegion(text: string): string { + return text.slice(text.indexOf('') + ''.length); +} + +function binaryMap(text: string): Map { + const map = new Map(); + for (const match of text.matchAll(/]*)>([\s\S]*?)<\/binary>/g)) { + const id = match[1]!.match(/\bid\s*=\s*"([^"]*)"/)?.[1] ?? '(none)'; + map.set(id, match[2]!); + } + return map; +} + +function expectWellFormed(text: string): void { + const result = XMLValidator.validate(text, { allowBooleanAttributes: true }); + expect(result === true ? true : JSON.stringify(result)).toBe(true); +} + +describe('writeFb2Metadata byte preservation', () => { + it('leaves every byte outside the description untouched', async () => { + const original = fb2Content(); + const path = await makeFile(original); + const originalBytes = await readFile(path); + + const result = await write(path, { title: 'New Title', language: 'de' }); + expect(result.status).toBe('success'); + + const updated = await readFile(path); + const originalText = originalBytes.toString('utf8'); + const updatedText = updated.toString('utf8'); + const headLength = originalText.indexOf(''); + + expect(updated.subarray(0, headLength).equals(originalBytes.subarray(0, headLength))).toBe(true); + const originalTail = originalBytes.subarray(originalText.indexOf('') + '
'.length); + const updatedTail = updated.subarray(updatedText.indexOf('
') + ''.length); + expect(updatedTail.equals(originalTail)).toBe(true); + }); + + it('preserves non-breaking spaces, entities and empty-line tags in the body', async () => { + const path = await makeFile(fb2Content()); + const before = bodyRegion(await readFile(path, 'utf8')); + + await write(path, { title: 'New Title', description: 'Fresh annotation.' }); + + const after = bodyRegion(await readFile(path, 'utf8')); + expect(after).toBe(before); + expect(after).toContain(NBSP); + expect(after).toContain('&'); + expect(after).toContain(''); + expect(after).toContain('with runs of spaces'); + }); + + it('preserves crlf line endings in the body', async () => { + const path = await makeFile(fb2Content({ newline: '\r\n' })); + const before = await readFile(path, 'utf8'); + + await write(path, { title: 'New Title' }); + + const after = await readFile(path, 'utf8'); + expect(bodyRegion(after)).toBe(bodyRegion(before)); + expect(bodyRegion(after)).toContain('\r\n'); + const rebuiltDescription = after.slice(after.indexOf(''), after.indexOf('')); + expect(rebuiltDescription).toContain('\r\n'); + expect(rebuiltDescription.replace(/\r\n/g, '')).not.toContain('\n'); + }); + + it('preserves a utf-8 BOM', async () => { + const path = join(testRoot, 'bom.fb2'); + await writeFile(path, Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(fb2Content(), 'utf8')])); + + const result = await write(path, { title: 'New Title' }); + expect(result.status).toBe('success'); + + const updated = await readFile(path); + expect(updated.subarray(0, 3).equals(Buffer.from([0xef, 0xbb, 0xbf]))).toBe(true); + expect(updated.toString('utf8')).toContain('New Title'); + }); + + it('keeps multibyte characters intact when computing byte offsets', async () => { + const body = `

Мастер и Маргарита ${'ü'.repeat(50)}

`; + const path = await makeFile(fb2Content({ body })); + const before = bodyRegion(await readFile(path, 'utf8')); + + await write(path, { title: 'Новое название' }); + + const after = await readFile(path, 'utf8'); + expect(bodyRegion(after)).toBe(before); + expect(after).toContain('Новое название'); + expectWellFormed(after); + }); + + it('produces a well-formed document', async () => { + const path = await makeFile(fb2Content()); + await write(path, { + title: 'Title & "Quotes" ', + authors: [{ name: 'A B', sortName: 'B, A' }], + description: 'One.\n\nTwo.', + genres: ['Fantasy & Epic'], + tags: ['a', 'b'], + seriesName: 'S & S', + seriesIndex: 2, + publisher: 'P & P', + isbn13: '9780000000001', + publishedDate: '2001-02-03', + rating: 4, + }); + expectWellFormed(await readFile(path, 'utf8')); + }); + + it('leaves no temporary files behind', async () => { + const path = await makeFile(fb2Content()); + await write(path, { title: 'New Title' }); + expect(await readdir(testRoot)).toEqual(['book.fb2']); + }); +}); + +describe('writeFb2Metadata idempotency', () => { + it('writing the same payload twice produces identical bytes', async () => { + const payload: BookWritePayload = { + title: 'Stable Title', + authors: [{ name: 'Cat Bruno', sortName: 'Bruno, Cat' }], + genres: ['Fantasy'], + language: 'en', + seriesName: 'Series', + seriesIndex: 2, + publishedDate: '2018-03-14', + publisher: 'Pub', + rating: 5, + }; + const path = await makeFile(fb2Content()); + + await write(path, payload); + const first = await readFile(path); + await write(path, payload); + const second = await readFile(path); + + expect(second.equals(first)).toBe(true); + }); + + it('writing the same cover twice produces identical bytes', async () => { + const path = await makeFile( + fb2Content({ + titleInfo: `${DEFAULT_TITLE_INFO}\n `, + binaries: 'QUJD', + }), + ); + + await write(path, { title: 'T', coverBytes: jpegCover }); + const first = await readFile(path); + await write(path, { title: 'T', coverBytes: jpegCover }); + + expect((await readFile(path)).equals(first)).toBe(true); + }); +}); + +describe('writeFb2Metadata cover handling', () => { + it('replaces the referenced cover binary and leaves the others alone', async () => { + const binaries = [ + 'SU1HMQ==', + 'T0xEQ09WRVI=', + 'SU1HMg==', + ].join('\n'); + const path = await makeFile( + fb2Content({ titleInfo: `${DEFAULT_TITLE_INFO}\n `, binaries }), + ); + + const result = await write(path, { coverBytes: jpegCover }); + expect(result.status).toBe('success'); + expect(result.fieldsWritten).toContain('coverBytes'); + + const updated = await readFile(path, 'utf8'); + const map = binaryMap(updated); + expect(map.size).toBe(3); + expect(map.get('img_1')).toBe('SU1HMQ=='); + expect(map.get('img_2')).toBe('SU1HMg=='); + expect(Buffer.from(map.get('cover.jpg')!.replace(/\s+/g, ''), 'base64').equals(jpegCover)).toBe(true); + expectWellFormed(updated); + }); + + it('injects a coverpage and binary when the document has neither', async () => { + const path = await makeFile(fb2Content({ binaries: 'SU1HMQ==' })); + + const result = await write(path, { coverBytes: jpegCover }); + expect(result.status).toBe('success'); + + const updated = await readFile(path, 'utf8'); + expect(updated).toContain(''); + const map = binaryMap(updated); + expect(map.size).toBe(2); + expect(Buffer.from(map.get('bookorbit-cover.jpg')!.replace(/\s+/g, ''), 'base64').equals(jpegCover)).toBe(true); + expect(updated.indexOf('bookorbit-cover.jpg', updated.indexOf(''))).toBeLessThan(updated.lastIndexOf('')); + expectWellFormed(updated); + }); + + it('injects the missing binary when a coverpage points at nothing', async () => { + const path = await makeFile(fb2Content({ titleInfo: `${DEFAULT_TITLE_INFO}\n ` })); + + await write(path, { coverBytes: jpegCover }); + + const updated = await readFile(path, 'utf8'); + expect(updated).toContain(''); + expect(Buffer.from(binaryMap(updated).get('missing.jpg')!.replace(/\s+/g, ''), 'base64').equals(jpegCover)).toBe(true); + expectWellFormed(updated); + }); + + it('uses the document xlink prefix when injecting a coverpage', async () => { + const path = await makeFile( + fb2Content({ rootAttributes: 'xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink"' }), + ); + + await write(path, { coverBytes: jpegCover }); + + expect(await readFile(path, 'utf8')).toContain(''); + }); + + it('records the cover media type from the image bytes', async () => { + const path = await makeFile(fb2Content()); + + await write(path, { coverBytes: pngCover }); + + const updated = await readFile(path, 'utf8'); + expect(updated).toContain(''); + }); + + it('does not touch the cover when coverBytes is masked out', async () => { + const binaries = 'T0xEQ09WRVI='; + const path = await makeFile( + fb2Content({ titleInfo: `${DEFAULT_TITLE_INFO}\n `, binaries }), + ); + + const result = await write(path, { title: 'T', coverBytes: jpegCover }, new Set(['title'])); + + expect(result.fieldsWritten).toEqual(['title']); + expect(binaryMap(await readFile(path, 'utf8')).get('cover.jpg')).toBe('T0xEQ09WRVI='); + }); + + it('replaces a cover that sits behind a large body without disturbing it', async () => { + const body = `

${'filler '.repeat(60_000)}

`; + const binaries = `${'QUJD'.repeat(20_000)}`; + const path = await makeFile( + fb2Content({ body, titleInfo: `${DEFAULT_TITLE_INFO}\n `, binaries }), + ); + const before = bodyRegion(await readFile(path, 'utf8')); + + const result = await write(path, { title: 'Big Book', coverBytes: jpegCover }); + expect(result.status).toBe('success'); + + const updated = await readFile(path, 'utf8'); + expect(bodyRegion(updated)).toBe(before); + expect(Buffer.from(binaryMap(updated).get('cover.jpg')!.replace(/\s+/g, ''), 'base64').equals(jpegCover)).toBe(true); + }); +}); + +describe('writeFb2Metadata encodings', () => { + it('writes a windows-1251 file without changing its encoding or body bytes', async () => { + const content = fb2Content({ + declaration: '', + titleInfo: ' prose_classic\n Мастер\n ru', + body: '

В белом плаще с кровавым подбоем.

', + }); + const path = await makeFile(content, 'win1251'); + const originalBytes = await readFile(path); + + const result = await write(path, { title: 'Мастер и Маргарита', seriesName: 'Русская классика', seriesIndex: 1 }); + expect(result.status).toBe('success'); + + const updatedBytes = await readFile(path); + const decoded = iconv.decode(updatedBytes, 'win1251'); + expect(decoded).toContain('encoding="windows-1251"'); + expect(decoded).toContain('Мастер и Маргарита'); + expect(decoded).toContain(''); + expect(decoded).toContain('В белом плаще с кровавым подбоем.'); + + // Single-byte encoding, so a latin1 view gives byte-accurate offsets. + const tailAfterDescription = (buffer: Buffer): Buffer => + buffer.subarray(buffer.toString('latin1').indexOf('') + ''.length); + expect(tailAfterDescription(updatedBytes).equals(tailAfterDescription(originalBytes))).toBe(true); + expect(iconv.encode(decoded, 'win1251').equals(updatedBytes)).toBe(true); + }); + + it('writes a koi8-r file in its declared encoding', async () => { + const content = fb2Content({ + declaration: '', + titleInfo: ' sf\n Старое', + body: '

Текст

', + }); + const path = await makeFile(content, 'koi8-r'); + + const result = await write(path, { title: 'Новое' }); + expect(result.status).toBe('success'); + + const decoded = iconv.decode(await readFile(path), 'koi8-r'); + expect(decoded).toContain('Новое'); + expect(decoded).toContain('Текст'); + }); + + it('skips utf-16 files instead of corrupting them', async () => { + const path = join(testRoot, 'utf16.fb2'); + const content = fb2Content({ declaration: '' }); + await writeFile(path, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(content, 'utf16le')])); + const before = await readFile(path); + + const result = await write(path, { title: 'New Title' }); + + expect(result).toMatchObject({ status: 'skipped' }); + expect(result.status === 'skipped' && result.reason).toMatch(/unsupported encoding/); + expect((await readFile(path)).equals(before)).toBe(true); + }); + + it('skips a file whose declared encoding is unknown', async () => { + const path = await makeFile(fb2Content({ declaration: '' })); + const before = await readFile(path); + + const result = await write(path, { title: 'New Title' }); + + expect(result).toMatchObject({ status: 'skipped' }); + expect(result.status === 'skipped' && result.reason).toContain('made-up-codec-9000'); + expect((await readFile(path)).equals(before)).toBe(true); + }); +}); + +describe('writeFb2Metadata refusals', () => { + it('reports dry-run without touching the file', async () => { + const path = await makeFile(fb2Content()); + const before = await readFile(path); + + const result = await write(path, { title: 'New Title', coverBytes: jpegCover }, FULL_MASK, true); + + expect(result).toMatchObject({ status: 'skipped', reason: 'dry-run' }); + expect(result.fieldsWritten).toEqual(expect.arrayContaining(['title', 'coverBytes'])); + expect((await readFile(path)).equals(before)).toBe(true); + }); + + it('skips when the payload has nothing FB2 can write', async () => { + const path = await makeFile(fb2Content()); + const before = await readFile(path); + + const result = await write(path, { comicIssueNumber: '3' }); + + expect(result).toMatchObject({ status: 'skipped', reason: 'no metadata to write' }); + expect((await readFile(path)).equals(before)).toBe(true); + }); + + it('skips a file with no description block', async () => { + const path = await makeFile('\n

x

'); + const before = await readFile(path); + + const result = await write(path, { title: 'New Title' }); + + expect(result).toMatchObject({ status: 'skipped', reason: 'no description block found' }); + expect((await readFile(path)).equals(before)).toBe(true); + }); + + it('skips a file whose body precedes the description', async () => { + const path = await makeFile( + '\n

x

T
', + ); + const before = await readFile(path); + + const result = await write(path, { title: 'New Title' }); + + expect(result).toMatchObject({ status: 'skipped', reason: 'body precedes description' }); + expect((await readFile(path)).equals(before)).toBe(true); + }); + + it('skips a description without title-info', async () => { + const path = await makeFile( + '\n1

x

', + ); + + const result = await write(path, { title: 'New Title' }); + + expect(result).toMatchObject({ status: 'skipped', reason: 'description has no title-info' }); + }); + + it('skips a truncated file instead of writing a broken one', async () => { + const path = await makeFile('\nT'); + const before = await readFile(path); + + const result = await write(path, { title: 'New Title' }); + + expect(result).toMatchObject({ status: 'skipped' }); + expect(result.status === 'skipped' && result.reason).toMatch(/unclosed/); + expect((await readFile(path)).equals(before)).toBe(true); + }); + + it('is not fooled by an escaped description tag inside the body', async () => { + const body = '

The <description> element holds metadata.

'; + const path = await makeFile(fb2Content({ body })); + + const result = await write(path, { title: 'New Title' }); + + expect(result.status).toBe('success'); + const updated = await readFile(path, 'utf8'); + expect(updated).toContain('The <description> element holds metadata.'); + expectWellFormed(updated); + }); +}); + +describe('writeFb2Metadata large metadata blocks', () => { + it('writes a description larger than the initial read window', async () => { + const hugeAnnotation = `

${'x'.repeat(400 * 1024)}

`; + const path = await makeFile(fb2Content({ titleInfo: `${DEFAULT_TITLE_INFO}\n ${hugeAnnotation}` })); + const before = bodyRegion(await readFile(path, 'utf8')); + + const result = await write(path, { title: 'New Title' }); + + expect(result.status).toBe('success'); + const updated = await readFile(path, 'utf8'); + expect(updated).toContain('New Title'); + expect(updated).toContain('x'.repeat(400 * 1024)); + expect(bodyRegion(updated)).toBe(before); + }); + + it('writes metadata into a multi-megabyte file without altering the body', async () => { + const body = `
${'

Chapter text goes here.

'.repeat(120_000)}
`; + const path = await makeFile(fb2Content({ body })); + const before = bodyRegion(await readFile(path, 'utf8')); + expect(Buffer.byteLength(before)).toBeGreaterThan(3 * 1024 * 1024); + + const result = await write(path, { title: 'New Title', authors: [{ name: 'A B', sortName: 'B, A' }] }); + + expect(result.status).toBe('success'); + const updated = await readFile(path, 'utf8'); + expect(bodyRegion(updated)).toBe(before); + expect(updated).toContain('New Title'); + }); +}); diff --git a/server/src/modules/file-write/formats/fb2/fb2-write-core.ts b/server/src/modules/file-write/formats/fb2/fb2-write-core.ts new file mode 100644 index 000000000..271b5dc16 --- /dev/null +++ b/server/src/modules/file-write/formats/fb2/fb2-write-core.ts @@ -0,0 +1,206 @@ +import { createReadStream, createWriteStream } from 'fs'; +import { open, unlink } from 'fs/promises'; +import type { FileHandle } from 'fs/promises'; +import { dirname, join } from 'path'; +import { randomUUID } from 'crypto'; +import { pipeline } from 'stream/promises'; + +import { decodeFb2, detectFb2Encoding, encodeFb2, isFb2EncodingSupported, type Fb2Encoding } from '../../../../common/utils/fb2-encoding.utils'; +import type { BookWritePayload, BookWritePayloadKey } from '../../interfaces/book-write-payload.interface'; +import { replaceFileAtomically } from '../shared/atomic-file-replace'; +import { inspectCoverImage } from '../shared/cover-image'; +import { buildBinaryElement, findBinarySpan, findRootCloseOffset } from './fb2-binary-locator'; +import { buildFb2Description, readCoverEntryId } from './fb2-description-builder'; +import { Fb2StructureError, findElementClose, findTagEnd } from './fb2-element-scanner'; + +/** + * Metadata lives in the element at the very start of an FB2 file, + * so the writer only decodes a leading window instead of the whole book. + */ +const HEAD_WINDOW_BYTES = 256 * 1024; +const MAX_HEAD_WINDOW_BYTES = 8 * 1024 * 1024; +const DEFAULT_COVER_ENTRY_ID = 'bookorbit-cover'; +const STREAM_CHUNK_BYTES = 1024 * 1024; + +export type Fb2WriteOutcome = + { status: 'success'; fieldsWritten: BookWritePayloadKey[] } | { status: 'skipped'; reason: string; fieldsWritten: BookWritePayloadKey[] }; + +type SourceRange = { kind: 'range'; start: number; end: number }; +type LiteralBytes = { kind: 'bytes'; bytes: Buffer }; +type OutputPart = SourceRange | LiteralBytes; + +type DescriptionLocation = { + startByte: number; + endByte: number; + descriptionXml: string; + xlinkPrefix: string; +}; + +export async function writeFb2Metadata( + filePath: string, + payload: BookWritePayload, + fieldMask: Set, + options: { dryRun: boolean }, +): Promise { + const handle = await open(filePath, 'r'); + try { + const fileSize = (await handle.stat()).size; + const encoding = await readEncoding(handle, fileSize); + if (!isFb2EncodingSupported(encoding)) { + return { status: 'skipped', reason: `unsupported encoding: ${encoding.declared ?? encoding.name}`, fieldsWritten: [] }; + } + + let location: DescriptionLocation | null; + try { + location = await locateDescription(handle, fileSize, encoding); + } catch (error) { + if (error instanceof Fb2StructureError) { + return { status: 'skipped', reason: error.message, fieldsWritten: [] }; + } + throw error; + } + if (!location) { + return { status: 'skipped', reason: 'no description block found', fieldsWritten: [] }; + } + + const writesCover = Boolean(payload.coverBytes?.length) && fieldMask.has('coverBytes'); + const existingCoverEntryId = writesCover ? readCoverEntryId(location.descriptionXml, location.xlinkPrefix) : null; + const coverImage = writesCover ? await inspectCoverImage(payload.coverBytes!) : null; + const coverEntryId = writesCover ? (existingCoverEntryId ?? `${DEFAULT_COVER_ENTRY_ID}.${coverImage!.extension}`) : null; + + const built = buildFb2Description(location.descriptionXml, payload, fieldMask, { xlinkPrefix: location.xlinkPrefix, coverEntryId }); + if (built.status === 'skipped') { + return { status: 'skipped', reason: built.reason, fieldsWritten: [] }; + } + + const fieldsWritten = [...built.fieldsWritten]; + if (writesCover) fieldsWritten.push('coverBytes'); + + if (fieldsWritten.length === 0) { + return { status: 'skipped', reason: 'no metadata to write', fieldsWritten: [] }; + } + if (options.dryRun) { + return { status: 'skipped', reason: 'dry-run', fieldsWritten }; + } + + const parts: OutputPart[] = [ + { kind: 'range', start: 0, end: location.startByte }, + { kind: 'bytes', bytes: encodeFb2(built.descriptionXml, encoding) }, + ]; + + if (writesCover) { + const binaryXml = buildBinaryElement(coverEntryId!, coverImage!.mediaType, payload.coverBytes!); + const existingSpan = await findBinarySpan(handle, coverEntryId!, location.endByte, fileSize); + if (existingSpan) { + parts.push({ kind: 'range', start: location.endByte, end: existingSpan.start }); + parts.push({ kind: 'bytes', bytes: encodeFb2(binaryXml, encoding) }); + parts.push({ kind: 'range', start: existingSpan.end, end: fileSize }); + } else { + const rootCloseAt = await findRootCloseOffset(handle, fileSize); + if (rootCloseAt === null) { + return { status: 'skipped', reason: 'no closing FictionBook tag found', fieldsWritten: [] }; + } + parts.push({ kind: 'range', start: location.endByte, end: rootCloseAt }); + parts.push({ kind: 'bytes', bytes: encodeFb2(`${binaryXml}\n`, encoding) }); + parts.push({ kind: 'range', start: rootCloseAt, end: fileSize }); + } + } else { + parts.push({ kind: 'range', start: location.endByte, end: fileSize }); + } + + await writeParts(filePath, parts); + return { status: 'success', fieldsWritten }; + } finally { + await handle.close(); + } +} + +async function readEncoding(handle: FileHandle, fileSize: number): Promise { + const length = Math.min(1024, Math.max(fileSize, 1)); + const buffer = Buffer.alloc(length); + const { bytesRead } = await handle.read(buffer, 0, length, 0); + return detectFb2Encoding(buffer.subarray(0, bytesRead)); +} + +/** + * Reads a leading window and resolves the byte span of . The window + * grows when the block is unusually large (for example a very long annotation) + * and gives up rather than buffering an entire book. + */ +async function locateDescription(handle: FileHandle, fileSize: number, encoding: Fb2Encoding): Promise { + for (let windowBytes = HEAD_WINDOW_BYTES; ; windowBytes *= 4) { + const length = Math.min(windowBytes, fileSize); + const buffer = Buffer.alloc(Math.max(length, 1)); + const { bytesRead } = await handle.read(buffer, 0, length, 0); + const headText = decodeFb2(buffer.subarray(0, bytesRead), encoding); + + const descriptionAt = headText.search(/]/); + if (descriptionAt < 0) { + if (length >= fileSize || windowBytes >= MAX_HEAD_WINDOW_BYTES) return null; + continue; + } + + const bodyAt = headText.search(/]/); + if (bodyAt >= 0 && bodyAt < descriptionAt) { + throw new Fb2StructureError('body precedes description'); + } + + let endOffset: number; + try { + const contentStart = findTagEnd(headText, descriptionAt) + 1; + endOffset = findTagEnd(headText, findElementClose(headText, 'description', contentStart)) + 1; + } catch (error) { + const exhausted = length >= fileSize || windowBytes >= MAX_HEAD_WINDOW_BYTES; + if (exhausted) throw error; + continue; + } + + const xlinkPrefix = headText.match(/xmlns:([\w.-]+)\s*=\s*["']http:\/\/www\.w3\.org\/1999\/xlink["']/)?.[1] ?? 'l'; + + return { + startByte: encodeFb2(headText.slice(0, descriptionAt), encoding).length, + endByte: encodeFb2(headText.slice(0, endOffset), encoding).length, + descriptionXml: headText.slice(descriptionAt, endOffset), + xlinkPrefix, + }; + } +} + +/** Streams the assembled output to a sibling temp file, then swaps it in atomically. */ +async function writeParts(filePath: string, parts: OutputPart[]): Promise { + const tempPath = join(dirname(filePath), `.fb2-write-${randomUUID()}`); + const output = createWriteStream(tempPath); + + try { + for (const part of parts) { + if (part.kind === 'bytes') { + await writeBuffer(output, part.bytes); + continue; + } + if (part.end <= part.start) continue; + await pipeline(createReadStream(filePath, { start: part.start, end: part.end - 1, highWaterMark: STREAM_CHUNK_BYTES }), output, { + end: false, + }); + } + await closeStream(output); + } catch (error) { + output.destroy(); + await unlink(tempPath).catch(() => {}); + throw error; + } + + await replaceFileAtomically(tempPath, filePath); +} + +function writeBuffer(output: ReturnType, bytes: Buffer): Promise { + return new Promise((resolve, reject) => { + output.write(bytes, (error) => (error ? reject(error) : resolve())); + }); +} + +function closeStream(output: ReturnType): Promise { + return new Promise((resolve, reject) => { + output.end(() => resolve()); + output.once('error', reject); + }); +} diff --git a/server/src/modules/file-write/formats/fb2/fb2-write-roundtrip.test.ts b/server/src/modules/file-write/formats/fb2/fb2-write-roundtrip.test.ts new file mode 100644 index 000000000..44d2f6d67 --- /dev/null +++ b/server/src/modules/file-write/formats/fb2/fb2-write-roundtrip.test.ts @@ -0,0 +1,189 @@ +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import iconv from 'iconv-lite'; +import sharp from 'sharp'; + +import { FB2_BOOK_FILE_WRITE_FIELDS } from '@bookorbit/types'; +import { Fb2FormatExtractor } from '../../../metadata/extractors/fb2-format.extractor'; +import type { BookWritePayload, BookWritePayloadKey } from '../../interfaces/book-write-payload.interface'; +import { Fb2FormatWriter } from './fb2-format-writer'; + +const FULL_MASK = new Set(FB2_BOOK_FILE_WRITE_FIELDS); + +const SOURCE = [ + '', + '', + '', + ' ', + ' antique', + ' OldAuthor', + ' Old Title', + ' en', + ' ', + ' doc-1', + ' 1901', + '', + '

Chapter one.

', + '
', + '', +].join('\n'); + +let testRoot: string; +let writer: Fb2FormatWriter; +let extractor: Fb2FormatExtractor; +let cover: Buffer; + +beforeAll(async () => { + cover = await sharp({ create: { width: 10, height: 15, channels: 3, background: { r: 5, g: 100, b: 200 } } }) + .jpeg() + .toBuffer(); +}); + +beforeEach(async () => { + testRoot = await mkdtemp(join(tmpdir(), 'bookorbit-fb2-roundtrip-')); + writer = new Fb2FormatWriter(); + extractor = new Fb2FormatExtractor(); +}); + +afterEach(async () => { + await rm(testRoot, { recursive: true, force: true }); +}); + +async function writeAndRead(payload: BookWritePayload, content = SOURCE, encoding?: string) { + const path = join(testRoot, 'book.fb2'); + await writeFile(path, encoding ? iconv.encode(content, encoding) : Buffer.from(content, 'utf8')); + + const result = await writer.write(path, payload, { fieldMask: FULL_MASK, dryRun: false }); + expect(result.status).toBe('success'); + + const parsed = await extractor.extract(path); + if (!parsed) throw new Error('extractor returned null for a file it just wrote'); + return { parsed, result, path }; +} + +describe('FB2 write then extract round-trip', () => { + it('round-trips every field FB2 advertises as writable', async () => { + const payload: BookWritePayload = { + title: 'Nemesis & the "Sword" ', + subtitle: 'A Tale of Ünïcode', + description: 'First paragraph.\n\nSecond paragraph.', + publisher: 'Painted Quill Press & Sons', + publishedDate: '2018-03-14', + publishedYear: 2018, + language: 'ru', + pageCount: 412, + seriesName: 'Pathway of the Chosen', + seriesIndex: 2, + isbn13: '9781729419007', + isbn10: '1729419001', + rating: 4.5, + authors: [ + { name: 'Cat Bruno', sortName: 'Bruno, Cat' }, + { name: 'James David Victor', sortName: 'Victor, James David' }, + { name: 'Prince', sortName: null }, + ], + genres: ['Fantasy', 'Epic & Heroic'], + tags: ['favourite', 'to-reread'], + goodreadsId: '12345', + amazonId: 'B07XYZ', + hardcoverId: 'hc-9', + googleBooksId: 'gb-1', + openLibraryId: 'OL1W', + koboId: 'kobo-1', + itunesId: 'it-1', + coverBytes: cover, + }; + + const { parsed } = await writeAndRead(payload); + + expect(parsed.title).toBe(payload.title); + expect(parsed.subtitle).toBe(payload.subtitle); + expect(parsed.description).toContain('First paragraph.'); + expect(parsed.description).toContain('Second paragraph.'); + expect(parsed.publisher).toBe(payload.publisher); + expect(parsed.publishedDate).toBe('2018-03-14'); + expect(parsed.publishedYear).toBe(2018); + expect(parsed.language).toBe('ru'); + expect(parsed.pageCount).toBe(412); + expect(parsed.seriesName).toBe('Pathway of the Chosen'); + expect(parsed.seriesIndex).toBe(2); + expect(parsed.isbn13).toBe('9781729419007'); + expect(parsed.isbn10).toBe('1729419001'); + expect(parsed.rating).toBe(4.5); + expect(parsed.authors.map((author) => author.name)).toEqual(['Cat Bruno', 'James David Victor', 'Prince']); + expect(parsed.genres).toEqual(['Fantasy', 'Epic & Heroic']); + expect(parsed.tags).toEqual(['favourite', 'to-reread']); + expect(parsed.goodreadsId).toBe('12345'); + expect(parsed.amazonId).toBe('B07XYZ'); + expect(parsed.hardcoverId).toBe('hc-9'); + expect(parsed.googleBooksId).toBe('gb-1'); + expect(parsed.openLibraryId).toBe('OL1W'); + expect(parsed.koboId).toBe('kobo-1'); + expect(parsed.itunesId).toBe('it-1'); + expect(parsed.cover?.equals(cover)).toBe(true); + }); + + it('round-trips a fractional series index', async () => { + const { parsed } = await writeAndRead({ seriesName: 'Series', seriesIndex: 2.5 }); + expect(parsed.seriesIndex).toBe(2.5); + }); + + it('round-trips cyrillic metadata in a windows-1251 file', async () => { + const source = SOURCE.replace('encoding="UTF-8"', 'encoding="windows-1251"').replace('

Chapter one.

', '

В белом плаще.

'); + const { parsed, path } = await writeAndRead( + { title: 'Мастер и Маргарита', authors: [{ name: 'Михаил Булгаков', sortName: 'Булгаков, Михаил' }], seriesName: 'Классика' }, + source, + 'win1251', + ); + + expect(parsed.title).toBe('Мастер и Маргарита'); + expect(parsed.authors[0]?.name).toBe('Михаил Булгаков'); + expect(parsed.seriesName).toBe('Классика'); + expect(iconv.decode(await readFile(path), 'win1251')).toContain('В белом плаще.'); + }); + + it('round-trips a cover injected into a file that had none', async () => { + const { parsed } = await writeAndRead({ coverBytes: cover }); + expect(parsed.cover?.equals(cover)).toBe(true); + }); + + it('leaves untouched fields readable after a partial write', async () => { + const path = join(testRoot, 'book.fb2'); + await writeFile(path, SOURCE, 'utf8'); + + await writer.write(path, { title: 'Only Title Changed' }, { fieldMask: new Set(['title']), dryRun: false }); + + const parsed = await extractor.extract(path); + expect(parsed?.title).toBe('Only Title Changed'); + expect(parsed?.language).toBe('en'); + expect(parsed?.publishedYear).toBe(1901); + expect(parsed?.authors.map((author) => author.name)).toEqual(['Old Author']); + expect(parsed?.genres).toEqual(['antique']); + }); + + it('re-extracts identical metadata after a second identical write, so scans do not churn', async () => { + const payload: BookWritePayload = { + title: 'Stable', + authors: [{ name: 'A B', sortName: 'B, A' }], + genres: ['Fantasy'], + tags: ['x'], + language: 'en', + publishedDate: '2010-05-06', + publisher: 'Pub', + seriesName: 'S', + seriesIndex: 1, + rating: 3, + coverBytes: cover, + }; + const { parsed: first, path } = await writeAndRead(payload); + const bytesAfterFirst = await readFile(path); + + await writer.write(path, payload, { fieldMask: FULL_MASK, dryRun: false }); + const second = await extractor.extract(path); + + expect((await readFile(path)).equals(bytesAfterFirst)).toBe(true); + expect(second).toEqual(first); + }); +}); diff --git a/server/src/modules/file-write/formats/shared/cover-image.ts b/server/src/modules/file-write/formats/shared/cover-image.ts new file mode 100644 index 000000000..bb4a7591b --- /dev/null +++ b/server/src/modules/file-write/formats/shared/cover-image.ts @@ -0,0 +1,34 @@ +import sharp from 'sharp'; + +const FORMAT_DETAILS = { + jpeg: { mediaType: 'image/jpeg', extension: 'jpg' }, + png: { mediaType: 'image/png', extension: 'png' }, + gif: { mediaType: 'image/gif', extension: 'gif' }, + webp: { mediaType: 'image/webp', extension: 'webp' }, + tiff: { mediaType: 'image/tiff', extension: 'tiff' }, + svg: { mediaType: 'image/svg+xml', extension: 'svg' }, + jp2: { mediaType: 'image/jp2', extension: 'jp2' }, + jxl: { mediaType: 'image/jxl', extension: 'jxl' }, +} as const; + +export interface CoverImageInfo { + mediaType: string; + extension: string; +} + +export async function inspectCoverImage(bytes: Buffer): Promise { + const metadata = await sharp(bytes, { failOn: 'error' }).metadata(); + const knownFormat = metadata.format ? FORMAT_DETAILS[metadata.format as keyof typeof FORMAT_DETAILS] : undefined; + if (knownFormat) return knownFormat; + + if (metadata.format === 'heif') { + return metadata.compression === 'av1' ? { mediaType: 'image/avif', extension: 'avif' } : { mediaType: 'image/heic', extension: 'heic' }; + } + + if (isBitmap(bytes)) return { mediaType: 'image/bmp', extension: 'bmp' }; + throw new Error(`Unsupported cover image format: ${metadata.format || 'unknown'}`); +} + +function isBitmap(bytes: Buffer): boolean { + return bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d; +} diff --git a/server/src/modules/library/dto/create-library.dto.ts b/server/src/modules/library/dto/create-library.dto.ts index 3d353d5f6..51a66b615 100644 --- a/server/src/modules/library/dto/create-library.dto.ts +++ b/server/src/modules/library/dto/create-library.dto.ts @@ -135,6 +135,16 @@ export class CreateLibraryDto { @Max(LIBRARY_FILE_WRITE_MAX_SIZE_MB_MAX) fileWriteEpubMaxFileSizeMb?: number; + @IsOptional() + @IsBoolean() + fileWriteFb2Enabled?: boolean; + + @IsOptional() + @IsInt() + @Min(LIBRARY_FILE_WRITE_MAX_SIZE_MB_MIN) + @Max(LIBRARY_FILE_WRITE_MAX_SIZE_MB_MAX) + fileWriteFb2MaxFileSizeMb?: number; + @IsOptional() @IsBoolean() fileWritePdfEnabled?: boolean; diff --git a/server/src/modules/library/dto/update-library.dto.ts b/server/src/modules/library/dto/update-library.dto.ts index 0d56b084e..48221cd6f 100644 --- a/server/src/modules/library/dto/update-library.dto.ts +++ b/server/src/modules/library/dto/update-library.dto.ts @@ -136,6 +136,16 @@ export class UpdateLibraryDto { @Max(LIBRARY_FILE_WRITE_MAX_SIZE_MB_MAX) fileWriteEpubMaxFileSizeMb?: number; + @IsOptional() + @IsBoolean() + fileWriteFb2Enabled?: boolean; + + @IsOptional() + @IsInt() + @Min(LIBRARY_FILE_WRITE_MAX_SIZE_MB_MIN) + @Max(LIBRARY_FILE_WRITE_MAX_SIZE_MB_MAX) + fileWriteFb2MaxFileSizeMb?: number; + @IsOptional() @IsBoolean() fileWritePdfEnabled?: boolean; diff --git a/server/src/modules/library/library.service.ts b/server/src/modules/library/library.service.ts index 38e699ae4..c47f54fc6 100644 --- a/server/src/modules/library/library.service.ts +++ b/server/src/modules/library/library.service.ts @@ -129,6 +129,8 @@ export class LibraryService { fileWriteWriteCover: dto.fileWriteWriteCover ?? true, fileWriteEpubEnabled: dto.fileWriteEpubEnabled ?? true, fileWriteEpubMaxFileSizeMb: dto.fileWriteEpubMaxFileSizeMb ?? 100, + fileWriteFb2Enabled: dto.fileWriteFb2Enabled ?? false, + fileWriteFb2MaxFileSizeMb: dto.fileWriteFb2MaxFileSizeMb ?? 100, fileWritePdfEnabled: dto.fileWritePdfEnabled ?? true, fileWritePdfMaxFileSizeMb: dto.fileWritePdfMaxFileSizeMb ?? 100, fileWriteCbxEnabled: dto.fileWriteCbxEnabled ?? false, diff --git a/server/src/modules/metadata/extractors/fb2-format.extractor.ts b/server/src/modules/metadata/extractors/fb2-format.extractor.ts index 21f16cf0e..f2ed21d50 100644 --- a/server/src/modules/metadata/extractors/fb2-format.extractor.ts +++ b/server/src/modules/metadata/extractors/fb2-format.extractor.ts @@ -2,19 +2,46 @@ import { extractFb2Cover } from '../lib/cover-fb2'; import { parseFb2File } from '../lib/fb2-parser'; import type { FormatExtractor, ParsedBookData } from './format-extractor.interface'; +function toNumber(value: string | undefined): number | null { + if (!value) return null; + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : null; +} + export class Fb2FormatExtractor implements FormatExtractor { async extract(absolutePath: string): Promise { const [fb2, cover] = await Promise.all([parseFb2File(absolutePath), extractFb2Cover(absolutePath).catch(() => null)]); if (!fb2) return null; + + const custom = fb2.custom; return { title: fb2.title, + subtitle: custom.subtitle ?? null, description: fb2.description, + publisher: fb2.publisher, + publishedDate: fb2.publishedDate, publishedYear: fb2.publishedYear, language: fb2.language, seriesName: fb2.seriesName, seriesIndex: fb2.seriesIndex, authors: fb2.authors, genres: fb2.genres, + tags: fb2.tags, + isbn13: fb2.isbn13, + isbn10: custom.isbn10 ?? null, + rating: toNumber(custom.rating), + pageCount: toNumber(custom.pageCount), + googleBooksId: custom.googleBooksId ?? null, + goodreadsId: custom.goodreadsId ?? null, + amazonId: custom.amazonId ?? null, + hardcoverId: custom.hardcoverId ?? null, + hardcoverEditionId: custom.hardcoverEditionId ?? null, + openLibraryId: custom.openLibraryId ?? null, + ranobedbId: custom.ranobedbId ?? null, + koboId: custom.koboId ?? null, + lubimyczytacId: custom.lubimyczytacId ?? null, + aladinId: custom.aladinId ?? null, + itunesId: custom.itunesId ?? null, cover: cover ?? null, }; } diff --git a/server/src/modules/metadata/extractors/format-extractors.test.ts b/server/src/modules/metadata/extractors/format-extractors.test.ts index 37acd04af..cfd1a00b4 100644 --- a/server/src/modules/metadata/extractors/format-extractors.test.ts +++ b/server/src/modules/metadata/extractors/format-extractors.test.ts @@ -286,12 +286,17 @@ describe('metadata format extractors', () => { mockParseFb2File.mockResolvedValue({ title: 'Roadside Picnic', description: 'Sci-fi classic', + publishedDate: null, publishedYear: 1972, language: 'ru', seriesName: null, seriesIndex: null, authors: [{ name: 'Arkady Strugatsky', sortName: null }], genres: ['Science Fiction'], + tags: [], + publisher: null, + isbn13: null, + custom: {}, }); mockExtractFb2Cover.mockRejectedValue(new Error('missing binary')); @@ -304,6 +309,65 @@ describe('metadata format extractors', () => { ); }); + it('fb2 extractor maps namespaced custom-info onto book fields', async () => { + mockParseFb2File.mockResolvedValue({ + title: 'Nemesis', + description: null, + publishedDate: '2018-03-14', + publishedYear: 2018, + language: 'en', + seriesName: 'Pathway', + seriesIndex: 2, + authors: [], + genres: [], + tags: ['favourite'], + publisher: 'Painted Quill Press', + isbn13: '9781729419007', + custom: { subtitle: 'A Tale', rating: '4.5', pageCount: '412', goodreadsId: '12345', isbn10: '1729419001' }, + }); + mockExtractFb2Cover.mockResolvedValue(Buffer.from('cover')); + + const result = await new Fb2FormatExtractor().extract('/books/test.fb2'); + + expect(result).toEqual( + expect.objectContaining({ + subtitle: 'A Tale', + rating: 4.5, + pageCount: 412, + goodreadsId: '12345', + isbn10: '1729419001', + isbn13: '9781729419007', + publisher: 'Painted Quill Press', + publishedDate: '2018-03-14', + tags: ['favourite'], + }), + ); + }); + + it('fb2 extractor leaves numeric custom-info null when the stored value is not a number', async () => { + mockParseFb2File.mockResolvedValue({ + title: 'Nemesis', + description: null, + publishedDate: null, + publishedYear: null, + language: null, + seriesName: null, + seriesIndex: null, + authors: [], + genres: [], + tags: [], + publisher: null, + isbn13: null, + custom: { rating: 'not-a-number', pageCount: '' }, + }); + mockExtractFb2Cover.mockResolvedValue(null); + + const result = await new Fb2FormatExtractor().extract('/books/test.fb2'); + + expect(result?.rating).toBeNull(); + expect(result?.pageCount).toBeNull(); + }); + it('comic extractor uses filename fallback when embedded metadata is missing', async () => { mockExtractCbzMetadata.mockResolvedValue(null); mockExtractCbzCover.mockResolvedValue(Buffer.from('cover')); diff --git a/server/src/modules/metadata/lib/cover-fb2.test.ts b/server/src/modules/metadata/lib/cover-fb2.test.ts index cdccf9685..a8f0f1cb6 100644 --- a/server/src/modules/metadata/lib/cover-fb2.test.ts +++ b/server/src/modules/metadata/lib/cover-fb2.test.ts @@ -20,18 +20,18 @@ describe('extractFb2Cover', () => { AQIDBA==
`; - mockReadFile.mockResolvedValue(xml as unknown as Awaited>); + mockReadFile.mockResolvedValue(Buffer.from(xml, 'utf8') as unknown as Awaited>); await expect(extractFb2Cover('/book.fb2')).resolves.toEqual(Buffer.from([1, 2, 3, 4])); }); it('returns null when coverpage or referenced binary is missing', async () => { - mockReadFile.mockResolvedValue('' as any); + mockReadFile.mockResolvedValue(Buffer.from('') as any); await expect(extractFb2Cover('/book.fb2')).resolves.toBeNull(); }); it('returns null on malformed XML', async () => { - mockReadFile.mockResolvedValue(' { try { - const xml = await readFile(absolutePath, 'utf-8'); + const { text: xml } = decodeFb2Document(await readFile(absolutePath)); const doc = parser.parse(xml) as Record; const fb = (doc['FictionBook'] ?? doc['fictionbook']) as Record | undefined; diff --git a/server/src/modules/metadata/lib/fb2-parser.test.ts b/server/src/modules/metadata/lib/fb2-parser.test.ts index 60e13c178..79037558a 100644 --- a/server/src/modules/metadata/lib/fb2-parser.test.ts +++ b/server/src/modules/metadata/lib/fb2-parser.test.ts @@ -1,10 +1,16 @@ vi.mock('fs/promises'); import { readFile } from 'fs/promises'; +import iconv from 'iconv-lite'; + import { parseFb2File } from './fb2-parser'; const mockReadFile = readFile as MockedFunction; +function fb2Buffer(titleInfo: string, publishInfo = '', description = ''): Buffer { + return Buffer.from(makeFb2(titleInfo, publishInfo, description), 'utf8'); +} + function makeFb2(titleInfo: string, publishInfo = '', description = ''): string { return ` @@ -25,14 +31,14 @@ beforeEach(() => { describe('parseFb2File', () => { describe('title', () => { it('extracts book title', async () => { - mockReadFile.mockResolvedValue(makeFb2('War and Peace') as unknown as Buffer); + mockReadFile.mockResolvedValue(fb2Buffer('War and Peace')); const r = await parseFb2File('/book.fb2'); expect(r?.title).toBe('War and Peace'); }); it('returns null title when book-title element is absent (other fields present)', async () => { // title-info with only a lang tag — titleInfo is truthy but book-title is missing - mockReadFile.mockResolvedValue(makeFb2('en') as unknown as Buffer); + mockReadFile.mockResolvedValue(fb2Buffer('en')); const r = await parseFb2File('/book.fb2'); expect(r?.title).toBeNull(); }); @@ -41,12 +47,12 @@ describe('parseFb2File', () => { describe('authors', () => { it('constructs author name from first + last name', async () => { mockReadFile.mockResolvedValue( - makeFb2(` + fb2Buffer(` Leo Tolstoy - `) as unknown as Buffer, + `), ); const r = await parseFb2File('/book.fb2'); expect(r?.authors).toHaveLength(1); @@ -56,13 +62,13 @@ describe('parseFb2File', () => { it('includes middle name in display name', async () => { mockReadFile.mockResolvedValue( - makeFb2(` + fb2Buffer(` John Ronald Reuel Tolkien - `) as unknown as Buffer, + `), ); const r = await parseFb2File('/book.fb2'); expect(r?.authors[0].name).toBe('John Ronald Reuel Tolkien'); @@ -72,11 +78,11 @@ describe('parseFb2File', () => { it('falls back to nickname when no name parts', async () => { mockReadFile.mockResolvedValue( - makeFb2(` + fb2Buffer(` Voltaire - `) as unknown as Buffer, + `), ); const r = await parseFb2File('/book.fb2'); expect(r?.authors[0].name).toBe('Voltaire'); @@ -85,17 +91,17 @@ describe('parseFb2File', () => { it('parses multiple authors', async () => { mockReadFile.mockResolvedValue( - makeFb2(` + fb2Buffer(` AuthorOne AuthorTwo - `) as unknown as Buffer, + `), ); const r = await parseFb2File('/book.fb2'); expect(r?.authors).toHaveLength(2); }); it('returns empty authors array when no author elements', async () => { - mockReadFile.mockResolvedValue(makeFb2('Anon') as unknown as Buffer); + mockReadFile.mockResolvedValue(fb2Buffer('Anon')); const r = await parseFb2File('/book.fb2'); expect(r?.authors).toHaveLength(0); }); @@ -103,13 +109,13 @@ describe('parseFb2File', () => { describe('genres', () => { it('extracts single genre', async () => { - mockReadFile.mockResolvedValue(makeFb2('sci-fi') as unknown as Buffer); + mockReadFile.mockResolvedValue(fb2Buffer('sci-fi')); const r = await parseFb2File('/book.fb2'); expect(r?.genres).toContain('sci-fi'); }); it('extracts multiple genres', async () => { - mockReadFile.mockResolvedValue(makeFb2('sci-fiadventure') as unknown as Buffer); + mockReadFile.mockResolvedValue(fb2Buffer('sci-fiadventure')); const r = await parseFb2File('/book.fb2'); expect(r?.genres).toEqual(['sci-fi', 'adventure']); }); @@ -117,26 +123,26 @@ describe('parseFb2File', () => { describe('language', () => { it('extracts language code', async () => { - mockReadFile.mockResolvedValue(makeFb2('ru') as unknown as Buffer); + mockReadFile.mockResolvedValue(fb2Buffer('ru')); expect((await parseFb2File('/book.fb2'))?.language).toBe('ru'); }); }); describe('series', () => { it('extracts series name and index from sequence element', async () => { - mockReadFile.mockResolvedValue(makeFb2('') as unknown as Buffer); + mockReadFile.mockResolvedValue(fb2Buffer('')); const r = await parseFb2File('/book.fb2'); expect(r?.seriesName).toBe('The Dark Tower'); expect(r?.seriesIndex).toBe(1); }); it('parses float series index', async () => { - mockReadFile.mockResolvedValue(makeFb2('') as unknown as Buffer); + mockReadFile.mockResolvedValue(fb2Buffer('')); expect((await parseFb2File('/book.fb2'))?.seriesIndex).toBe(1.5); }); it('returns null seriesName when no sequence element', async () => { - mockReadFile.mockResolvedValue(makeFb2('Standalone') as unknown as Buffer); + mockReadFile.mockResolvedValue(fb2Buffer('Standalone')); const r = await parseFb2File('/book.fb2'); expect(r?.seriesName).toBeNull(); expect(r?.seriesIndex).toBeNull(); @@ -145,40 +151,40 @@ describe('parseFb2File', () => { describe('publishedYear', () => { it('extracts year from publish-info/year', async () => { - mockReadFile.mockResolvedValue(makeFb2('Book', '1869') as unknown as Buffer); + mockReadFile.mockResolvedValue(fb2Buffer('Book', '1869')); expect((await parseFb2File('/book.fb2'))?.publishedYear).toBe(1869); }); it('rejects years outside 1000-2200 range', async () => { - mockReadFile.mockResolvedValue(makeFb2('Book', '900') as unknown as Buffer); + mockReadFile.mockResolvedValue(fb2Buffer('Book', '900')); expect((await parseFb2File('/book.fb2'))?.publishedYear).toBeNull(); }); it('rejects years above 2200', async () => { - mockReadFile.mockResolvedValue(makeFb2('Book', '2500') as unknown as Buffer); + mockReadFile.mockResolvedValue(fb2Buffer('Book', '2500')); expect((await parseFb2File('/book.fb2'))?.publishedYear).toBeNull(); }); it('falls back to title-info/date when publish-info/year absent', async () => { - mockReadFile.mockResolvedValue(makeFb2('Book1984') as unknown as Buffer); + mockReadFile.mockResolvedValue(fb2Buffer('Book1984')); expect((await parseFb2File('/book.fb2'))?.publishedYear).toBe(1984); }); it('parses year from title-info/date value attribute', async () => { - mockReadFile.mockResolvedValue(makeFb2('Book') as unknown as Buffer); + mockReadFile.mockResolvedValue(fb2Buffer('Book')); expect((await parseFb2File('/book.fb2'))?.publishedYear).toBe(1984); }); }); describe('description (annotation)', () => { it('extracts plain string annotation', async () => { - mockReadFile.mockResolvedValue(makeFb2('Plain description text.') as unknown as Buffer); + mockReadFile.mockResolvedValue(fb2Buffer('Plain description text.')); expect((await parseFb2File('/book.fb2'))?.description).toBe('Plain description text.'); }); it('extracts text from structured annotation with paragraph tags', async () => { // Bug regression: previously JSON.stringify was used, producing {"p":"text"} - mockReadFile.mockResolvedValue(makeFb2('

Description with paragraph.

') as unknown as Buffer); + mockReadFile.mockResolvedValue(fb2Buffer('

Description with paragraph.

')); const r = await parseFb2File('/book.fb2'); expect(r?.description).not.toContain('{'); expect(r?.description).not.toContain('"p":'); @@ -186,11 +192,147 @@ describe('parseFb2File', () => { }); it('returns null description when annotation absent', async () => { - mockReadFile.mockResolvedValue(makeFb2('No Desc') as unknown as Buffer); + mockReadFile.mockResolvedValue(fb2Buffer('No Desc')); expect((await parseFb2File('/book.fb2'))?.description).toBeNull(); }); }); + describe('publishedDate', () => { + it('prefers a full date in title-info over a bare publish-info year', async () => { + mockReadFile.mockResolvedValue(fb2Buffer('2018-03-14', '2018')); + const r = await parseFb2File('/book.fb2'); + expect(r?.publishedDate).toBe('2018-03-14'); + expect(r?.publishedYear).toBe(2018); + }); + + it('keeps the publish-info year when the years disagree', async () => { + mockReadFile.mockResolvedValue(fb2Buffer('2018-03-14', '1975')); + const r = await parseFb2File('/book.fb2'); + expect(r?.publishedDate).toBe('2018-03-14'); + expect(r?.publishedYear).toBe(1975); + }); + + it('returns a null date when only a year is recorded', async () => { + mockReadFile.mockResolvedValue(fb2Buffer('Book', '1999')); + const r = await parseFb2File('/book.fb2'); + expect(r?.publishedDate).toBeNull(); + expect(r?.publishedYear).toBe(1999); + }); + + it('reads a full date from title-info when publish-info is absent', async () => { + mockReadFile.mockResolvedValue(fb2Buffer('2001-02-03')); + expect((await parseFb2File('/book.fb2'))?.publishedDate).toBe('2001-02-03'); + }); + }); + + describe('publish-info fields', () => { + it('extracts publisher and isbn', async () => { + mockReadFile.mockResolvedValue(fb2Buffer('Book', 'Kensington Books9781729419007')); + const r = await parseFb2File('/book.fb2'); + expect(r?.publisher).toBe('Kensington Books'); + expect(r?.isbn13).toBe('9781729419007'); + }); + + it('returns nulls when publish-info is absent', async () => { + mockReadFile.mockResolvedValue(fb2Buffer('Book')); + const r = await parseFb2File('/book.fb2'); + expect(r?.publisher).toBeNull(); + expect(r?.isbn13).toBeNull(); + }); + }); + + describe('tags (keywords)', () => { + it('splits keywords on commas', async () => { + mockReadFile.mockResolvedValue(fb2Buffer('favourite, to-reread , sci-fi')); + expect((await parseFb2File('/book.fb2'))?.tags).toEqual(['favourite', 'to-reread', 'sci-fi']); + }); + + it('returns an empty list when keywords are absent', async () => { + mockReadFile.mockResolvedValue(fb2Buffer('Book')); + expect((await parseFb2File('/book.fb2'))?.tags).toEqual([]); + }); + }); + + describe('custom-info', () => { + function withCustomInfo(entries: string): Buffer { + return Buffer.from( + ` + + + Book + ${entries} + +`, + 'utf8', + ); + } + + it('reads namespaced fields written by BookOrbit', async () => { + mockReadFile.mockResolvedValue( + withCustomInfo( + [ + 'A Subtitle', + '4.5', + '412', + '12345', + '1729419001', + ].join('\n '), + ), + ); + const r = await parseFb2File('/book.fb2'); + expect(r?.custom).toEqual({ + subtitle: 'A Subtitle', + rating: '4.5', + pageCount: '412', + goodreadsId: '12345', + isbn10: '1729419001', + }); + }); + + it('ignores custom-info written by other tools', async () => { + mockReadFile.mockResolvedValue(withCustomInfo('abc-123')); + expect((await parseFb2File('/book.fb2'))?.custom).toEqual({}); + }); + + it('ignores namespaced keys it does not recognise', async () => { + mockReadFile.mockResolvedValue(withCustomInfo('x')); + expect((await parseFb2File('/book.fb2'))?.custom).toEqual({}); + }); + + it('returns an empty object when there is no custom-info', async () => { + mockReadFile.mockResolvedValue(fb2Buffer('Book')); + expect((await parseFb2File('/book.fb2'))?.custom).toEqual({}); + }); + }); + + describe('encodings', () => { + it('decodes a windows-1251 document using its declared encoding', async () => { + const xml = ` + + + МихаилБулгаков + Мастер и Маргарита + +`; + mockReadFile.mockResolvedValue(iconv.encode(xml, 'win1251')); + const r = await parseFb2File('/book.fb2'); + expect(r?.title).toBe('Мастер и Маргарита'); + expect(r?.authors[0]?.name).toBe('Михаил Булгаков'); + }); + + it('decodes a koi8-r document', async () => { + const xml = ` +Пикник`; + mockReadFile.mockResolvedValue(iconv.encode(xml, 'koi8-r')); + expect((await parseFb2File('/book.fb2'))?.title).toBe('Пикник'); + }); + + it('still reads utf-8 documents', async () => { + mockReadFile.mockResolvedValue(fb2Buffer('Über den Bergen')); + expect((await parseFb2File('/book.fb2'))?.title).toBe('Über den Bergen'); + }); + }); + describe('error handling', () => { it('returns null when file read throws', async () => { mockReadFile.mockRejectedValue(new Error('ENOENT')); @@ -198,12 +340,12 @@ describe('parseFb2File', () => { }); it('returns null when XML has no FictionBook root', async () => { - mockReadFile.mockResolvedValue('' as unknown as Buffer); + mockReadFile.mockResolvedValue(Buffer.from('')); expect(await parseFb2File('/bad.fb2')).toBeNull(); }); it('returns null when title-info is missing', async () => { - mockReadFile.mockResolvedValue(`` as unknown as Buffer); + mockReadFile.mockResolvedValue(Buffer.from('')); expect(await parseFb2File('/bad.fb2')).toBeNull(); }); }); diff --git a/server/src/modules/metadata/lib/fb2-parser.ts b/server/src/modules/metadata/lib/fb2-parser.ts index 7acbaedfa..88e5bb174 100644 --- a/server/src/modules/metadata/lib/fb2-parser.ts +++ b/server/src/modules/metadata/lib/fb2-parser.ts @@ -1,6 +1,8 @@ import { readFile } from 'fs/promises'; import { XMLParser } from 'fast-xml-parser'; +import { BOOKORBIT_NS_PREFIX } from '../../../common/bookorbit-ns'; +import { decodeFb2Document } from '../../../common/utils/fb2-encoding.utils'; import { parsePublishedDateKey } from '../../../common/utils/published-date.utils'; const parser = new XMLParser({ @@ -86,6 +88,27 @@ function parseFb2DateNode(val: unknown): string | null { return null; } +/** Fields BookOrbit writes into . */ +const CUSTOM_INFO_FIELDS = [ + 'subtitle', + 'pageCount', + 'rating', + 'isbn10', + 'googleBooksId', + 'goodreadsId', + 'amazonId', + 'hardcoverId', + 'hardcoverEditionId', + 'openLibraryId', + 'ranobedbId', + 'koboId', + 'lubimyczytacId', + 'aladinId', + 'itunesId', +] as const; + +export type Fb2CustomInfoField = (typeof CUSTOM_INFO_FIELDS)[number]; + export interface Fb2Metadata { title: string | null; description: string | null; @@ -96,11 +119,36 @@ export interface Fb2Metadata { seriesIndex: number | null; authors: { name: string; sortName: string | null }[]; genres: string[]; + tags: string[]; + publisher: string | null; + isbn13: string | null; + custom: Partial>; +} + +function parseCustomInfo(description: Record | undefined): Partial> { + const custom: Partial> = {}; + if (!description) return custom; + + for (const entry of toArray(description['custom-info'])) { + if (typeof entry !== 'object' || entry === null) continue; + const infoType = text((entry as Record)['@_info-type']); + if (!infoType?.startsWith(`${BOOKORBIT_NS_PREFIX}:`)) continue; + + const field = infoType.slice(BOOKORBIT_NS_PREFIX.length + 1) as Fb2CustomInfoField; + if (!CUSTOM_INFO_FIELDS.includes(field)) continue; + + const value = text(entry); + if (value) custom[field] = value; + } + + return custom; } export async function parseFb2File(absolutePath: string): Promise { try { - const xml = await readFile(absolutePath, 'utf-8'); + // FB2 is commonly published in windows-1251 or koi8-r, so honour the + // encoding declared in the file instead of assuming UTF-8. + const { text: xml } = decodeFb2Document(await readFile(absolutePath)); const doc = parser.parse(xml) as Record; const fb = (doc['FictionBook'] ?? doc['fictionbook']) as Record | undefined; @@ -151,12 +199,13 @@ export async function parseFb2File(absolutePath: string): Promise or - let publishedYear: number | null = null; + // usually carries only a year, so a full date in + // wins when it is available. const publishInfo = description?.['publish-info'] as Record | undefined; - const yearRaw = publishInfo?.['year'] ?? titleInfo['date']; - const publishedDate = parseFb2DateNode(yearRaw); - publishedYear = parseFb2YearNode(yearRaw); + const titleInfoDate = titleInfo['date']; + const publishInfoYear = publishInfo?.['year']; + const publishedDate = parseFb2DateNode(titleInfoDate) ?? parseFb2DateNode(publishInfoYear); + const publishedYear = parseFb2YearNode(publishInfoYear) ?? parseFb2YearNode(titleInfoDate); // Annotation (description) let annotationDescription: string | null = null; @@ -166,7 +215,29 @@ export async function parseFb2File(absolutePath: string): Promise tag.trim()) + .filter(Boolean) + : []; + + return { + title, + description: annotationDescription, + language, + publishedDate, + publishedYear, + seriesName, + seriesIndex, + authors, + genres, + tags, + publisher: text(publishInfo?.['publisher']), + isbn13: text(publishInfo?.['isbn']), + custom: parseCustomInfo(description), + }; } catch { return null; } diff --git a/server/test/e2e/metadata-write/metadata-write-fixture-builder.ts b/server/test/e2e/metadata-write/metadata-write-fixture-builder.ts index bf72ab7a2..da74708b8 100644 --- a/server/test/e2e/metadata-write/metadata-write-fixture-builder.ts +++ b/server/test/e2e/metadata-write/metadata-write-fixture-builder.ts @@ -26,6 +26,12 @@ interface EpubFixtureInput { uid?: string; } +interface Fb2FixtureInput { + title?: string; + language?: string; + bodyText?: string; +} + interface ComicFixtureInput { title?: string; author?: string; @@ -116,6 +122,31 @@ export async function createEpubFixture(rootPath: string, relativePath: string, return absolutePath; } +export async function createFb2Fixture(rootPath: string, relativePath: string, input: Fb2FixtureInput = {}): Promise { + const title = input.title ?? 'Fixture FB2 Title'; + const language = input.language ?? 'en'; + const bodyText = input.bodyText ?? 'fixture body text'; + + const xml = [ + '', + '', + '', + ' ', + ' antique', + ' FixtureAuthor', + ` ${escapeXml(title)}`, + ` ${escapeXml(language)}`, + ' ', + ' fixture-doc', + '', + `

${escapeXml(bodyText)}

`, + '
', + '', + ].join('\n'); + + return writeFixtureFile(rootPath, relativePath, Buffer.from(xml, 'utf8')); +} + export async function createCbzFixture(rootPath: string, relativePath: string, input: ComicFixtureInput = {}): Promise { assertRelativePath(relativePath); const absolutePath = join(rootPath, relativePath); diff --git a/server/test/e2e/metadata-write/metadata-write-harness.ts b/server/test/e2e/metadata-write/metadata-write-harness.ts index d9dca0f96..87332595b 100644 --- a/server/test/e2e/metadata-write/metadata-write-harness.ts +++ b/server/test/e2e/metadata-write/metadata-write-harness.ts @@ -65,6 +65,8 @@ export type LibraryFileWritePatch = { fileWriteWriteCover?: boolean; fileWriteEpubEnabled?: boolean; fileWriteEpubMaxFileSizeMb?: number; + fileWriteFb2Enabled?: boolean; + fileWriteFb2MaxFileSizeMb?: number; fileWritePdfEnabled?: boolean; fileWritePdfMaxFileSizeMb?: number; fileWriteCbxEnabled?: boolean; @@ -132,6 +134,8 @@ export async function createLibraryWithFolder( fileWriteEnabled?: boolean; fileWriteWriteCover?: boolean; fileWriteEpubEnabled?: boolean; + fileWriteFb2Enabled?: boolean; + fileWriteFb2MaxFileSizeMb?: number; fileWritePdfEnabled?: boolean; fileWriteCbxEnabled?: boolean; fileWriteCbxMaxFileSizeMb?: number; @@ -155,6 +159,8 @@ export async function createLibraryWithFolder( fileWriteEnabled: options.fileWriteEnabled ?? false, fileWriteWriteCover: options.fileWriteWriteCover ?? true, fileWriteEpubEnabled: options.fileWriteEpubEnabled ?? true, + fileWriteFb2Enabled: options.fileWriteFb2Enabled ?? false, + fileWriteFb2MaxFileSizeMb: options.fileWriteFb2MaxFileSizeMb ?? 100, fileWritePdfEnabled: options.fileWritePdfEnabled ?? true, fileWriteCbxEnabled: options.fileWriteCbxEnabled ?? false, fileWriteCbxMaxFileSizeMb: options.fileWriteCbxMaxFileSizeMb ?? 500, @@ -184,6 +190,8 @@ export async function setLibraryFileWriteSettings(db: Db, libraryId: number, pat if (patch.fileWriteWriteCover !== undefined) updateValues.fileWriteWriteCover = patch.fileWriteWriteCover; if (patch.fileWriteEpubEnabled !== undefined) updateValues.fileWriteEpubEnabled = patch.fileWriteEpubEnabled; if (patch.fileWriteEpubMaxFileSizeMb !== undefined) updateValues.fileWriteEpubMaxFileSizeMb = patch.fileWriteEpubMaxFileSizeMb; + if (patch.fileWriteFb2Enabled !== undefined) updateValues.fileWriteFb2Enabled = patch.fileWriteFb2Enabled; + if (patch.fileWriteFb2MaxFileSizeMb !== undefined) updateValues.fileWriteFb2MaxFileSizeMb = patch.fileWriteFb2MaxFileSizeMb; if (patch.fileWritePdfEnabled !== undefined) updateValues.fileWritePdfEnabled = patch.fileWritePdfEnabled; if (patch.fileWritePdfMaxFileSizeMb !== undefined) updateValues.fileWritePdfMaxFileSizeMb = patch.fileWritePdfMaxFileSizeMb; if (patch.fileWriteCbxEnabled !== undefined) updateValues.fileWriteCbxEnabled = patch.fileWriteCbxEnabled; diff --git a/server/test/metadata-write.e2e-spec.ts b/server/test/metadata-write.e2e-spec.ts index 7b93a3eab..c94e32ac6 100644 --- a/server/test/metadata-write.e2e-spec.ts +++ b/server/test/metadata-write.e2e-spec.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'crypto'; -import { mkdir, writeFile } from 'fs/promises'; +import { mkdir, readFile, writeFile } from 'fs/promises'; import { dirname, join } from 'path'; import { eq } from 'drizzle-orm'; @@ -10,6 +10,7 @@ import { BookService, type MetadataUpdateFailpoint } from '../src/modules/book/b import { FileWriteService } from '../src/modules/file-write/file-write.service'; import { extractCb7Metadata, extractCbzMetadata } from '../src/modules/metadata/lib/cbz-metadata'; import { extractEpubMetadata } from '../src/modules/metadata/lib/epub'; +import { parseFb2File } from '../src/modules/metadata/lib/fb2-parser'; import { parsePdfFile } from '../src/modules/metadata/lib/pdf-parser'; import { authHeader, @@ -30,6 +31,7 @@ import { createCb7Fixture, createCbzFixture, createEpubFixture, + createFb2Fixture, createPdfFixture, writeFixtureFile, } from './e2e/metadata-write/metadata-write-fixture-builder'; @@ -1328,6 +1330,74 @@ describe('Metadata write operations (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () expect(disabledLog).toBeNull(); }); + it('writes metadata into an fb2 book without touching its body text', async () => { + const library = await createLibraryWithFolder(context, { + mode: 'book_per_file', + fileWriteEnabled: true, + fileWriteFb2Enabled: true, + }); + await createFb2Fixture(library.folderPath, 'per-format/book.fb2', { title: 'FB2 Seed', bodyText: 'untouched chapter text' }); + await triggerAndWaitForLibraryScan(context, library.libraryId); + const book = await locateBookFileByRelPath(context, library.libraryId, 'per-format/book.fb2'); + const originalBytes = await readFile(book.absolutePath); + + const patchResponse = await context.app.inject({ + method: 'PATCH', + url: `/api/v1/books/${book.bookId}/metadata`, + headers: authHeader(context.adminToken), + payload: { title: 'FB2 Written Title', seriesName: 'FB2 Series', seriesIndex: 2 }, + }); + expect(patchResponse.statusCode).toBe(200); + + const log = await waitForWriteLogEntry(context.db, book.bookId, { triggeredBy: 'auto', status: 'success' }); + expect(log.format).toBe('fb2'); + expect(log.fieldsWritten).toEqual(expect.arrayContaining(['title', 'seriesName', 'seriesIndex'])); + + const updated = await readFile(book.absolutePath, 'utf8'); + expect(updated).toContain('FB2 Written Title'); + expect(updated).toContain(''); + + const bodyOf = (text: string) => text.slice(text.indexOf('') + ''.length); + expect(bodyOf(updated)).toBe(bodyOf(originalBytes.toString('utf8'))); + expect(updated).toContain('untouched chapter text'); + + const reparsed = await parseFb2File(book.absolutePath); + expect(reparsed?.title).toBe('FB2 Written Title'); + expect(reparsed?.seriesName).toBe('FB2 Series'); + expect(reparsed?.seriesIndex).toBe(2); + }); + + it('skips fb2 book when the fb2 format is disabled for the library', async () => { + const library = await createLibraryWithFolder(context, { + mode: 'book_per_file', + fileWriteEnabled: true, + fileWriteFb2Enabled: false, + }); + await createFb2Fixture(library.folderPath, 'per-format/disabled.fb2', { title: 'FB2 Disabled' }); + await triggerAndWaitForLibraryScan(context, library.libraryId); + const book = await locateBookFileByRelPath(context, library.libraryId, 'per-format/disabled.fb2'); + const originalBytes = await readFile(book.absolutePath); + + await setLibraryFileWriteSettings(context.db, library.libraryId, { fileWriteEnabled: true, fileWriteFb2Enabled: false }); + + const syncResponse = await context.app.inject({ + method: 'POST', + url: `/api/v1/libraries/${library.libraryId}/write-metadata-to-files`, + headers: authHeader(context.adminToken), + }); + expect(syncResponse.statusCode).toBe(200); + + const doneEvent = parseSseEvents(syncResponse.body).find( + (event): event is Extract => 'done' in event && event.done === true, + ); + expect(doneEvent).toMatchObject({ processed: 1, succeeded: 0, failed: 0, skipped: 1 }); + + const logEntry = await getLatestWriteLogEntry(context.db, book.bookId, 'sync'); + expect(logEntry?.status).toBe('skipped'); + expect(logEntry?.errorMessage).toBe('format disabled'); + expect((await readFile(book.absolutePath)).equals(originalBytes)).toBe(true); + }); + it('skips cbz book when cbx format is disabled for the library', async () => { const library = await createLibraryWithFolder(context, { mode: 'book_per_file',