bookish-press 0.5.25 → 0.5.32

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 (54) hide show
  1. package/dist/components/BookPreview.svelte +1 -0
  2. package/dist/components/chapter/Callout.svelte +0 -1
  3. package/dist/components/chapter/Embed.svelte +4 -3
  4. package/dist/components/chapter/ExtendedPrism.d.ts +1 -1
  5. package/dist/components/chapter/Figure.svelte +0 -1
  6. package/dist/components/chapter/Footnote.svelte +1 -0
  7. package/dist/components/chapter/List.svelte +16 -33
  8. package/dist/components/chapter/Paragraph.svelte +7 -2
  9. package/dist/components/chapter/Quote.svelte +6 -6
  10. package/dist/components/controls/DarkToggle.svelte +1 -1
  11. package/dist/components/editor/BookishEditor.svelte +9 -16
  12. package/dist/components/editor/Commands.js +14 -14
  13. package/dist/components/editor/ConfirmButton.svelte +1 -1
  14. package/dist/components/editor/EmbedEditor.svelte +3 -3
  15. package/dist/components/editor/ImageChooser.svelte +6 -26
  16. package/dist/components/editor/ImageThumbnail.svelte +61 -0
  17. package/dist/components/editor/ImageThumbnail.svelte.d.ts +19 -0
  18. package/dist/components/editor/Permissions.svelte +2 -0
  19. package/dist/components/editor/TextEditor.svelte +5 -2
  20. package/dist/components/editor/Toggle.svelte +1 -1
  21. package/dist/components/page/Chapter.svelte +1 -1
  22. package/dist/components/page/DefinitionView.svelte +43 -38
  23. package/dist/components/page/Edition.svelte +11 -4
  24. package/dist/components/page/EditionLoader.svelte +12 -3
  25. package/dist/components/page/Editions.svelte +1 -1
  26. package/dist/components/page/Header.svelte +73 -69
  27. package/dist/components/page/Media.svelte +5 -4
  28. package/dist/components/page/MediaPreview.svelte +8 -2
  29. package/dist/components/page/MediaPreview.svelte.d.ts +1 -0
  30. package/dist/components/page/Outline.svelte +1 -1
  31. package/dist/components/page/Page.svelte +6 -3
  32. package/dist/components/page/Reference.svelte +1 -1
  33. package/dist/components/page/Search.svelte +1 -1
  34. package/dist/components/page/TableOfContents.svelte +3 -6
  35. package/dist/components/page/TableOfContentsRow.svelte +1 -1
  36. package/dist/components/page/ThemeEditor.svelte +1 -1
  37. package/dist/models/CRUD.d.ts +1 -0
  38. package/dist/models/CRUD.js +17 -4
  39. package/dist/models/book/BookMedia.d.ts +3 -0
  40. package/dist/models/book/BookMedia.js +22 -5
  41. package/dist/models/book/Chapter.d.ts +9 -7
  42. package/dist/models/book/Chapter.js +16 -6
  43. package/dist/models/book/Edition.d.ts +6 -1
  44. package/dist/models/book/Edition.js +22 -10
  45. package/dist/models/book/Words.js +0 -1
  46. package/dist/models/chapter/ChapterNode.d.ts +1 -0
  47. package/dist/models/chapter/ChapterNode.js +3 -0
  48. package/dist/models/chapter/DefinitionNode.js +1 -1
  49. package/dist/models/chapter/FormatNode.js +4 -1
  50. package/dist/models/chapter/InlineCodeNode.js +1 -1
  51. package/dist/models/chapter/LinkNode.js +1 -1
  52. package/dist/models/chapter/ListNode.js +1 -1
  53. package/dist/models/chapter/Parser.js +3 -6
  54. package/package.json +20 -18
@@ -2,9 +2,10 @@
2
2
  import { goto } from '$app/navigation';
3
3
  import { onMount, setContext, tick } from 'svelte';
4
4
  import { writable } from 'svelte/store';
5
- import { BASE, DARK_MODE, } from './Contexts';
5
+ import { BASE, DARK_MODE, getBook, } from './Contexts';
6
6
  import { BookishTheme } from '../../models/book/Theme';
