@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.
- package/README.md +1 -1
- package/dist/ui/book_dialog/dialog.d.ts +98 -0
- package/dist/ui/book_dialog/dialog.d.ts.map +1 -0
- package/dist/ui/book_dialog/dialog.js +590 -0
- package/dist/ui/book_dialog/dialog.js.map +1 -0
- package/dist/ui/book_dialog/export.d.ts +81 -0
- package/dist/ui/book_dialog/export.d.ts.map +1 -0
- package/dist/ui/book_dialog/export.js +184 -0
- package/dist/ui/book_dialog/export.js.map +1 -0
- package/dist/ui/book_dialog/index.d.ts +38 -0
- package/dist/ui/book_dialog/index.d.ts.map +1 -0
- package/dist/ui/book_dialog/index.js +36 -0
- package/dist/ui/book_dialog/index.js.map +1 -0
- package/dist/ui/book_dialog/sanity_check.d.ts +22 -0
- package/dist/ui/book_dialog/sanity_check.d.ts.map +1 -0
- package/dist/ui/book_dialog/sanity_check.js +109 -0
- package/dist/ui/book_dialog/sanity_check.js.map +1 -0
- package/dist/ui/book_dialog/templates.d.ts +74 -0
- package/dist/ui/book_dialog/templates.d.ts.map +1 -0
- package/dist/ui/book_dialog/templates.js +526 -0
- package/dist/ui/book_dialog/templates.js.map +1 -0
- package/dist/ui/book_dialog/types.d.ts +165 -0
- package/dist/ui/book_dialog/types.d.ts.map +1 -0
- package/dist/ui/book_dialog/types.js +15 -0
- package/dist/ui/book_dialog/types.js.map +1 -0
- package/package.json +20 -16
- package/src/ui/book_dialog/dialog.ts +765 -0
- package/src/ui/book_dialog/export.ts +398 -0
- package/src/ui/book_dialog/index.ts +81 -0
- package/src/ui/book_dialog/sanity_check.ts +208 -0
- package/src/ui/book_dialog/templates.ts +705 -0
- 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
|
+
}
|