@fiduswriter/books-document 0.1.28 → 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 (31) hide show
  1. package/dist/ui/book_dialog/dialog.d.ts +98 -0
  2. package/dist/ui/book_dialog/dialog.d.ts.map +1 -0
  3. package/dist/ui/book_dialog/dialog.js +590 -0
  4. package/dist/ui/book_dialog/dialog.js.map +1 -0
  5. package/dist/ui/book_dialog/export.d.ts +81 -0
  6. package/dist/ui/book_dialog/export.d.ts.map +1 -0
  7. package/dist/ui/book_dialog/export.js +184 -0
  8. package/dist/ui/book_dialog/export.js.map +1 -0
  9. package/dist/ui/book_dialog/index.d.ts +38 -0
  10. package/dist/ui/book_dialog/index.d.ts.map +1 -0
  11. package/dist/ui/book_dialog/index.js +36 -0
  12. package/dist/ui/book_dialog/index.js.map +1 -0
  13. package/dist/ui/book_dialog/sanity_check.d.ts +22 -0
  14. package/dist/ui/book_dialog/sanity_check.d.ts.map +1 -0
  15. package/dist/ui/book_dialog/sanity_check.js +109 -0
  16. package/dist/ui/book_dialog/sanity_check.js.map +1 -0
  17. package/dist/ui/book_dialog/templates.d.ts +74 -0
  18. package/dist/ui/book_dialog/templates.d.ts.map +1 -0
  19. package/dist/ui/book_dialog/templates.js +526 -0
  20. package/dist/ui/book_dialog/templates.js.map +1 -0
  21. package/dist/ui/book_dialog/types.d.ts +165 -0
  22. package/dist/ui/book_dialog/types.d.ts.map +1 -0
  23. package/dist/ui/book_dialog/types.js +15 -0
  24. package/dist/ui/book_dialog/types.js.map +1 -0
  25. package/package.json +5 -1
  26. package/src/ui/book_dialog/dialog.ts +765 -0
  27. package/src/ui/book_dialog/export.ts +398 -0
  28. package/src/ui/book_dialog/index.ts +81 -0
  29. package/src/ui/book_dialog/sanity_check.ts +208 -0
  30. package/src/ui/book_dialog/templates.ts +705 -0
  31. package/src/ui/book_dialog/types.ts +180 -0