7
7
  import { isDark, setDark } from '../../util/dark';
8
+ import { page } from '$app/stores';
8
9
  // Poly fill smooth scrolling for Safari.
9
10
  onMount(() => smoothscroll.polyfill());
10
11
  // The book edition to render.
@@ -14,6 +15,7 @@ export let edition;
14
15
  // but when the book is being viewed or edited in the Bookish app, it needs a prefix for the
15
16
  // route in the app.
16
17
  export let base = '';
18
+ let book = getBook();
17
19
  // When the base changes, update the context.
18
20
  let baseStore = writable(base);
19
21
  $: setContext(BASE, baseStore);
@@ -84,10 +86,15 @@ function toRules(set) {
84
86
  <meta property="og:title" content={edition.getTitle()} />
85
87
  <meta property="og:image" content={edition.getImage('cover')} />
86
88
  <meta property="og:description" content={edition.getDescription()} />
87
- <!-- <meta
89
+ <meta property="og:type" content="book" />
90
+ <meta property="og:author" content={edition.getAuthorsText()} />
91
+ <meta
88
92
  property="og:url"
89
- content="https://criticallyconsciouscomputing.org"
90
- /> -->
93
+ content={edition.base ??
94
+ `https://bookish.press/${
95
+ $book && $book.domain ? $book.domain : $page.params.bookid
96
+ }`}
97
+ />
91
98
  </svelte:head>
92
99
 
93
100
  <main class="bookish {$darkMode ? ' dark' : ''}">
@@ -4,7 +4,7 @@ import Feedback from '../app/Feedback.svelte';
4
4
  import { getBook, getEdition, getUser, getChapterText, } from './Contexts';
5
5
  import { onDestroy } from 'svelte';
6
6
  import { page } from '$app/stores';
7
- import { listenToChapters, listenToEdition } from '../../models/CRUD';
7
+ import { deleteChapterText, listenToChapters, listenToEdition, } from '../../models/CRUD';
8
8
  export let write;
9
9
  let auth = getUser();
10
10
  let book = getBook();
@@ -67,8 +67,17 @@ function listen() {
67
67
  // Update the chapter text store.
68
68
  chapterText.set(chapters);
69
69
  // Update the chapter text in the current edition.
70
- if ($edition)
71
- edition.set($edition.withChapterText(chapters));
70
+ if ($edition) {
71
+ const newEdition = $edition.withChapterText(chapters);
72
+ if (newEdition !== $edition)
73
+ edition.set(newEdition);
74
+ // Get all the chapters in the updated chapter list and
75
+ // delete the ones not referenced by the edition.
76
+ chapters
77
+ .filter(([ref]) => $edition &&
78
+ !$edition.hasChapterRefID(ref.id))
79
+ .forEach(([ref]) => deleteChapterText(ref));
80
+ }
72
81
  });
73
82
  }
74
83
  }
@@ -84,7 +84,7 @@ async function handleDraftEdition() {
84
84
  undefined,
85
85
  edition.summary
86
86
  )}
87
- />{:else}&mdash{/if}</p
87
+ />{:else}&mdash;{/if}</p
88
88
  >
89
89
  </section>
90
90
  {/if}
@@ -54,80 +54,84 @@ let embed;
54
54
  $: embed = getImage();
55
55
  </script>
56
56
 
