@fiduswriter/books-document 0.1.27 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +1 -1
  2. package/dist/ui/book_dialog/dialog.d.ts +98 -0
  3. package/dist/ui/book_dialog/dialog.d.ts.map +1 -0
  4. package/dist/ui/book_dialog/dialog.js +590 -0
  5. package/dist/ui/book_dialog/dialog.js.map +1 -0
  6. package/dist/ui/book_dialog/export.d.ts +81 -0
  7. package/dist/ui/book_dialog/export.d.ts.map +1 -0
  8. package/dist/ui/book_dialog/export.js +184 -0
  9. package/dist/ui/book_dialog/export.js.map +1 -0
  10. package/dist/ui/book_dialog/index.d.ts +38 -0
  11. package/dist/ui/book_dialog/index.d.ts.map +1 -0
  12. package/dist/ui/book_dialog/index.js +36 -0
  13. package/dist/ui/book_dialog/index.js.map +1 -0
  14. package/dist/ui/book_dialog/sanity_check.d.ts +22 -0
  15. package/dist/ui/book_dialog/sanity_check.d.ts.map +1 -0
  16. package/dist/ui/book_dialog/sanity_check.js +109 -0
  17. package/dist/ui/book_dialog/sanity_check.js.map +1 -0
  18. package/dist/ui/book_dialog/templates.d.ts +74 -0
  19. package/dist/ui/book_dialog/templates.d.ts.map +1 -0
  20. package/dist/ui/book_dialog/templates.js +526 -0
  21. package/dist/ui/book_dialog/templates.js.map +1 -0
  22. package/dist/ui/book_dialog/types.d.ts +165 -0
  23. package/dist/ui/book_dialog/types.d.ts.map +1 -0
  24. package/dist/ui/book_dialog/types.js +15 -0
  25. package/dist/ui/book_dialog/types.js.map +1 -0
  26. package/package.json +20 -16
  27. package/src/ui/book_dialog/dialog.ts +765 -0
  28. package/src/ui/book_dialog/export.ts +398 -0
  29. package/src/ui/book_dialog/index.ts +81 -0
  30. package/src/ui/book_dialog/sanity_check.ts +208 -0
  31. package/src/ui/book_dialog/templates.ts +705 -0
  32. package/src/ui/book_dialog/types.ts +180 -0