@@ -0,0 +1,98 @@
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
+ import type { Book, Chapter } from "../../types.js";
14
+ import { Dialog, FileSelector } from "fwtoolkit";
15
+ import type { BookExportMenu } from "./export.js";
16
+ import type { BookDialogImageDB, BookDialogInfo, BookDialogOptions, BookDialogPart } from "./types.js";
17
+ /** The tabbed book editor. */
18
+ export declare class BookDialog {
19
+ options: BookDialogOptions;
20
+ /** Whether this dialog creates a new book or edits an existing one. */
21
+ isNew: boolean;
22
+ /** The book being edited. A working copy, not the host's own record. */
23
+ book: Book;
24
+ documentList: BookDialogOptions["documentList"];
25
+ exportMenu: BookExportMenu;
26
+ /** Callbacks invoked after the book has been saved. */
27
+ onSave: Array<(book: Book) => Promise<unknown> | unknown>;
28
+ dialogParts: BookDialogPart[];
29
+ /** Cover images that are not part of the host's image database. */
30
+ bookImageDB: BookDialogImageDB;
31
+ dialog: Dialog | null;
32
+ constructor(options: BookDialogOptions);
33
+ /** The image database the dialog renders cover previews from. */
34
+ imageDB(): BookDialogImageDB;
35
+ /** The dialog's root element, or the document before it is opened. */
36
+ root(): ParentNode;
37
+ /** The bundle the tab templates are rendered from. */
38
+ bookInfo(): BookDialogInfo;
39
+ /** A brand new book with the dialog's default metadata and settings. */
40
+ createEmptyBook(): Book;
41
+ /**
42
+ * The dialog's working copy of an existing book.
43
+ *
44
+ * The record is copied shallowly -- the `chapters` array stays shared with
45
+ * the host's record, exactly as the Django backend's dialog behaved -- and
46
+ * the metadata block is merged onto a complete one so every form field
47
+ * has something to read back.
48
+ */
49
+ workingCopy(book: Book): Book;
50
+ /** Render and open the dialog. */
51
+ open(): void;
52
+ /** Whether the book is only readable, i.e. not editable. */
53
+ readOnly(): boolean;
54
+ /**
55
+ * Let the user pick a DOCX/ODT template file and hand it to the host.
56
+ *
57
+ * The book is saved first, because a freshly created book has no id the
58
+ * host could attach a template to.
59
+ */
60
+ bindTemplateInput(dialog: Dialog, inputId: string, rowId: string, applyTemplate: (book: Book, template: string) => void, saveTemplate?: (book: Book, file: File) => Promise<string | null>): void;
61
+ /** Replace the markup of one of the dialog's rows. */
62
+ refreshRow(rowId: string, html: string): void;
63
+ /** Re-render the chapter list. */
64
+ refreshChapterList(): void;
65
+ /**
66
+ * Add or remove the encrypted-chapters notice that lives alongside the
67
+ * chapter list. Called after any operation that changes book.chapters so
68
+ * the notice stays in sync.
69
+ */
70
+ updateChapterNotice(): void;
71
+ /** Open a sub-dialog to edit a chapter's part title. */
72
+ editChapterDialog(chapter: Chapter): void;
73
+ /** Let the user pick the cover image through the host's image picker. */
74
+ selectCoverImage(): Promise<void>;
75
+ /**
76
+ * Persist the edited book through the host and notify the save callbacks.
77
+ *
78
+ * Reads the metadata form fields back into the book, so it must only be
79
+ * called while the dialog is open.
80
+ */
81
+ saveBook(): Promise<unknown>;
82
+ /**
83
+ * Move a chapter one position up or down the list.
84
+ *
85
+ * @param docId - Id of the chapter document to move.
86
+ * @param offset - `-1` to move up, `1` to move down.
87
+ */
88
+ moveChapter(docId: number, offset: number): void;
89
+ /** Remove a chapter from the book and close the numbering gap. */
90
+ removeChapter(docId: number): void;
91
+ /** Append the documents selected in the file selector as new chapters. */
92
+ addChapters(fileSelector: FileSelector): void;
93
+ /** Handle a click anywhere inside the dialog. */
94
+ handleDialogClick(event: Event, fileSelector?: FileSelector): void;
95
+ /** Save the book and render its sanity check report in place. */
96
+ performSanityCheck(): Promise<void>;
97
+ }
98
+ //# sourceMappingURL=dialog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dialog.d.ts","sourceRoot":"","sources":["../../../src/ui/book_dialog/dialog.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAC,IAAI,EAAgB,OAAO,EAAC,MAAM,gBAAgB,CAAA;AAC/D,OAAO,EAEH,MAAM,EACN,YAAY,EAIf,MAAM,WAAW,CAAA;AAGlB,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,aAAa,CAAA;AAkB/C,OAAO,KAAK,EACR,iBAAiB,EACjB,cAAc,EACd,iBAAiB,EACjB,cAAc,EACjB,MAAM,YAAY,CAAA;AA8BnB,8BAA8B;AAC9B,qBAAa,UAAU;IACnB,OAAO,EAAE,iBAAiB,CAAA;IAC1B,uEAAuE;IACvE,KAAK,EAAE,OAAO,CAAA;IACd,wEAAwE;IACxE,IAAI,EAAE,IAAI,CAAA;IACV,YAAY,EAAE,iBAAiB,CAAC,cAAc,CAAC,CAAA;IAC/C,UAAU,EAAE,cAAc,CAAA;IAC1B,uDAAuD;IACvD,MAAM,EAAE,KAAK,CAAC,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,CAAA;IACzD,WAAW,EAAE,cAAc,EAAE,CAAA;IAC7B,mEAAmE;IACnE,WAAW,EAAE,iBAAiB,CAAA;IAC9B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IAErB,YAAY,OAAO,EAAE,iBAAiB,EAuErC;IAED,iEAAiE;IACjE,OAAO,IAAI,iBAAiB,CAQ3B;IAED,sEAAsE;IACtE,IAAI,IAAI,UAAU,CAEjB;IAED,sDAAsD;IACtD,QAAQ,IAAI,cAAc,CAQzB;IAED,wEAAwE;IACxE,eAAe,IAAI,IAAI,CAoBtB;IAED;;;;;;;OAOG;IACH,WAAW,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,CAI5B;IAED,kCAAkC;IAClC,IAAI,IAAI,IAAI,CAuKX;IAED,4DAA4D;IAC5D,QAAQ,IAAI,OAAO,CAElB;IAED;;;;;OAKG;IACH,iBAAiB,CACb,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EACb,aAAa,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,EACrD,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,GAClE,IAAI,CAuBN;IAED,sDAAsD;IACtD,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAK5C;IAED,kCAAkC;IAClC,kBAAkB,IAAI,IAAI,CAQzB;IAED;;;;OAIG;IACH,mBAAmB,IAAI,IAAI,CAwB1B;IAED,wDAAwD;IACxD,iBAAiB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAmCxC;IAED,yEAAyE;IACzE,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAmBhC;IAED;;;;;OAKG;IACH,QAAQ,IAAI,OAAO,CAAC,OAAO,CAAC,CAyC3B;IAED;;;;;OAKG;IACH,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAc/C;IAED,kEAAkE;IAClE,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAiBjC;IAED,0EAA0E;IAC1E,WAAW,CAAC,YAAY,EAAE,YAAY,GAAG,IAAI,CAiB5C;IAED,iDAAiD;IACjD,iBAAiB,CAAC,KAAK,EAAE,KAAK,EAAE,YAAY,CAAC,EAAE,YAAY,GAAG,IAAI,CAgFjE;IAED,iEAAiE;IACjE,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC,CAgBlC;CACJ"}
@@ -0,0 +1,590 @@
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
+ import { ContentMenu, Dialog, FileSelector, addAlert, findTarget, gettext } from "fwtoolkit";
14
+ import { bookExportMenuModel } from "./export.js";
15
+ import { bookSanityCheck } from "./sanity_check.js";
16
+ import { bookBasicInfoTemplate, bookBibliographyDataTemplate, bookChapterDialogTemplate, bookChapterListTemplate, bookDOCXDataRowTemplate, bookDOCXDataTemplate, bookDialogChaptersTemplate, bookDialogTemplate, bookEpubDataCoverTemplate, bookEpubDataTemplate, bookODTDataRowTemplate, bookODTDataTemplate, bookPrintDataTemplate, bookSanityCheckTemplate } from "./templates.js";
17
+ /** The metadata a freshly created book starts out with. */
18
+ function emptyMetadata() {
19
+ return {
20
+ author: "",
21
+ subtitle: "",
22
+ version: "",
23
+ publisher: "",
24
+ copyright: "",
25
+ keywords: "",
26
+ description: "",
27
+ isbn: "",
28
+ publication_date: "",
29
+ series_title: "",
30
+ series_position: ""
31
+ };
32
+ }
33
+ /**
34
+ * Read a value out of one of the book dialog's form fields. The dialog markup
35
+ * comes from ./templates.js, so the ids are fixed.
36
+ */
37
+ function fieldValue(root, id) {
38
+ return root.querySelector(`#${id}`).value;
39
+ }
40
+ /** The tabbed book editor. */
41
+ export class BookDialog {
42
+ options;
43
+ /** Whether this dialog creates a new book or edits an existing one. */
44
+ isNew;
45
+ /** The book being edited. A working copy, not the host's own record. */
46
+ book;
47
+ documentList;
48
+ exportMenu;
49
+ /** Callbacks invoked after the book has been saved. */
50
+ onSave;
51
+ dialogParts;
52
+ /** Cover images that are not part of the host's image database. */
53
+ bookImageDB;
54
+ dialog;
55
+ constructor(options) {
56
+ this.options = options;
57
+ this.documentList = options.documentList;
58
+ this.exportMenu = bookExportMenuModel();
59
+ this.onSave = options.onSave ? [...options.onSave] : [];
60
+ this.dialog = null;
61
+ this.isNew = !options.book;
62
+ this.book = this.isNew
63
+ ? this.createEmptyBook()
64
+ : this.workingCopy(options.book);
65
+ this.bookImageDB = { db: {} };
66
+ if (this.book.cover_image && !this.imageDB().db[this.book.cover_image]) {
67
+ // The cover image is not in the current user's image DB -- it was
68
+ // either deleted or another user originally added it. As we don't
69
+ // do anything fancy with it, we simply add the current cover image
70
+ // to the DB locally so that image selection works as expected.
71
+ this.bookImageDB.db[this.book.cover_image] =
72
+ this.book.cover_image_data ?? {};
73
+ }
74
+ this.dialogParts = [
75
+ {
76
+ title: gettext("Basic info"),
77
+ description: gettext("Basic book information"),
78
+ template: bookBasicInfoTemplate
79
+ },
80
+ {
81
+ title: gettext("Chapters"),
82
+ description: gettext("Documents assigned as chapters"),
83
+ template: bookDialogChaptersTemplate
84
+ },
85
+ {
86
+ title: gettext("Bibliography"),
87
+ description: gettext("Bibliography related settings"),
88
+ template: bookBibliographyDataTemplate
89
+ },
90
+ {
91
+ title: gettext("Epub"),
92
+ description: gettext("Epub related settings"),
93
+ template: bookEpubDataTemplate
94
+ },
95
+ ...(this.options.backend.saveDocxTemplate
96
+ ? [
97
+ {
98
+ title: gettext("DOCX"),
99
+ description: gettext("DOCX related settings"),
100
+ template: bookDOCXDataTemplate
101
+ }
102
+ ]
103
+ : []),
104
+ ...(this.options.backend.saveOdtTemplate
105
+ ? [
106
+ {
107
+ title: gettext("ODT"),
108
+ description: gettext("ODT related settings"),
109
+ template: bookODTDataTemplate
110
+ }
111
+ ]
112
+ : []),
113
+ {
114
+ title: gettext("Print/PDF"),
115
+ description: gettext("Print related settings"),
116
+ template: bookPrintDataTemplate
117
+ },
118
+ {
119
+ title: gettext("Sanity check"),
120
+ description: gettext("Perform sanity check on book"),
121
+ template: bookSanityCheckTemplate
122
+ }
123
+ ];
124
+ }
125
+ /** The image database the dialog renders cover previews from. */
126
+ imageDB() {
127
+ return {
128
+ db: Object.assign({}, this.options.imageDB?.db ?? {}, this.bookImageDB.db)
129
+ };
130
+ }
131
+ /** The dialog's root element, or the document before it is opened. */
132
+ root() {
133
+ return this.dialog?.dialogEl ?? document;
134
+ }
135
+ /** The bundle the tab templates are rendered from. */
136
+ bookInfo() {
137
+ return {
138
+ book: this.book,
139
+ documentList: this.documentList,
140
+ citationStyles: this.options.citationStyles ?? [],
141
+ bookStyleList: this.options.exportContext?.bookStyles ?? [],
142
+ imageDB: this.imageDB()
143
+ };
144
+ }
145
+ /** A brand new book with the dialog's default metadata and settings. */
146
+ createEmptyBook() {
147
+ const { path = "/", exportContext } = this.options;
148
+ return {
149
+ title: "",
150
+ id: 0,
151
+ chapters: [],
152
+ added: 0,
153
+ updated: 0,
154
+ path,
155
+ metadata: emptyMetadata(),
156
+ settings: {
157
+ bibliography_header: gettext("Bibliography"),
158
+ citationstyle: "apa",
159
+ book_style: exportContext?.bookStyles[0]
160
+ ? exportContext.bookStyles[0].slug
161
+ : "",
162
+ papersize: "octavo",
163
+ language: "en-US"
164
+ }
165
+ };
166
+ }
167
+ /**
168
+ * The dialog's working copy of an existing book.
169
+ *
170
+ * The record is copied shallowly -- the `chapters` array stays shared with
171
+ * the host's record, exactly as the Django backend's dialog behaved -- and
172
+ * the metadata block is merged onto a complete one so every form field
173
+ * has something to read back.
174
+ */
175
+ workingCopy(book) {
176
+ return Object.assign({}, book, {
177
+ metadata: Object.assign(emptyMetadata(), book.metadata)
178
+ });
179
+ }
180
+ /** Render and open the dialog. */
181
+ open() {
182
+ const title = this.isNew ? gettext("Create Book") : gettext("Edit Book");
183
+ const body = bookDialogTemplate({
184
+ title,
185
+ dialogParts: this.dialogParts,
186
+ bookInfo: this.bookInfo()
187
+ });
188
+ const buttons = [];
189
+ if (this.options.exportContext) {
190
+ buttons.push({
191
+ text: gettext("Export"),
192
+ dropdown: true,
193
+ classes: "fw-dark",
194
+ click: (event) => {
195
+ const mouseEvent = event;
196
+ const contentMenu = new ContentMenu({
197
+ page: {
198
+ saveBook: () => this.saveBook(),
199
+ book: this.book,
200
+ documentList: this.documentList,
201
+ context: this.options.exportContext
202
+ },
203
+ menu: this.exportMenu,
204
+ menuPos: { X: mouseEvent.pageX, Y: mouseEvent.pageY },
205
+ width: 250
206
+ });
207
+ return contentMenu.open();
208
+ }
209
+ });
210
+ }
211
+ if (!this.readOnly()) {
212
+ buttons.push({
213
+ text: gettext("Submit"),
214
+ classes: "fw-dark",
215
+ click: () => {
216
+ return this.saveBook().then(() => dialog.close());
217
+ }
218
+ });
219
+ buttons.push({ type: "cancel" });
220
+ }
221
+ else {
222
+ buttons.push({ type: "close" });
223
+ }
224
+ const dialog = new Dialog({
225
+ width: 840,
226
+ height: 520,
227
+ title,
228
+ body,
229
+ buttons
230
+ });
231
+ dialog.open();
232
+ this.dialog = dialog;
233
+ dialog.dialogEl
234
+ .querySelectorAll("#bookoptions-tab .tab-content")
235
+ .forEach((el, index) => {
236
+ if (index) {
237
+ el.style.display = "none";
238
+ }
239
+ });
240
+ let fileSelector;
241
+ if (!this.readOnly()) {
242
+ fileSelector = new FileSelector({
243
+ dom: dialog.dialogEl.querySelector("#book-document-list"),
244
+ files: this.documentList,
245
+ multiSelect: true,
246
+ selectFolders: false
247
+ });
248
+ fileSelector.init();
249
+ this.bindTemplateInput(dialog, "input-docx-template", "docx-template-row", (book, template) => {
250
+ book.docx_template = template;
251
+ }, this.options.backend.saveDocxTemplate?.bind(this.options.backend));
252
+ this.bindTemplateInput(dialog, "input-odt-template", "odt-template-row", (book, template) => {
253
+ book.odt_template = template;
254
+ }, this.options.backend.saveOdtTemplate?.bind(this.options.backend));
255
+ }
256
+ // Handle tab link clicking
257
+ dialog.dialogEl
258
+ .querySelectorAll("#bookoptions-tab .fw-tab-link a")
259
+ .forEach(el => {
260
+ const tab = el.parentElement;
261
+ const tabList = tab.parentElement;
262
+ el.addEventListener("click", event => {
263
+ event.preventDefault();
264
+ tabList
265
+ .querySelectorAll(".fw-tab-link.fw-current-tab")
266
+ .forEach(other => other.classList.remove("fw-current-tab"));
267
+ tab.classList.add("fw-current-tab");
268
+ const link = el.getAttribute("href");
269
+ dialog.dialogEl
270
+ .querySelectorAll("#bookoptions-tab .tab-content")
271
+ .forEach(panel => {
272
+ if (panel.matches(link)) {
273
+ panel.style.display = "";
274
+ }
275
+ else {
276
+ panel.style.display = "none";
277
+ }
278
+ });
279
+ });
280
+ });
281
+ dialog.dialogEl.addEventListener("click", event => this.handleDialogClick(event, fileSelector));
282
+ const citationStyleSelect = dialog.dialogEl.querySelector("#book-settings-citationstyle");
283
+ if (citationStyleSelect) {
284
+ citationStyleSelect.addEventListener("change", event => {
285
+ this.book.settings.citationstyle = event.target.value;
286
+ });
287
+ }
288
+ const bookStyleSelect = dialog.dialogEl.querySelector("#book-settings-bookstyle");
289
+ if (bookStyleSelect) {
290
+ bookStyleSelect.addEventListener("change", event => {
291
+ this.book.settings.book_style = event.target.value;
292
+ });
293
+ }
294
+ const paperSizeSelect = dialog.dialogEl.querySelector("#book-settings-papersize");
295
+ if (paperSizeSelect) {
296
+ paperSizeSelect.addEventListener("change", event => {
297
+ this.book.settings.papersize = event.target.value;
298
+ });
299
+ }
300
+ }
301
+ /** Whether the book is only readable, i.e. not editable. */
302
+ readOnly() {
303
+ return this.book.rights === "read";
304
+ }
305
+ /**
306
+ * Let the user pick a DOCX/ODT template file and hand it to the host.
307
+ *
308
+ * The book is saved first, because a freshly created book has no id the
309
+ * host could attach a template to.
310
+ */
311
+ bindTemplateInput(dialog, inputId, rowId, applyTemplate, saveTemplate) {
312
+ const input = dialog.dialogEl.querySelector(`#${inputId}`);
313
+ if (!input || !saveTemplate) {
314
+ return;
315
+ }
316
+ input.addEventListener("change", event => {
317
+ const file = event.target.files[0];
318
+ return this.saveBook()
319
+ .then(() => saveTemplate(this.book, file))
320
+ .then(template => {
321
+ if (!template) {
322
+ return;
323
+ }
324
+ applyTemplate(this.book, template);
325
+ this.refreshRow(rowId, rowId === "docx-template-row"
326
+ ? bookDOCXDataRowTemplate({ book: this.book })
327
+ : bookODTDataRowTemplate({ book: this.book }));
328
+ });
329
+ });
330
+ }
331
+ /** Replace the markup of one of the dialog's rows. */
332
+ refreshRow(rowId, html) {
333
+ const row = this.root().querySelector(`#${rowId}`);
334
+ if (row) {
335
+ row.innerHTML = html;
336
+ }
337
+ }
338
+ /** Re-render the chapter list. */
339
+ refreshChapterList() {
340
+ this.refreshRow("book-chapter-list", bookChapterListTemplate({
341
+ book: this.book,
342
+ documentList: this.documentList
343
+ }));
344
+ }
345
+ /**
346
+ * Add or remove the encrypted-chapters notice that lives alongside the
347
+ * chapter list. Called after any operation that changes book.chapters so
348
+ * the notice stays in sync.
349
+ */
350
+ updateChapterNotice() {
351
+ const chapterListEl = this.root().querySelector("#book-chapter-list");
352
+ if (!chapterListEl) {
353
+ return;
354
+ }
355
+ const container = chapterListEl.closest(".fw-ar-container");
356
+ if (!container) {
357
+ return;
358
+ }
359
+ const existingNotice = container.querySelector(".e2ee-chapter-notice");
360
+ const hasEncrypted = this.book.chapters.some(ch => this.documentList.find(doc => doc.id === ch.text)?.e2ee);
361
+ if (hasEncrypted && !existingNotice) {
362
+ container.insertAdjacentHTML("beforeend", `<p class="fw-note e2ee-chapter-notice">
363
+ <i class="fas fa-lock"></i>
364
+ ${gettext("This book contains encrypted chapters. A personal passphrase is required to export or run a sanity check on this book.")}
365
+ </p>`);
366
+ }
367
+ else if (!hasEncrypted && existingNotice) {
368
+ existingNotice.remove();
369
+ }
370
+ }
371
+ /** Open a sub-dialog to edit a chapter's part title. */
372
+ editChapterDialog(chapter) {
373
+ const doc = this.documentList.find(doc => doc.id === chapter.text);
374
+ let docTitle = doc?.title ?? "";
375
+ if (!docTitle.length) {
376
+ docTitle = gettext("Untitled");
377
+ }
378
+ const buttons = [
379
+ {
380
+ type: "cancel"
381
+ },
382
+ {
383
+ text: gettext("Submit"),
384
+ classes: "fw-dark",
385
+ click: () => {
386
+ // The part-title field lives in this sub-dialog, not in
387
+ // the book dialog's own root.
388
+ chapter.part = fieldValue(dialog.dialogEl, "book-chapter-part");
389
+ this.refreshChapterList();
390
+ dialog.close();
391
+ }
392
+ }
393
+ ];
394
+ const dialog = new Dialog({
395
+ title: `${gettext("Edit Chapter")}: ${chapter.number}. ${docTitle}`,
396
+ body: bookChapterDialogTemplate({ chapter }),
397
+ width: 300,
398
+ height: 100,
399
+ buttons
400
+ });
401
+ dialog.open();
402
+ }
403
+ /** Let the user pick the cover image through the host's image picker. */
404
+ selectCoverImage() {
405
+ return Promise.resolve(this.options.backend.selectCoverImage?.(this.book.cover_image ?? false)).then(selected => {
406
+ if (!selected) {
407
+ delete this.book.cover_image;
408
+ }
409
+ else {
410
+ this.book.cover_image = selected.id;
411
+ this.book.cover_image_data = selected.data;
412
+ this.bookImageDB.db[selected.id] = selected.data;
413
+ }
414
+ this.refreshRow("cover-preview-row", bookEpubDataCoverTemplate({
415
+ book: this.book,
416
+ imageDB: this.imageDB()
417
+ }));
418
+ });
419
+ }
420
+ /**
421
+ * Persist the edited book through the host and notify the save callbacks.
422
+ *
423
+ * Reads the metadata form fields back into the book, so it must only be
424
+ * called while the dialog is open.
425
+ */
426
+ saveBook() {
427
+ const book = this.book;
428
+ if (this.readOnly()) {
429
+ return Promise.resolve();
430
+ }
431
+ const root = this.root();
432
+ book.title = fieldValue(root, "book-title");
433
+ book.metadata.author = fieldValue(root, "book-metadata-author");
434
+ book.metadata.subtitle = fieldValue(root, "book-metadata-subtitle");
435
+ book.metadata.version = fieldValue(root, "book-metadata-version");
436
+ book.metadata.copyright = fieldValue(root, "book-metadata-copyright");
437
+ book.metadata.publisher = fieldValue(root, "book-metadata-publisher");
438
+ book.metadata.keywords = fieldValue(root, "book-metadata-keywords");
439
+ book.metadata.description = fieldValue(root, "book-metadata-description");
440
+ book.metadata.isbn = fieldValue(root, "book-metadata-isbn");
441
+ book.metadata.publication_date = fieldValue(root, "book-metadata-publication-date");
442
+ book.metadata.series_title = fieldValue(root, "book-metadata-series-title");
443
+ book.metadata.series_position = fieldValue(root, "book-metadata-series-position");
444
+ book.settings.language = fieldValue(root, "book-settings-language");
445
+ book.path = (this.isNew ? "" : (book.path ?? "")) || this.options.path || "";
446
+ return this.options.backend
447
+ .saveBook(book)
448
+ .catch((error) => {
449
+ addAlert("error", gettext("The book could not be saved"));
450
+ throw error;
451
+ })
452
+ .then(saved => {
453
+ this.book = Object.assign(book, {
454
+ id: saved.id ?? book.id,
455
+ added: saved.added ?? book.added,
456
+ updated: saved.updated ?? book.updated
457
+ });
458
+ return Promise.all(this.onSave.map(method => method(this.book)));
459
+ });
460
+ }
461
+ /**
462
+ * Move a chapter one position up or down the list.
463
+ *
464
+ * @param docId - Id of the chapter document to move.
465
+ * @param offset - `-1` to move up, `1` to move down.
466
+ */
467
+ moveChapter(docId, offset) {
468
+ const chapter = this.book.chapters.find(chapter => chapter.text === docId);
469
+ const neighbour = this.book.chapters.find(other => other.number === chapter.number + offset);
470
+ if (!neighbour) {
471
+ return;
472
+ }
473
+ chapter.number += offset;
474
+ neighbour.number -= offset;
475
+ this.refreshChapterList();
476
+ this.updateChapterNotice();
477
+ }
478
+ /** Remove a chapter from the book and close the numbering gap. */
479
+ removeChapter(docId) {
480
+ const chapter = this.book.chapters.find(chapter => chapter.text === docId);
481
+ if (!chapter) {
482
+ return;
483
+ }
484
+ this.book.chapters.forEach(other => {
485
+ if (other.number > chapter.number) {
486
+ other.number--;
487
+ }
488
+ });
489
+ this.book.chapters = this.book.chapters.filter(other => other !== chapter);
490
+ this.refreshChapterList();
491
+ this.updateChapterNotice();
492
+ }
493
+ /** Append the documents selected in the file selector as new chapters. */
494
+ addChapters(fileSelector) {
495
+ fileSelector.selected.forEach(entry => {
496
+ const chapNums = this.book.chapters.map(chapter => chapter.number), number = chapNums.length ? Math.max(...chapNums) + 1 : 1;
497
+ if (entry.type !== "file") {
498
+ return;
499
+ }
500
+ this.book.chapters.push({
501
+ text: entry.file.id,
502
+ number,
503
+ part: ""
504
+ });
505
+ });
506
+ fileSelector.deselectAll();
507
+ this.refreshChapterList();
508
+ this.updateChapterNotice();
509
+ }
510
+ /** Handle a click anywhere inside the dialog. */
511
+ handleDialogClick(event, fileSelector) {
512
+ const el = {};
513
+ // `findTarget` fills `el.target` with the matching ancestor; all the
514
+ // chapter/template rows carry their document id in a data attribute.
515
+ const clickedId = () => Number.parseInt(el.target.dataset.id);
516
+ switch (true) {
517
+ case findTarget(event, ".book-sort-up", el):
518
+ this.moveChapter(clickedId(), -1);
519
+ break;
520
+ case findTarget(event, ".book-sort-down", el):
521
+ this.moveChapter(clickedId(), 1);
522
+ break;
523
+ case findTarget(event, ".delete-chapter", el):
524
+ this.removeChapter(clickedId());
525
+ break;
526
+ case findTarget(event, "#add-chapter", el):
527
+ this.addChapters(fileSelector);
528
+ break;
529
+ case findTarget(event, ".edit-chapter", el):
530
+ this.editChapterDialog(this.book.chapters.find(chapter => chapter.text === clickedId()));
531
+ break;
532
+ case findTarget(event, "#select-cover-image-button", el):
533
+ this.selectCoverImage();
534
+ break;
535
+ case findTarget(event, "#select-docx-template", el): {
536
+ const input = this.root().querySelector("#input-docx-template");
537
+ input.click();
538
+ break;
539
+ }
540
+ case findTarget(event, "#select-odt-template", el): {
541
+ const input = this.root().querySelector("#input-odt-template");
542
+ input.click();
543
+ break;
544
+ }
545
+ case findTarget(event, "#remove-cover-image-button", el): {
546
+ delete this.book.cover_image;
547
+ this.refreshRow("cover-preview-row", bookEpubDataCoverTemplate({
548
+ book: this.book,
549
+ // We just deleted the cover image, so we don't need a
550
+ // full DB.
551
+ imageDB: { db: {} }
552
+ }));
553
+ break;
554
+ }
555
+ case findTarget(event, "#remove-docx-template-button", el): {
556
+ delete this.book.docx_template;
557
+ this.refreshRow("docx-template-row", bookDOCXDataRowTemplate({ book: this.book }));
558
+ break;
559
+ }
560
+ case findTarget(event, "#remove-odt-template-button", el): {
561
+ delete this.book.odt_template;
562
+ this.refreshRow("odt-template-row", bookODTDataRowTemplate({ book: this.book }));
563
+ break;
564
+ }
565
+ case findTarget(event, "#perform-sanity-check-button", el): {
566
+ this.performSanityCheck();
567
+ break;
568
+ }
569
+ default:
570
+ break;
571
+ }
572
+ }
573
+ /** Save the book and render its sanity check report in place. */
574
+ performSanityCheck() {
575
+ const { schema, chapterLoader, e2ee } = this.options;
576
+ return this.saveBook()
577
+ .then(() => bookSanityCheck(this.book, this.documentList, {
578
+ schema,
579
+ chapterLoader,
580
+ e2ee
581
+ }))
582
+ .then(sanityCheckOutputHTML => {
583
+ const sanityCheckOutput = this.root().querySelector("#sanity-check-output");
584
+ if (sanityCheckOutput) {
585
+ sanityCheckOutput.innerHTML = sanityCheckOutputHTML;
586
+ }
587
+ });
588
+ }
589
+ }
590
+ //# sourceMappingURL=dialog.js.map