57
- <header class="bookish-chapter-header">
58
- {#if embed}
59
- <div class="bookish-figure-full">
60
- {#if $edition && editable}
61
- <BookishEditor
62
- text={embed}
63
- parser={(text) => Parser.parseEmbed($edition, text)}
64
- save={(node) =>
65
- setImage ? setImage(node.toBookdown()) : undefined}
66
- chapter={false}
67
- component={Embed}
68
- placeholder=""
69
- leasee={getLeasee(auth, edition, `embed-${id}`)}
70
- lease={(lock) => lease(auth, edition, `embed-${id}`, lock)}
71
- />
72
- {:else}
73
- <Embed
74
- node={Parser.parseEmbed($edition, embed)}
75
- editable={false}
76
- />
77
- {/if}
78
- {#if !print && showReminder}
79
- <div class="bookish-scroll-reminder" />
80
- {/if}
81
- </div>
82
- {:else}
83
- <!-- Add a bit of space to account for the lack of an image. -->
84
- <p>&nbsp;</p>
85
- {/if}
86
- {#if !print}
87
- <slot name="outline" />
88
- {/if}
89
- {#if editable}
90
- {#if embed === null}
91
- <Button tooltip="add cover image" command={addCover}
92
- >+ cover image</Button
93
- >
57
+ <!-- We key on the chapter ID to avoid laggy updates from image loading -->
58
+ {#key id}
59
+ <header class="bookish-chapter-header">
60
+ {#if embed}
61
+ <div class="bookish-figure-full">
62
+ {#if $edition && editable}
63
+ <BookishEditor
64
+ text={embed}
65
+ parser={(text) => Parser.parseEmbed($edition, text)}
66
+ save={(node) =>
67
+ setImage ? setImage(node.toBookdown()) : undefined}
68
+ chapter={false}
69
+ component={Embed}
70
+ placeholder=""
71
+ leasee={getLeasee(auth, edition, `embed-${id}`)}
72
+ lease={(lock) =>
73
+ lease(auth, edition, `embed-${id}`, lock)}
74
+ />
75
+ {:else}
76
+ <Embed
77
+ node={Parser.parseEmbed($edition, embed)}
78
+ editable={false}
79
+ />
80
+ {/if}
81
+ {#if !print && showReminder}
82
+ <div class="bookish-scroll-reminder" />
83
+ {/if}
84
+ </div>
94
85
  {:else}
95
- <ConfirmButton
96
- confirm="remove image?"
97
- tooltip="remove cover image"
98
- command={removeCover}>- cover image</ConfirmButton
99
- >
86
+ <!-- Add a bit of space to account for the lack of an image. -->
87
+ <p>&nbsp;</p>
100
88
  {/if}
101
- {/if}
102
- <div bind:this={title} class="bookish-chapter-header-text">
103
- <slot name="before" />
104
- <Title>
105
- {#if editable && save}
106
- <TextEditor
107
- {label}
108
- text={header + (subtitle ? ': ' + subtitle : '')}
109
- placeholder="title"
110
- valid={(text) =>
111
- text.length === 0
112
- ? 'Titles have to be at least one character long.'
113
- : undefined}
114
- {save}
115
- />
89
+ {#if !print}
90
+ <slot name="outline" />
91
+ {/if}
92
+ {#if editable}
93
+ {#if embed === null}
94
+ <Button tooltip="add cover image" command={addCover}
95
+ >+ cover image</Button
96
+ >
116
97
  {:else}
117
- {header}
118
- {#if subtitle}<div class="bookish-subtitle">{subtitle}</div
119
- >{/if}
98
+ <ConfirmButton
99
+ confirm="delete image"
100
+ tooltip="delete cover image"
101
+ command={removeCover}>- cover image</ConfirmButton
102
+ >
120
103
  {/if}
121
- </Title>
122
- <slot name="after" />
123
- {#if tags}
124
- <div
125
- >{#each tags as tag}<span class="bookish-tag">{tag}</span
126
- >{/each}</div
127
- >
128
104
  {/if}
129
- </div>
130
- </header>
105
+ <div bind:this={title} class="bookish-chapter-header-text">
106
+ <slot name="before" />
107
+ <Title>
108
+ {#if editable && save}
109
+ <TextEditor
110
+ {label}
111
+ text={header + (subtitle ? ': ' + subtitle : '')}
112
+ placeholder="title"
113
+ valid={(text) =>
114
+ text.length === 0
115
+ ? 'Titles have to be at least one character long.'
116
+ : undefined}
117
+ {save}
118
+ />
119
+ {:else}
120
+ {header}
121
+ {#if subtitle}<div class="bookish-subtitle">{subtitle}</div
122
+ >{/if}
123
+ {/if}
124
+ </Title>
125
+ <slot name="after" />
126
+ {#if tags}
127
+ <div
128
+ >{#each tags as tag}<span class="bookish-tag">{tag}</span
129
+ >{/each}</div
130
+ >
131
+ {/if}
132
+ </div>
133
+ </header>
134
+ {/key}
131
135
 
132
136
  <style>
133
137
  .bookish-subtitle {
@@ -24,7 +24,7 @@ onMount(() => {
24
24
  media?.notify(updateImages);
25
25
  return () => media?.stopNotifying(updateImages);
26
26
  });
27
- $: unused = images?.filter((image) => embeds.find((embed) => embed.getURL() === image.url) === undefined);
27
+ $: unused = images?.filter((image) => !embeds.some((embed) => image.url.includes(embed.embed.getURL())));
28
28
  </script>
29
29
 
30
30
  {#if $edition}
@@ -59,9 +59,10 @@ $: unused = images?.filter((image) => embeds.find((embed) => embed.getURL() ===
59
59
  {:else}
60
60
  <p>These are the images in the book:</p>
61
61
  {/if}
62
- {#each embeds as embed}
62
+ {#each embeds as { embed, chapterID }}
63
63
  {#if !embed.isVideo()}
64
64
  <MediaPreview
65
+ {chapterID}
65
66
  url={embed.isLocal()
66
67
  ? `${$base}/${embed.getSmallURL()}`
67
68
  : embed.getSmallURL()}
@@ -89,10 +90,10 @@ $: unused = images?.filter((image) => embeds.find((embed) => embed.getURL() ===
89
90
  be used in other editions of this book.
90
91
  </Instructions>
91
92
  {#each unused as image}
92
- <MediaPreview url={image.url} alt={''}>
93
+ <MediaPreview chapterID={undefined} url={image.url} alt={''}>
93
94
  <ConfirmButton
94
95
  tooltip="delete unused image"
95
- confirm="delete image?"
96
+ confirm="delete image"
96
97
  command={() =>
97
98
  media
98
99
  ?.remove(image)
@@ -1,5 +1,9 @@
1
- <script>export let url;
1
+ <script>import Link from '../Link.svelte';
2
+ import { getBase } from './Contexts';
3
+ export let url;
2
4
  export let alt;
5
+ export let chapterID;
6
+ let base = getBase();
3
7
  let loaded;
4
8
  let urlChecked;
5
9
  $: {
@@ -18,7 +22,9 @@ $: {
18
22
 
19
23
  <figure class={'media-preview'}>
20
24
  {#if loaded}
21
- <img src={url.startsWith('http') ? url : `${url}`} {alt} />
25
+ <Link to={`${$base}/${chapterID}`} linked={chapterID !== undefined}
26
+ ><img src={url.startsWith('http') ? url : `${url}`} {alt} /></Link
27
+ >
22
28
  {:else if loaded === null}
23
29
  <div class="missing">missing image</div>
24
30
  {/if}
@@ -3,6 +3,7 @@ declare const __propDef: {
3
3
  props: {
4
4
  url: string;
5
5
  alt: string;
6
+ chapterID: string | undefined;
6
7
  };
7
8
  events: {
8
9
  [evt: string]: CustomEvent<any>;
@@ -129,7 +129,7 @@ let expandLabel = '\u2630';
129
129
  aria-label={expanded
130
130
  ? 'Collapse navigation menu'
131
131
  : 'Expand navigation menu'}
132
- tabIndex="0"
132
+ tabindex="0"
133
133
  on:click={headers.length > 0 ? toggleExpanded : undefined}
134
134
  on:keydown={(event) => {
135
135
  if (event.key === 'Enter' || event.key === ' ')
@@ -2,10 +2,12 @@
2
2
  import Loading from './Loading.svelte';
3
3
  export let title;
4
4
  export let afterLoaded = undefined;
5
- let loaded = false;
5
+ let fontsLoaded = false;
6
+ let imagesLoaded = false;
6
7
  let lastHeight = 0;
7
8
  let intervalID = null;
8
9
  let mountTime = Date.now();
10
+ $: loaded = fontsLoaded && imagesLoaded;
9
11
  function watchLoading() {
10
12
  const bodyHeight = document.body.clientHeight;
11
13
  // If the height hasn't changed and images are loaded OR it's been 2 seconds, load.
@@ -14,7 +16,7 @@ function watchLoading() {
14
16
  // Stop watching the images
15
17
  stopWatching();
16
18
  // Set to loaded to hide the overlay and notify callback if there is one.
17
- loaded = true;
19
+ imagesLoaded = true;
18
20
  if (afterLoaded)
19
21
  afterLoaded();
20
22
  }
@@ -29,6 +31,7 @@ onMount(() => {
29
31
  // Watch the height of the document and wait until it's been stable for a while before scrolling
30
32
  // to any targets. We use the data below to monitor the document height over time.
31
33
  intervalID = setInterval(() => watchLoading(), 50);
34
+ document.fonts.ready.then(() => (fontsLoaded = true));
32
35
  return () => stopWatching();
33
36
  });
34
37
  function imagesAreLoaded() {
@@ -45,7 +48,7 @@ function imagesAreLoaded() {
45
48
  <meta name="description" content={title} />
46
49
  </svelte:head>
47
50
 
48
- <section class={'bookish-page' + (loaded ? ' loaded' : '')}>
51
+ <section class={'bookish-page' + (imagesLoaded ? ' loaded' : '')}>
49
52
  <slot />
50
53
  </section>
51
54
  <!-- Overlay loading feedback if loading, but still render the page. -->
@@ -249,7 +249,7 @@ function move(field, direction) {
249
249
  {#if edit && editable && !reference.short}
250
250
  <ConfirmButton
251
251
  tooltip="delete reference {reference.title}"
252
- confirm="delete reference..."
252
+ confirm="delete reference"
253
253
  command={() =>
254
254
  $edition
255
255
  ? edition.set($edition.withoutReference(reference.citationID))
@@ -83,7 +83,7 @@ $: {
83
83
 
84
84
  <p>
85
85
  Type a word—just a single word—and we'll show its occurrences in
86
- this book:
86
+ this book's chapters:
87
87
  </p>
88
88
 
89
89
  <input
@@ -138,7 +138,7 @@ onMount(() => {
138
138
  </Instructions>
139
139
 
140
140
  <PageHeader id="chapters"
141
- >Chapters {#if editable}<Button
141
+ >Chapters{#if editable}&nbsp;<Button
142
142
  tooltip="add empty chapter"
143
143
  disabled={waitingForChapter}
144
144
  command={addChapter}>+ chapter</Button
@@ -273,10 +273,7 @@ onMount(() => {
273
273
 
274
274
  <!-- Book citation -->
275
275
  <PageParagraph>
276
- {$edition
277
- .getAuthors()
278
- .map((author) => Parser.parseFormat($edition, author).toText())
279
- .join(', ')} ({new Date().getFullYear()}).
276
+ {$edition.getAuthorsText()} ({new Date().getFullYear()}).
280
277
  <em>{$edition.getTitle()}</em>. {bookURL},
281
278
  <em>retrieved {new Date().toLocaleDateString('en-US')}</em>.
282
279
  </PageParagraph>
@@ -292,7 +289,7 @@ onMount(() => {
292
289
  {/if}
293
290
 
294
291
  {#if $book && (editable || isEditionPartiallyEditable())}
295
- <PageHeader>Editors</PageHeader>
292
+ <PageHeader id="editors">Editors</PageHeader>
296
293
  <Instructions {editable}>
297
294
  These authors can edit this edition and its chapters. To give
298
295
  book-level permissions, edit the book editors on the <Link
@@ -76,7 +76,7 @@ $: chapterURL =
76
76
  >
77
77
  <ConfirmButton
78
78
  tooltip="delete chapter {title}"
79
- confirm="delete?"
79
+ confirm="delete chapter"
80
80
  command={() =>
81
81
  chapter && $edition
82
82
  ? edition.set(
@@ -87,7 +87,7 @@ function getEmptyGroup(group) {
87
87
  {:else}
88
88
  <ConfirmButton
89
89
  tooltip="change theme to the default"
90
- confirm="delete your theme?"
90
+ confirm="delete theme"
91
91
  command={() =>
92
92
  $edition
93
93
  ? edition.set($edition.withTheme(null))
@@ -20,6 +20,7 @@ export declare function updateLock(edition: Edition, uid: string, content: strin
20
20
  * returns a map of DocumentReferences for new chapters so they can be persisted client side.
21
21
  */
22
22
  export declare function updateEdition(book: Book, previousEdition: Edition | undefined, newEdition: Edition): Promise<Map<string, DocumentReference>>;
23
+ export declare function deleteChapterText(ref: DocumentReference): Promise<void>;
23
24
  export declare function updateBook(book: Book): Promise<void>;
24
25
  export declare function isSubdomainAvailable(subdomain: string, book: Book): Promise<boolean>;
25
26
  export declare function getBookIDWithSubdomain(subdomain: string): Promise<string | null>;
@@ -60,8 +60,12 @@ export function listenToChapters(bookID, editionID, react) {
60
60
  throw NO_DATABASE_CONNECTION;
61
61
  return onSnapshot(collection(db, 'books', bookID, 'editions', editionID, 'chapters'), (docs) => {
62
62
  const chapters = [];
63
+ // For each chapter in the book, if the doc exists and it's not a pending local write, send updated chapters to the front end.
63
64
  docs.forEach((doc) => {
64
- chapters.push([doc.ref, doc.data().text]);
65
+ if (doc.exists() && !doc.metadata.hasPendingWrites)
66
+ // If the chapter text is somehow null, set it to an empty string.
67
+ // This accounts for previous defects that could lead to null.
68
+ chapters.push([doc.ref, doc.data().text ?? '']);
65
69
  });
66
70
  react(chapters);
67
71
  });
@@ -182,7 +186,9 @@ export async function updateEdition(book, previousEdition, newEdition) {
182
186
  for (const newChapter of newEdition.chapters) {
183
187
  if (newChapter.ref === undefined) {
184
188
  // Make the new chapter's text.
185
- const newChapterRef = await addDoc(collection(db, editionPath(newEdition.bookRef.id, editionRef.id), 'chapters'), { text: newChapter.text });
189
+ const newChapterRef = await addDoc(collection(db, editionPath(newEdition.bookRef.id, editionRef.id), 'chapters'),
190
+ // If the new chapter has null text, set it to empty string
191
+ { text: newChapter.text ?? '' });
186
192
  // Add the ref of the new chapter to the revised chapter.
187
193
  newEdition = newEdition.withRevisedChapter(newChapter, newChapter.withRef(newChapterRef));
188
194
  // Map the chapter ref to it's id.
@@ -193,8 +199,12 @@ export async function updateEdition(book, previousEdition, newEdition) {
193
199
  const previousChapter = previousEdition === undefined
194
200
  ? undefined
195
201
  : previousEdition.getChapterByRef(newChapter.ref);
196
- if (previousChapter === undefined ||
197
- newChapter.text !== previousChapter.text) {
202
+ if (
203
+ // A null chapter text means that we haven't loaded it yet from the database, so we
204
+ // only set it if it's not null.
205
+ newChapter.text !== null &&
206
+ (previousChapter === undefined ||
207
+ newChapter.text !== previousChapter.text)) {
198
208
  await setDoc(doc(db, editionPath(newEdition.bookRef.id, editionRef.id), 'chapters', newChapter.ref.id), { text: newChapter.text });
199
209
  }
200
210
  }
@@ -225,6 +235,9 @@ export async function updateEdition(book, previousEdition, newEdition) {
225
235
  await setDoc(doc(db, editionPath(newEdition.bookRef.id, editionRef.id)), newEdition.toObject());
226
236
  return newChapterRefs;
227
237
  }
238
+ export async function deleteChapterText(ref) {
239
+ deleteDoc(ref);
240
+ }
228
241
  export async function updateBook(book) {
229
242
  if (!db)
230
243
  throw Error("Can't update book, not connected to Firebase.");
@@ -2,6 +2,7 @@ import type { StorageReference } from 'firebase/storage';
2
2
  import type Book from './Book';
3
3
  export type Image = {
4
4
  url: string;
5
+ thumb: string;
5
6
  ref: StorageReference;
6
7
  };
7
8
  export default class BookMedia {
@@ -17,6 +18,8 @@ export default class BookMedia {
17
18
  /** Get a listing of all of the images for the book. */
18
19
  cacheImages(): Promise<Image[]>;
19
20
  retrieveImages(): Promise<(Image | undefined)[]>;
21
+ stripTokenFromURL(url: string): string;
22
+ makeThumbnailURL(url: string): string;
20
23
  getThumbnailURL(url: string, thumbnailRef: StorageReference, error: (message: string) => void, finished: (url: string, thumbnailURL: string) => void, attempt?: number): void;
21
24
  upload(file: File, _: (progress: number) => void, errorHandler: (message: string) => void, finishedHandler: (url: string, thumbnailURL: string) => void): void;
22
25
  remove(image: Image): Promise<Image[] | undefined>;
@@ -1,5 +1,4 @@
1
1
  import { deleteObject, getDownloadURL, listAll, ref, uploadBytes, } from 'firebase/storage';
2
- import { v4 as uuidv4 } from 'uuid';
3
2
  import { storage } from '../Firebase';
4
3
  export default class BookMedia {
5
4
  book;
@@ -43,7 +42,8 @@ export default class BookMedia {
43
42
  // Find all the prefixes and items.
44
43
  return listAll(listRef).then((res) => Promise.all(res.items.map((itemRef) => getDownloadURL(itemRef).then((url) => {
45
44
  const image = {
46
- url: url,
45
+ url,
46
+ thumb: this.makeThumbnailURL(url),
47
47
  description: '',
48
48
  ref: itemRef,
49
49
  };
@@ -53,6 +53,22 @@ export default class BookMedia {
53
53
  return image;
54
54
  }))));
55
55
  }
56
+ stripTokenFromURL(url) {
57
+ const tokenIndex = url.indexOf('&token=');
58
+ return tokenIndex >= 0 ? (url = url.substring(0, tokenIndex)) : url;
59
+ }
60
+ // Construct a thumbnail URL from the base URL.
61
+ makeThumbnailURL(url) {
62
+ // The structure of the URL is
63
+ // https://[firebase-domain]/v0/b/[projectid].appspot.com/o/images%2F[bookID]%2F[imagename]?alt=media
64
+ // And we want
65
+ // https://[firebase-domain]/v0/b/[projectid].appspot.com/o/images%2F[bookID]%2Fthumbnails%2F[imagename]?alt=media
66
+ const bookID = this.book.getID();
67
+ const bookIDIndex = url.indexOf(bookID);
68
+ return bookIDIndex >= 0
69
+ ? `${url.substring(0, bookIDIndex)}${bookID}%2Fthumbnails${url.substring(bookIDIndex + bookID.length)}`
70
+ : url;
71
+ }
56
72
  getThumbnailURL(url, thumbnailRef, error, finished, attempt = 0) {
57
73
  getDownloadURL(thumbnailRef)
58
74
  .then((thumbnail) => finished(url, thumbnail))
@@ -64,9 +80,10 @@ export default class BookMedia {
64
80
  upload(file, _, errorHandler, finishedHandler) {
65
81
  if (storage === undefined)
66
82
  return;
83
+ // We use the file name given, but append a unique string to avoid collisions.
84
+ const imageName = file.name;
67
85
  // The canonical path format for Bookish images in the store is image/{bookid}/{imageid}
68
- // where {bookid} is the Firestore ID of the book being edited and {imageid} is just a random id.
69
- const imageName = `image-${uuidv4()}`;
86
+ // where {bookid} is the Firestore document ID of the book being edited and {imageid} is just a random id.
70
87
  const imageRef = ref(storage, `${this.getImagePath()}/${imageName}`);
71
88
  const thumbnailRef = ref(storage, `${this.getImagePath()}/thumbnails/${imageName}`);
72
89
  uploadBytes(imageRef, file)
@@ -74,7 +91,7 @@ export default class BookMedia {
74
91
  getDownloadURL(snapshot.ref)
75
92
  // After we get the download URL for the uploaded image,
76
93
  // try to get the thumbnail URL for the resized image on the server.
77
- .then((url) => this.getThumbnailURL(url, thumbnailRef, errorHandler, finishedHandler))
94
+ .then((url) => finishedHandler(this.stripTokenFromURL(url), this.makeThumbnailURL(this.stripTokenFromURL(url))))
78
95
  .catch(() => errorHandler('Unable to get image URL.'));
79
96
  })
80
97
  .then(() => this.cacheImages())
@@ -10,7 +10,8 @@ export type ChapterSpecification = {
10
10
  numbered: boolean;
11
11
  forthcoming: boolean;
12
12
  section?: string;
13
- text?: string;
13
+ /** A string means that we know the chapter's text; null means we haven't loaded it yet from the database and should not be editing or saving it. */
14
+ text?: string | null;
14
15
  uids: string[];
15
16
  };
16
17
  export type ChapterContent = {
@@ -30,7 +31,7 @@ export default class Chapter {
30
31
  readonly numbered: boolean;
31
32
  readonly forthcoming: boolean;
32
33
  readonly section: string | undefined;
33
- readonly text: string;
34
+ readonly text: string | null;
34
35
  readonly uids: string[];
35
36
  _ast: ChapterNode | undefined;
36
37
  _index: Record<string, Array<Match>> | undefined;
@@ -41,15 +42,16 @@ export default class Chapter {
41
42
  }[];
42
43
  lastEdit: number;
43
44
  listeners: Set<(text: string) => void>;
44
- constructor(ref: DocumentReference | undefined, id: string, title: string, authors: string[], image: string | null, numbered: boolean, forthcoming: boolean, section: string | undefined, text: string, uids: string[]);
45
+ constructor(ref: DocumentReference | undefined, id: string, title: string, authors: string[], image: string | null, numbered: boolean, forthcoming: boolean, section: string | undefined, text: string | null, uids: string[]);
45
46
  /**
46
47
  * Convert a chapter spec and it's text into a chapter object.
47
48
  * The spec may contain the text itself, but it may not, so we require it to be
48
49
  * repeated here to guarantee its provided.
49
50
  */
50
- static fromJSON(spec: ChapterSpecification, text: string): Chapter;
51
+ static fromJSON(spec: ChapterSpecification): Chapter;
51
52
  toJSON(): ChapterSpecification;
52
53
  getRef(): DocumentReference<import("@firebase/firestore").DocumentData> | undefined;
54
+ hasRefID(id: string): boolean;
53
55
  withEditors(uids: string[]): Chapter;
54
56
  withRef(ref: DocumentReference): Chapter;
55
57
  withoutRef(): Chapter;
@@ -62,7 +64,7 @@ export default class Chapter {
62
64
  isNumbered(): boolean;
63
65
  asNumbered(numbered: boolean): Chapter;
64
66
  isEditor(uid: string): boolean;
65
- getText(): string;
67
+ getText(): string | null;
66
68
  withText(text: string): Chapter;
67
69
  withAST(ast: ChapterNode): Chapter;
68
70
  getAuthors(): string[];
@@ -73,8 +75,8 @@ export default class Chapter {
73
75
  withTitle(title: string): Chapter;
74
76
  getImage(): string | null;
75
77
  withImage(image: string | null): Chapter;
76
- getAST(edition: Edition): ChapterNode;
77
- getWordCount(edition: Edition): number;
78
+ getAST(edition: Edition): ChapterNode | undefined;
79
+ getWordCount(edition: Edition): number | undefined;
78
80
  getReadingTime(edition: Edition): number | undefined;
79
81
  getIndex(edition: Edition): Record<string, Match[]>;
80
82
  toString(): string;