@@ -0,0 +1,765 @@
1
+ /**
2
+ * The book edit dialog.
3
+ *
4
+ * A tabbed dialog that edits a book's metadata and settings, manages its
5
+ * chapter list (add/remove/reorder, part grouping and per-chapter part
6
+ * titles), picks the cover image, triggers a sanity check and starts an
7
+ * export.
8
+ *
9
+ * Everything host-specific is injected: see `./types.js`. The book *overview*
10
+ * page -- its datatable, bulk actions, sharing, copy and delete -- is not
11
+ * part of this component and stays with the host.
12
+ */
13
+
14
+ import type {Book, BookMetadata, Chapter} from "../../types.js"
15
+ import {
16
+ ContentMenu,
17
+ Dialog,
18
+ FileSelector,
19
+ addAlert,
20
+ findTarget,
21
+ gettext
22
+ } from "fwtoolkit"
23
+ import type {DialogButtonSpec} from "fwtoolkit"
24
+ import {bookExportMenuModel} from "./export.js"
25
+ import type {BookExportMenu} from "./export.js"
26
+ import {bookSanityCheck} from "./sanity_check.js"
27
+ import {
28
+ bookBasicInfoTemplate,
29
+ bookBibliographyDataTemplate,
30
+ bookChapterDialogTemplate,
31
+ bookChapterListTemplate,
32
+ bookDOCXDataRowTemplate,
33
+ bookDOCXDataTemplate,
34
+ bookDialogChaptersTemplate,
35
+ bookDialogTemplate,
36
+ bookEpubDataCoverTemplate,
37
+ bookEpubDataTemplate,
38
+ bookODTDataRowTemplate,
39
+ bookODTDataTemplate,
40
+ bookPrintDataTemplate,
41
+ bookSanityCheckTemplate
42
+ } from "./templates.js"
43
+ import type {
44
+ BookDialogImageDB,
45
+ BookDialogInfo,
46
+ BookDialogOptions,
47
+ BookDialogPart
48
+ } from "./types.js"
49
+
50
+ /** The file records fwtoolkit's file selector accepts. */
51
+ type FileSelectorFiles = ConstructorParameters<typeof FileSelector>[0]["files"]
52
+
53
+ /** The metadata a freshly created book starts out with. */
54
+ function emptyMetadata(): BookMetadata {
55
+ return {
56
+ author: "",
57
+ subtitle: "",
58
+ version: "",
59
+ publisher: "",
60
+ copyright: "",
61
+ keywords: "",
62
+ description: "",
63
+ isbn: "",
64
+ publication_date: "",
65
+ series_title: "",
66
+ series_position: ""
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Read a value out of one of the book dialog's form fields. The dialog markup
72
+ * comes from ./templates.js, so the ids are fixed.
73
+ */
74
+ function fieldValue(root: ParentNode, id: string): string {
75
+ return (root.querySelector(`#${id}`) as HTMLInputElement).value
76
+ }
77
+
78
+ /** The tabbed book editor. */
79
+ export class BookDialog {
80
+ options: BookDialogOptions
81
+ /** Whether this dialog creates a new book or edits an existing one. */
82
+ isNew: boolean
83
+ /** The book being edited. A working copy, not the host's own record. */
84
+ book: Book
85
+ documentList: BookDialogOptions["documentList"]
86
+ exportMenu: BookExportMenu
87
+ /** Callbacks invoked after the book has been saved. */
88
+ onSave: Array<(book: Book) => Promise<unknown> | unknown>
89
+ dialogParts: BookDialogPart[]
90
+ /** Cover images that are not part of the host's image database. */
91
+ bookImageDB: BookDialogImageDB
92
+ dialog: Dialog | null
93
+
94
+ constructor(options: BookDialogOptions) {
95
+ this.options = options
96
+ this.documentList = options.documentList
97
+ this.exportMenu = bookExportMenuModel()
98
+ this.onSave = options.onSave ? [...options.onSave] : []
99
+ this.dialog = null
100
+ this.isNew = !options.book
101
+ this.book = this.isNew
102
+ ? this.createEmptyBook()
103
+ : this.workingCopy(options.book as Book)
104
+ this.bookImageDB = {db: {}}
105
+
106
+ if (this.book.cover_image && !this.imageDB().db[this.book.cover_image]) {
107
+ // The cover image is not in the current user's image DB -- it was
108
+ // either deleted or another user originally added it. As we don't
109
+ // do anything fancy with it, we simply add the current cover image
110
+ // to the DB locally so that image selection works as expected.
111
+ this.bookImageDB.db[this.book.cover_image] =
112
+ this.book.cover_image_data ?? {}
113
+ }
114
+
115
+ this.dialogParts = [
116
+ {
117
+ title: gettext("Basic info"),
118
+ description: gettext("Basic book information"),
119
+ template: bookBasicInfoTemplate
120
+ },
121
+ {
122
+ title: gettext("Chapters"),
123
+ description: gettext("Documents assigned as chapters"),
124
+ template: bookDialogChaptersTemplate
125
+ },
126
+ {
127
+ title: gettext("Bibliography"),
128
+ description: gettext("Bibliography related settings"),
129
+ template: bookBibliographyDataTemplate
130
+ },
131
+ {
132
+ title: gettext("Epub"),
133
+ description: gettext("Epub related settings"),
134
+ template: bookEpubDataTemplate
135
+ },
136
+ ...(this.options.backend.saveDocxTemplate
137
+ ? [
138
+ {
139
+ title: gettext("DOCX"),
140
+ description: gettext("DOCX related settings"),
141
+ template: bookDOCXDataTemplate
142
+ }
143
+ ]
144
+ : []),
145
+ ...(this.options.backend.saveOdtTemplate
146
+ ? [
147
+ {
148
+ title: gettext("ODT"),
149
+ description: gettext("ODT related settings"),
150
+ template: bookODTDataTemplate
151
+ }
152
+ ]
153
+ : []),
154
+ {
155
+ title: gettext("Print/PDF"),
156
+ description: gettext("Print related settings"),
157
+ template: bookPrintDataTemplate
158
+ },
159
+ {
160
+ title: gettext("Sanity check"),
161
+ description: gettext("Perform sanity check on book"),
162
+ template: bookSanityCheckTemplate
163
+ }
164
+ ]
165
+ }
166
+
167
+ /** The image database the dialog renders cover previews from. */
168
+ imageDB(): BookDialogImageDB {
169
+ return {
170
+ db: Object.assign(
171
+ {},
172
+ this.options.imageDB?.db ?? {},
173
+ this.bookImageDB.db
174
+ )
175
+ }
176
+ }
177
+
178
+ /** The dialog's root element, or the document before it is opened. */
179
+ root(): ParentNode {
180
+ return this.dialog?.dialogEl ?? document
181
+ }
182
+
183
+ /** The bundle the tab templates are rendered from. */
184
+ bookInfo(): BookDialogInfo {
185
+ return {
186
+ book: this.book,
187
+ documentList: this.documentList,
188
+ citationStyles: this.options.citationStyles ?? [],
189
+ bookStyleList: this.options.exportContext?.bookStyles ?? [],
190
+ imageDB: this.imageDB()
191
+ }
192
+ }
193
+
194
+ /** A brand new book with the dialog's default metadata and settings. */
195
+ createEmptyBook(): Book {
196
+ const {path = "/", exportContext} = this.options
197
+ return {
198
+ title: "",
199
+ id: 0,
200
+ chapters: [],
201
+ added: 0,
202
+ updated: 0,
203
+ path,
204
+ metadata: emptyMetadata(),
205
+ settings: {
206
+ bibliography_header: gettext("Bibliography"),
207
+ citationstyle: "apa",
208
+ book_style: exportContext?.bookStyles[0]
209
+ ? exportContext.bookStyles[0].slug
210
+ : ("" as string),
211
+ papersize: "octavo",
212
+ language: "en-US"
213
+ }
214
+ } as Book
215
+ }
216
+
217
+ /**
218
+ * The dialog's working copy of an existing book.
219
+ *
220
+ * The record is copied shallowly -- the `chapters` array stays shared with
221
+ * the host's record, exactly as the Django backend's dialog behaved -- and
222
+ * the metadata block is merged onto a complete one so every form field
223
+ * has something to read back.
224
+ */
225
+ workingCopy(book: Book): Book {
226
+ return Object.assign({}, book, {
227
+ metadata: Object.assign(emptyMetadata(), book.metadata)
228
+ })
229
+ }
230
+
231
+ /** Render and open the dialog. */
232
+ open(): void {
233
+ const title = this.isNew ? gettext("Create Book") : gettext("Edit Book")
234
+ const body = bookDialogTemplate({
235
+ title,
236
+ dialogParts: this.dialogParts,
237
+ bookInfo: this.bookInfo()
238
+ })
239
+
240
+ const buttons: DialogButtonSpec[] = []
241
+ if (this.options.exportContext) {
242
+ buttons.push({
243
+ text: gettext("Export"),
244
+ dropdown: true,
245
+ classes: "fw-dark",
246
+ click: (event?: Event) => {
247
+ const mouseEvent = event as MouseEvent
248
+ const contentMenu = new ContentMenu({
249
+ page: {
250
+ saveBook: () => this.saveBook(),
251
+ book: this.book,
252
+ documentList: this.documentList,
253
+ context: this.options.exportContext
254
+ },
255
+ menu: this.exportMenu,
256
+ menuPos: {X: mouseEvent.pageX, Y: mouseEvent.pageY},
257
+ width: 250
258
+ })
259
+ return contentMenu.open()
260
+ }
261
+ })
262
+ }
263
+ if (!this.readOnly()) {
264
+ buttons.push({
265
+ text: gettext("Submit"),
266
+ classes: "fw-dark",
267
+ click: () => {
268
+ return this.saveBook().then(() => dialog.close())
269
+ }
270
+ })
271
+ buttons.push({type: "cancel"})
272
+ } else {
273
+ buttons.push({type: "close"})
274
+ }
275
+
276
+ const dialog = new Dialog({
277
+ width: 840,
278
+ height: 520,
279
+ title,
280
+ body,
281
+ buttons
282
+ })
283
+ dialog.open()
284
+ this.dialog = dialog
285
+
286
+ dialog.dialogEl
287
+ .querySelectorAll<HTMLElement>("#bookoptions-tab .tab-content")
288
+ .forEach((el, index) => {
289
+ if (index) {
290
+ el.style.display = "none"
291
+ }
292
+ })
293
+
294
+ let fileSelector: FileSelector | undefined
295
+ if (!this.readOnly()) {
296
+ fileSelector = new FileSelector({
297
+ dom: dialog.dialogEl.querySelector<HTMLElement>(
298
+ "#book-document-list"
299
+ )!,
300
+ files: this.documentList as unknown as FileSelectorFiles,
301
+ multiSelect: true,
302
+ selectFolders: false
303
+ })
304
+ fileSelector.init()
305
+
306
+ this.bindTemplateInput(
307
+ dialog,
308
+ "input-docx-template",
309
+ "docx-template-row",
310
+ (book, template) => {
311
+ book.docx_template = template
312
+ },
313
+ this.options.backend.saveDocxTemplate?.bind(this.options.backend)
314
+ )
315
+ this.bindTemplateInput(
316
+ dialog,
317
+ "input-odt-template",
318
+ "odt-template-row",
319
+ (book, template) => {
320
+ book.odt_template = template
321
+ },
322
+ this.options.backend.saveOdtTemplate?.bind(this.options.backend)
323
+ )
324
+ }
325
+
326
+ // Handle tab link clicking
327
+ dialog.dialogEl
328
+ .querySelectorAll<HTMLAnchorElement>(
329
+ "#bookoptions-tab .fw-tab-link a"
330
+ )
331
+ .forEach(el => {
332
+ const tab = el.parentElement as HTMLElement
333
+ const tabList = tab.parentElement as HTMLElement
334
+ el.addEventListener("click", event => {
335
+ event.preventDefault()
336
+
337
+ tabList
338
+ .querySelectorAll<HTMLElement>(
339
+ ".fw-tab-link.fw-current-tab"
340
+ )
341
+ .forEach(other =>
342
+ other.classList.remove("fw-current-tab")
343
+ )
344
+ tab.classList.add("fw-current-tab")
345
+
346
+ const link = el.getAttribute("href") as string
347
+ dialog.dialogEl
348
+ .querySelectorAll<HTMLElement>(
349
+ "#bookoptions-tab .tab-content"
350
+ )
351
+ .forEach(panel => {
352
+ if (panel.matches(link)) {
353
+ panel.style.display = ""
354
+ } else {
355
+ panel.style.display = "none"
356
+ }
357
+ })
358
+ })
359
+ })
360
+
361
+ dialog.dialogEl.addEventListener("click", event =>
362
+ this.handleDialogClick(event, fileSelector)
363
+ )
364
+
365
+ const citationStyleSelect = dialog.dialogEl.querySelector<HTMLSelectElement>(
366
+ "#book-settings-citationstyle"
367
+ )
368
+ if (citationStyleSelect) {
369
+ citationStyleSelect.addEventListener("change", event => {
370
+ this.book.settings.citationstyle = (
371
+ event.target as HTMLSelectElement
372
+ ).value
373
+ })
374
+ }
375
+
376
+ const bookStyleSelect =
377
+ dialog.dialogEl.querySelector<HTMLSelectElement>(
378
+ "#book-settings-bookstyle"
379
+ )
380
+ if (bookStyleSelect) {
381
+ bookStyleSelect.addEventListener("change", event => {
382
+ this.book.settings.book_style = (
383
+ event.target as HTMLSelectElement
384
+ ).value
385
+ })
386
+ }
387
+
388
+ const paperSizeSelect =
389
+ dialog.dialogEl.querySelector<HTMLSelectElement>(
390
+ "#book-settings-papersize"
391
+ )
392
+ if (paperSizeSelect) {
393
+ paperSizeSelect.addEventListener("change", event => {
394
+ this.book.settings.papersize = (
395
+ event.target as HTMLSelectElement
396
+ ).value
397
+ })
398
+ }
399
+ }
400
+
401
+ /** Whether the book is only readable, i.e. not editable. */
402
+ readOnly(): boolean {
403
+ return this.book.rights === "read"
404
+ }
405
+
406
+ /**
407
+ * Let the user pick a DOCX/ODT template file and hand it to the host.
408
+ *
409
+ * The book is saved first, because a freshly created book has no id the
410
+ * host could attach a template to.
411
+ */
412
+ bindTemplateInput(
413
+ dialog: Dialog,
414
+ inputId: string,
415
+ rowId: string,
416
+ applyTemplate: (book: Book, template: string) => void,
417
+ saveTemplate?: (book: Book, file: File) => Promise<string | null>
418
+ ): void {
419
+ const input =
420
+ dialog.dialogEl.querySelector<HTMLInputElement>(`#${inputId}`)
421
+ if (!input || !saveTemplate) {
422
+ return
423
+ }
424
+ input.addEventListener("change", event => {
425
+ const file = (event.target as HTMLInputElement).files![0]
426
+ return this.saveBook()
427
+ .then(() => saveTemplate(this.book, file))
428
+ .then(template => {
429
+ if (!template) {
430
+ return
431
+ }
432
+ applyTemplate(this.book, template)
433
+ this.refreshRow(
434
+ rowId,
435
+ rowId === "docx-template-row"
436
+ ? bookDOCXDataRowTemplate({book: this.book})
437
+ : bookODTDataRowTemplate({book: this.book})
438
+ )
439
+ })
440
+ })
441
+ }
442
+
443
+ /** Replace the markup of one of the dialog's rows. */
444
+ refreshRow(rowId: string, html: string): void {
445
+ const row = this.root().querySelector(`#${rowId}`)
446
+ if (row) {
447
+ row.innerHTML = html
448
+ }
449
+ }
450
+
451
+ /** Re-render the chapter list. */
452
+ refreshChapterList(): void {
453
+ this.refreshRow(
454
+ "book-chapter-list",
455
+ bookChapterListTemplate({
456
+ book: this.book,
457
+ documentList: this.documentList
458
+ })
459
+ )
460
+ }
461
+
462
+ /**
463
+ * Add or remove the encrypted-chapters notice that lives alongside the
464
+ * chapter list. Called after any operation that changes book.chapters so
465
+ * the notice stays in sync.
466
+ */
467
+ updateChapterNotice(): void {
468
+ const chapterListEl = this.root().querySelector("#book-chapter-list")
469
+ if (!chapterListEl) {
470
+ return
471
+ }
472
+ const container = chapterListEl.closest(".fw-ar-container")
473
+ if (!container) {
474
+ return
475
+ }
476
+ const existingNotice = container.querySelector(".e2ee-chapter-notice")
477
+ const hasEncrypted = this.book.chapters.some(ch =>
478
+ this.documentList.find(doc => doc.id === ch.text)?.e2ee
479
+ )
480
+ if (hasEncrypted && !existingNotice) {
481
+ container.insertAdjacentHTML(
482
+ "beforeend",
483
+ `<p class="fw-note e2ee-chapter-notice">
484
+ <i class="fas fa-lock"></i>
485
+ ${gettext("This book contains encrypted chapters. A personal passphrase is required to export or run a sanity check on this book.")}
486
+ </p>`
487
+ )
488
+ } else if (!hasEncrypted && existingNotice) {
489
+ existingNotice.remove()
490
+ }
491
+ }
492
+
493
+ /** Open a sub-dialog to edit a chapter's part title. */
494
+ editChapterDialog(chapter: Chapter): void {
495
+ const doc = this.documentList.find(doc => doc.id === chapter.text)
496
+ let docTitle = doc?.title ?? ""
497
+ if (!docTitle.length) {
498
+ docTitle = gettext("Untitled")
499
+ }
500
+
501
+ const buttons: DialogButtonSpec[] = [
502
+ {
503
+ type: "cancel"
504
+ },
505
+ {
506
+ text: gettext("Submit"),
507
+ classes: "fw-dark",
508
+ click: () => {
509
+ // The part-title field lives in this sub-dialog, not in
510
+ // the book dialog's own root.
511
+ chapter.part = fieldValue(
512
+ dialog.dialogEl,
513
+ "book-chapter-part"
514
+ )
515
+ this.refreshChapterList()
516
+ dialog.close()
517
+ }
518
+ }
519
+ ]
520
+
521
+ const dialog = new Dialog({
522
+ title: `${gettext("Edit Chapter")}: ${chapter.number}. ${docTitle}`,
523
+ body: bookChapterDialogTemplate({chapter}),
524
+ width: 300,
525
+ height: 100,
526
+ buttons
527
+ })
528
+ dialog.open()
529
+ }
530
+
531
+ /** Let the user pick the cover image through the host's image picker. */
532
+ selectCoverImage(): Promise<void> {
533
+ return Promise.resolve(
534
+ this.options.backend.selectCoverImage?.(this.book.cover_image ?? false)
535
+ ).then(selected => {
536
+ if (!selected) {
537
+ delete this.book.cover_image
538
+ } else {
539
+ this.book.cover_image = selected.id
540
+ this.book.cover_image_data = selected.data
541
+ this.bookImageDB.db[selected.id] = selected.data
542
+ }
543
+ this.refreshRow(
544
+ "cover-preview-row",
545
+ bookEpubDataCoverTemplate({
546
+ book: this.book,
547
+ imageDB: this.imageDB()
548
+ })
549
+ )
550
+ })
551
+ }
552
+
553
+ /**
554
+ * Persist the edited book through the host and notify the save callbacks.
555
+ *
556
+ * Reads the metadata form fields back into the book, so it must only be
557
+ * called while the dialog is open.
558
+ */
559
+ saveBook(): Promise<unknown> {
560
+ const book = this.book
561
+ if (this.readOnly()) {
562
+ return Promise.resolve()
563
+ }
564
+ const root = this.root()
565
+ book.title = fieldValue(root, "book-title")
566
+ book.metadata.author = fieldValue(root, "book-metadata-author")
567
+ book.metadata.subtitle = fieldValue(root, "book-metadata-subtitle")
568
+ book.metadata.version = fieldValue(root, "book-metadata-version")
569
+ book.metadata.copyright = fieldValue(root, "book-metadata-copyright")
570
+ book.metadata.publisher = fieldValue(root, "book-metadata-publisher")
571
+ book.metadata.keywords = fieldValue(root, "book-metadata-keywords")
572
+ book.metadata.description = fieldValue(root, "book-metadata-description")
573
+ book.metadata.isbn = fieldValue(root, "book-metadata-isbn")
574
+ book.metadata.publication_date = fieldValue(
575
+ root,
576
+ "book-metadata-publication-date"
577
+ )
578
+ book.metadata.series_title = fieldValue(root, "book-metadata-series-title")
579
+ book.metadata.series_position = fieldValue(
580
+ root,
581
+ "book-metadata-series-position"
582
+ )
583
+ book.settings.language = fieldValue(root, "book-settings-language")
584
+ book.path = (this.isNew ? "" : (book.path ?? "")) || this.options.path || ""
585
+
586
+ return this.options.backend
587
+ .saveBook(book)
588
+ .catch((error: unknown) => {
589
+ addAlert("error", gettext("The book could not be saved"))
590
+ throw error
591
+ })
592
+ .then(saved => {
593
+ this.book = Object.assign(book, {
594
+ id: saved.id ?? book.id,
595
+ added: saved.added ?? book.added,
596
+ updated: saved.updated ?? book.updated
597
+ })
598
+ return Promise.all(this.onSave.map(method => method(this.book)))
599
+ })
600
+ }
601
+
602
+ /**
603
+ * Move a chapter one position up or down the list.
604
+ *
605
+ * @param docId - Id of the chapter document to move.
606
+ * @param offset - `-1` to move up, `1` to move down.
607
+ */
608
+ moveChapter(docId: number, offset: number): void {
609
+ const chapter = this.book.chapters.find(
610
+ chapter => chapter.text === docId
611
+ ) as Chapter
612
+ const neighbour = this.book.chapters.find(
613
+ other => other.number === chapter.number + offset
614
+ ) as Chapter
615
+ if (!neighbour) {
616
+ return
617
+ }
618
+ chapter.number += offset
619
+ neighbour.number -= offset
620
+ this.refreshChapterList()
621
+ this.updateChapterNotice()
622
+ }
623
+
624
+ /** Remove a chapter from the book and close the numbering gap. */
625
+ removeChapter(docId: number): void {
626
+ const chapter = this.book.chapters.find(
627
+ chapter => chapter.text === docId
628
+ ) as Chapter
629
+ if (!chapter) {
630
+ return
631
+ }
632
+ this.book.chapters.forEach(other => {
633
+ if (other.number > chapter.number) {
634
+ other.number--
635
+ }
636
+ })
637
+ this.book.chapters = this.book.chapters.filter(
638
+ other => other !== chapter
639
+ )
640
+ this.refreshChapterList()
641
+ this.updateChapterNotice()
642
+ }
643
+
644
+ /** Append the documents selected in the file selector as new chapters. */
645
+ addChapters(fileSelector: FileSelector): void {
646
+ fileSelector.selected.forEach(entry => {
647
+ const chapNums = this.book.chapters.map(chapter => chapter.number),
648
+ number = chapNums.length ? Math.max(...chapNums) + 1 : 1
649
+ if (entry.type !== "file") {
650
+ return
651
+ }
652
+ this.book.chapters.push({
653
+ text: entry.file.id as number,
654
+ number,
655
+ part: ""
656
+ })
657
+ })
658
+ fileSelector.deselectAll()
659
+
660
+ this.refreshChapterList()
661
+ this.updateChapterNotice()
662
+ }
663
+
664
+ /** Handle a click anywhere inside the dialog. */
665
+ handleDialogClick(event: Event, fileSelector?: FileSelector): void {
666
+ const el: {target?: HTMLElement} = {}
667
+ // `findTarget` fills `el.target` with the matching ancestor; all the
668
+ // chapter/template rows carry their document id in a data attribute.
669
+ const clickedId = (): number =>
670
+ Number.parseInt(el.target!.dataset.id as string)
671
+
672
+ switch (true) {
673
+ case findTarget(event, ".book-sort-up", el):
674
+ this.moveChapter(clickedId(), -1)
675
+ break
676
+ case findTarget(event, ".book-sort-down", el):
677
+ this.moveChapter(clickedId(), 1)
678
+ break
679
+ case findTarget(event, ".delete-chapter", el):
680
+ this.removeChapter(clickedId())
681
+ break
682
+ case findTarget(event, "#add-chapter", el):
683
+ this.addChapters(fileSelector!)
684
+ break
685
+ case findTarget(event, ".edit-chapter", el):
686
+ this.editChapterDialog(
687
+ this.book.chapters.find(
688
+ chapter => chapter.text === clickedId()
689
+ ) as Chapter
690
+ )
691
+ break
692
+ case findTarget(event, "#select-cover-image-button", el):
693
+ this.selectCoverImage()
694
+ break
695
+ case findTarget(event, "#select-docx-template", el): {
696
+ const input = this.root().querySelector<HTMLElement>(
697
+ "#input-docx-template"
698
+ )
699
+ input!.click()
700
+ break
701
+ }
702
+ case findTarget(event, "#select-odt-template", el): {
703
+ const input = this.root().querySelector<HTMLElement>(
704
+ "#input-odt-template"
705
+ )
706
+ input!.click()
707
+ break
708
+ }
709
+ case findTarget(event, "#remove-cover-image-button", el): {
710
+ delete this.book.cover_image
711
+ this.refreshRow(
712
+ "cover-preview-row",
713
+ bookEpubDataCoverTemplate({
714
+ book: this.book,
715
+ // We just deleted the cover image, so we don't need a
716
+ // full DB.
717
+ imageDB: {db: {}}
718
+ })
719
+ )
720
+ break
721
+ }
722
+ case findTarget(event, "#remove-docx-template-button", el): {
723
+ delete this.book.docx_template
724
+ this.refreshRow(
725
+ "docx-template-row",
726
+ bookDOCXDataRowTemplate({book: this.book})
727
+ )
728
+ break
729
+ }
730
+ case findTarget(event, "#remove-odt-template-button", el): {
731
+ delete this.book.odt_template
732
+ this.refreshRow(
733
+ "odt-template-row",
734
+ bookODTDataRowTemplate({book: this.book})
735
+ )
736
+ break
737
+ }
738
+ case findTarget(event, "#perform-sanity-check-button", el): {
739
+ this.performSanityCheck()
740
+ break
741
+ }
742
+ default:
743
+ break
744
+ }
745
+ }
746
+
747
+ /** Save the book and render its sanity check report in place. */
748
+ performSanityCheck(): Promise<void> {
749
+ const {schema, chapterLoader, e2ee} = this.options
750
+ return this.saveBook()
751
+ .then(() =>
752
+ bookSanityCheck(this.book, this.documentList, {
753
+ schema,
754
+ chapterLoader,
755
+ e2ee
756
+ })
757
+ )
758
+ .then(sanityCheckOutputHTML => {
759
+ const sanityCheckOutput = this.root().querySelector("#sanity-check-output")
760
+ if (sanityCheckOutput) {
761
+ sanityCheckOutput.innerHTML = sanityCheckOutputHTML
762
+ }
763
+ })
764
+ }
765
+ }