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.
- package/dist/components/BookPreview.svelte +1 -0
- package/dist/components/chapter/Callout.svelte +0 -1
- package/dist/components/chapter/Embed.svelte +4 -3
- package/dist/components/chapter/ExtendedPrism.d.ts +1 -1
- package/dist/components/chapter/Figure.svelte +0 -1
- package/dist/components/chapter/Footnote.svelte +1 -0
- package/dist/components/chapter/List.svelte +16 -33
- package/dist/components/chapter/Paragraph.svelte +7 -2
- package/dist/components/chapter/Quote.svelte +6 -6
- package/dist/components/controls/DarkToggle.svelte +1 -1
- package/dist/components/editor/BookishEditor.svelte +9 -16
- package/dist/components/editor/Commands.js +14 -14
- package/dist/components/editor/ConfirmButton.svelte +1 -1
- package/dist/components/editor/EmbedEditor.svelte +3 -3
- package/dist/components/editor/ImageChooser.svelte +6 -26
- package/dist/components/editor/ImageThumbnail.svelte +61 -0
- package/dist/components/editor/ImageThumbnail.svelte.d.ts +19 -0
- package/dist/components/editor/Permissions.svelte +2 -0
- package/dist/components/editor/TextEditor.svelte +5 -2
- package/dist/components/editor/Toggle.svelte +1 -1
- package/dist/components/page/Chapter.svelte +1 -1
- package/dist/components/page/DefinitionView.svelte +43 -38
- package/dist/components/page/Edition.svelte +11 -4
- package/dist/components/page/EditionLoader.svelte +12 -3
- package/dist/components/page/Editions.svelte +1 -1
- package/dist/components/page/Header.svelte +73 -69
- package/dist/components/page/Media.svelte +5 -4
- package/dist/components/page/MediaPreview.svelte +8 -2
- package/dist/components/page/MediaPreview.svelte.d.ts +1 -0
- package/dist/components/page/Outline.svelte +1 -1
- package/dist/components/page/Page.svelte +6 -3
- package/dist/components/page/Reference.svelte +1 -1
- package/dist/components/page/Search.svelte +1 -1
- package/dist/components/page/TableOfContents.svelte +3 -6
- package/dist/components/page/TableOfContentsRow.svelte +1 -1
- package/dist/components/page/ThemeEditor.svelte +1 -1
- package/dist/models/CRUD.d.ts +1 -0
- package/dist/models/CRUD.js +17 -4
- package/dist/models/book/BookMedia.d.ts +3 -0
- package/dist/models/book/BookMedia.js +22 -5
- package/dist/models/book/Chapter.d.ts +9 -7
- package/dist/models/book/Chapter.js +16 -6
- package/dist/models/book/Edition.d.ts +6 -1
- package/dist/models/book/Edition.js +22 -10
- package/dist/models/book/Words.js +0 -1
- package/dist/models/chapter/ChapterNode.d.ts +1 -0
- package/dist/models/chapter/ChapterNode.js +3 -0
- package/dist/models/chapter/DefinitionNode.js +1 -1
- package/dist/models/chapter/FormatNode.js +4 -1
- package/dist/models/chapter/InlineCodeNode.js +1 -1
- package/dist/models/chapter/LinkNode.js +1 -1
- package/dist/models/chapter/ListNode.js +1 -1
- package/dist/models/chapter/Parser.js +3 -6
- 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
|
-
|
|
89
|
+
<meta property="og:type" content="book" />
|
|
90
|
+
<meta property="og:author" content={edition.getAuthorsText()} />
|
|
91
|
+
<meta
|
|
88
92
|
property="og:url"
|
|
89
|
-
content=
|
|
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
|
-
|
|
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
|
}
|
|
@@ -54,80 +54,84 @@ let embed;
|
|
|
54
54
|
$: embed = getImage();
|
|
55
55
|
</script>
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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> </p>
|
|
100
88
|
{/if}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
{#if
|
|
106
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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.
|
|
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>
|
|
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
|
-
<
|
|
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}
|
|
@@ -129,7 +129,7 @@ let expandLabel = '\u2630';
|
|
|
129
129
|
aria-label={expanded
|
|
130
130
|
? 'Collapse navigation menu'
|
|
131
131
|
: 'Expand navigation menu'}
|
|
132
|
-
|
|
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
|
|
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
|
-
|
|
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' + (
|
|
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))
|
|
@@ -138,7 +138,7 @@ onMount(() => {
|
|
|
138
138
|
</Instructions>
|
|
139
139
|
|
|
140
140
|
<PageHeader id="chapters"
|
|
141
|
-
>Chapters
|
|
141
|
+
>Chapters{#if editable} <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
|
package/dist/models/CRUD.d.ts
CHANGED
|
@@ -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>;
|
package/dist/models/CRUD.js
CHANGED
|
@@ -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
|
-
|
|
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'),
|
|
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 (
|
|
197
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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;
|