@kernhq/module-quire 0.13.1 → 0.14.0
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/contract/models.d.ts +185 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +133 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/permissions.d.ts +14 -0
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +82 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/properties.d.ts +4 -4
- package/dist/contract/router.d.ts +535 -8
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +133 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +519 -8
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +128 -0
- package/dist/server/_impl.js.map +1 -1
- package/dist/server/export/html.d.ts +67 -0
- package/dist/server/export/html.d.ts.map +1 -0
- package/dist/server/export/html.js +206 -0
- package/dist/server/export/html.js.map +1 -0
- package/dist/server/export/markdown.d.ts +51 -0
- package/dist/server/export/markdown.d.ts.map +1 -0
- package/dist/server/export/markdown.js +312 -0
- package/dist/server/export/markdown.js.map +1 -0
- package/dist/server/export/pdf.d.ts +20 -0
- package/dist/server/export/pdf.d.ts.map +1 -0
- package/dist/server/export/pdf.js +91 -0
- package/dist/server/export/pdf.js.map +1 -0
- package/dist/server/export/zip.d.ts +31 -0
- package/dist/server/export/zip.d.ts.map +1 -0
- package/dist/server/export/zip.js +158 -0
- package/dist/server/export/zip.js.map +1 -0
- package/dist/server/import/csv.d.ts +77 -0
- package/dist/server/import/csv.d.ts.map +1 -0
- package/dist/server/import/csv.js +263 -0
- package/dist/server/import/csv.js.map +1 -0
- package/dist/server/import/html.d.ts +52 -0
- package/dist/server/import/html.d.ts.map +1 -0
- package/dist/server/import/html.js +472 -0
- package/dist/server/import/html.js.map +1 -0
- package/dist/server/import/markdown.d.ts +63 -0
- package/dist/server/import/markdown.d.ts.map +1 -0
- package/dist/server/import/markdown.js +692 -0
- package/dist/server/import/markdown.js.map +1 -0
- package/dist/server/import/plan.d.ts +70 -0
- package/dist/server/import/plan.d.ts.map +1 -0
- package/dist/server/import/plan.js +761 -0
- package/dist/server/import/plan.js.map +1 -0
- package/dist/server/import/ydoc.d.ts +35 -0
- package/dist/server/import/ydoc.d.ts.map +1 -0
- package/dist/server/import/ydoc.js +91 -0
- package/dist/server/import/ydoc.js.map +1 -0
- package/dist/server/import/zip.d.ts +63 -0
- package/dist/server/import/zip.d.ts.map +1 -0
- package/dist/server/import/zip.js +308 -0
- package/dist/server/import/zip.js.map +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +3 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/schema.d.ts +445 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +146 -0
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/databases.d.ts +5 -5
- package/dist/server/services/export.d.ts +176 -0
- package/dist/server/services/export.d.ts.map +1 -0
- package/dist/server/services/export.js +822 -0
- package/dist/server/services/export.js.map +1 -0
- package/dist/server/services/import.d.ts +109 -0
- package/dist/server/services/import.d.ts.map +1 -0
- package/dist/server/services/import.js +570 -0
- package/dist/server/services/import.js.map +1 -0
- package/dist/server/services/index.d.ts +26 -1
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +60 -1
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/versions.d.ts +1 -1
- package/migrations/0010_transfers.sql +154 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/client/components/ExportDialog.svelte +685 -0
- package/src/client/components/ImportDialog.svelte +702 -0
- package/src/client/components/ImportReport.svelte +310 -0
- package/src/client/components/SidebarSpaces.svelte +79 -0
- package/src/client/i18n.ts +614 -0
- package/src/client/index.ts +32 -0
- package/src/client/mock.ts +318 -0
- package/src/client/module.ts +36 -0
- package/src/client/pages/PageView.svelte +36 -0
- package/src/client/pages/TransfersPage.svelte +570 -0
- package/src/client/permissions.ts +11 -0
- package/src/client/query.ts +23 -0
- package/src/client/transfers.ts +142 -0
- package/src/contract/models.ts +152 -0
- package/src/contract/permissions.ts +84 -0
- package/src/contract/router.ts +147 -0
package/src/client/index.ts
CHANGED
|
@@ -27,8 +27,17 @@ export {
|
|
|
27
27
|
type Comment,
|
|
28
28
|
type CommentAnchor,
|
|
29
29
|
type CommentThread,
|
|
30
|
+
type ExportFormat,
|
|
31
|
+
type ExportJob,
|
|
32
|
+
type ExportJobDetail,
|
|
33
|
+
type ExportScope,
|
|
30
34
|
type Favorite,
|
|
31
35
|
type FavoriteEntry,
|
|
36
|
+
type ImportJob,
|
|
37
|
+
type ImportJobSummary,
|
|
38
|
+
type ImportOutcome,
|
|
39
|
+
type ImportReportEntry,
|
|
40
|
+
type ImportSource,
|
|
32
41
|
type Label,
|
|
33
42
|
type LabelColour,
|
|
34
43
|
MODULE_ID,
|
|
@@ -49,6 +58,8 @@ export {
|
|
|
49
58
|
type RecentView,
|
|
50
59
|
type Space,
|
|
51
60
|
type SpaceVisibility,
|
|
61
|
+
type TransferCounts,
|
|
62
|
+
type TransferState,
|
|
52
63
|
type VersionKind,
|
|
53
64
|
type Watcher,
|
|
54
65
|
type WatchState,
|
|
@@ -141,3 +152,24 @@ export {
|
|
|
141
152
|
publicSiteBasePath,
|
|
142
153
|
publicSiteUrl,
|
|
143
154
|
} from './public-url.js'
|
|
155
|
+
/**
|
|
156
|
+
* The vocabulary of a transfer, as labels rather than as enum members.
|
|
157
|
+
*
|
|
158
|
+
* Exported for the same reason `descriptorFor` and `viewIcon` are: a state, a scope, a format and a
|
|
159
|
+
* source have exactly one right name each in each language, and anything that ends up drawing one —
|
|
160
|
+
* a widget, a notification renderer, a screen written later — should read it from here rather than
|
|
161
|
+
* re-deriving a `switch` that has to be kept in step with the contract's enums.
|
|
162
|
+
*/
|
|
163
|
+
export {
|
|
164
|
+
EXPORT_FORMATS,
|
|
165
|
+
formatDescription,
|
|
166
|
+
formatLabel,
|
|
167
|
+
IMPORT_SOURCES,
|
|
168
|
+
isRunning,
|
|
169
|
+
progressRatio,
|
|
170
|
+
scopeLabel,
|
|
171
|
+
sourceDescription,
|
|
172
|
+
sourceLabel,
|
|
173
|
+
stateIcon,
|
|
174
|
+
stateLabel,
|
|
175
|
+
} from './transfers.js'
|
package/src/client/mock.ts
CHANGED
|
@@ -5,7 +5,13 @@ import type {
|
|
|
5
5
|
Database,
|
|
6
6
|
DatabaseRef,
|
|
7
7
|
Row as DatabaseRow,
|
|
8
|
+
ExportFormat,
|
|
9
|
+
ExportJob,
|
|
10
|
+
ExportScope,
|
|
8
11
|
FavoriteEntry,
|
|
12
|
+
ImportJob,
|
|
13
|
+
ImportReportEntry,
|
|
14
|
+
ImportSource,
|
|
9
15
|
Label,
|
|
10
16
|
LabelColour,
|
|
11
17
|
Page,
|
|
@@ -18,6 +24,7 @@ import type {
|
|
|
18
24
|
RecentEntry,
|
|
19
25
|
RowRef,
|
|
20
26
|
Space,
|
|
27
|
+
TransferCounts,
|
|
21
28
|
View,
|
|
22
29
|
ViewConfig,
|
|
23
30
|
ViewKind,
|
|
@@ -918,6 +925,217 @@ export function createMockQuireApi() {
|
|
|
918
925
|
return String(left).localeCompare(String(right))
|
|
919
926
|
}
|
|
920
927
|
|
|
928
|
+
// ----------------------------------------------------------------------------------------------
|
|
929
|
+
// Getting work in and out
|
|
930
|
+
// ----------------------------------------------------------------------------------------------
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* A transfer is a job, so the mock has to behave like one rather than answer instantly.
|
|
934
|
+
*
|
|
935
|
+
* Every screen here is a progress bar, a spinner and a report that arrives afterwards, and a mock
|
|
936
|
+
* that returned `done` from `start` would leave all three unreachable — the demo and the
|
|
937
|
+
* end-to-end sweep would only ever see the finished state. So a started job carries a real
|
|
938
|
+
* `createdAt` and its state is **computed from how long ago that was**: queued for the first
|
|
939
|
+
* second, running while it counts, then done. No timers, so nothing keeps running after the tab
|
|
940
|
+
* that made it has gone.
|
|
941
|
+
*
|
|
942
|
+
* The seeded rows cover the three states somebody has to be able to look at without waiting: a
|
|
943
|
+
* finished export with a file, a failed one with a reason worth reading, and a finished import
|
|
944
|
+
* whose report has all three outcomes in it.
|
|
945
|
+
*/
|
|
946
|
+
const EXPORT_STEP_MS = 320
|
|
947
|
+
const EXPORT_START_MS = 800
|
|
948
|
+
const IMPORT_READ_MS = 3200
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* A valid, empty zip — the 22-byte end-of-central-directory record and nothing else.
|
|
952
|
+
*
|
|
953
|
+
* There is no object storage behind `dev:mock`, so a download has to be something the browser can
|
|
954
|
+
* actually save; a `data:` URL is the only address that works without one. Empty rather than
|
|
955
|
+
* fabricated: a demo zip full of invented pages is a file somebody opens expecting their handbook.
|
|
956
|
+
*/
|
|
957
|
+
const EMPTY_ZIP = 'data:application/zip;base64,UEsFBgAAAAAAAAAAAAAAAAAAAAAAAA=='
|
|
958
|
+
|
|
959
|
+
const countsOf = (report: ImportReportEntry[]): TransferCounts => ({
|
|
960
|
+
total: report.length,
|
|
961
|
+
done: report.filter((r) => r.outcome === 'imported').length,
|
|
962
|
+
skipped: report.filter((r) => r.outcome === 'skipped').length,
|
|
963
|
+
failed: report.filter((r) => r.outcome === 'failed').length,
|
|
964
|
+
})
|
|
965
|
+
|
|
966
|
+
const exportJobs: ExportJob[] = [
|
|
967
|
+
{
|
|
968
|
+
id: uid(701),
|
|
969
|
+
workspaceId: '' as ExportJob['workspaceId'],
|
|
970
|
+
requestedBy: ME as ExportJob['requestedBy'],
|
|
971
|
+
scope: 'space',
|
|
972
|
+
targetId: uid(1),
|
|
973
|
+
format: 'markdown',
|
|
974
|
+
state: 'done',
|
|
975
|
+
fileId: uid(781),
|
|
976
|
+
error: null,
|
|
977
|
+
counts: { total: 8, done: 7, skipped: 1, failed: 0 },
|
|
978
|
+
createdAt: iso(72e5),
|
|
979
|
+
finishedAt: iso(71e5),
|
|
980
|
+
},
|
|
981
|
+
{
|
|
982
|
+
id: uid(702),
|
|
983
|
+
workspaceId: '' as ExportJob['workspaceId'],
|
|
984
|
+
requestedBy: ME as ExportJob['requestedBy'],
|
|
985
|
+
scope: 'subtree',
|
|
986
|
+
targetId: uid(102),
|
|
987
|
+
format: 'pdf',
|
|
988
|
+
state: 'failed',
|
|
989
|
+
fileId: null,
|
|
990
|
+
// The failure worth seeding is the one an operator can act on, written the way the service
|
|
991
|
+
// writes it: the PDF renderer is a separate container, and a stack with it switched off is by
|
|
992
|
+
// far the most common reason a PDF export dies.
|
|
993
|
+
error: 'The PDF service did not answer at http://gotenberg:3000 (connect ECONNREFUSED)',
|
|
994
|
+
counts: { total: 3, done: 3, skipped: 0, failed: 0 },
|
|
995
|
+
createdAt: iso(864e5),
|
|
996
|
+
finishedAt: iso(8636e4),
|
|
997
|
+
},
|
|
998
|
+
]
|
|
999
|
+
|
|
1000
|
+
/** One report with all three outcomes in it, because that is the whole point of the screen. */
|
|
1001
|
+
const seededImportReport: ImportReportEntry[] = [
|
|
1002
|
+
{ path: 'Company Handbook 8f2c41d0.md', outcome: 'imported', pageId: uid(101), reason: null },
|
|
1003
|
+
{
|
|
1004
|
+
path: 'Company Handbook 8f2c41d0/Getting started 5b91aa02.md',
|
|
1005
|
+
outcome: 'imported',
|
|
1006
|
+
pageId: uid(103),
|
|
1007
|
+
reason: null,
|
|
1008
|
+
},
|
|
1009
|
+
{
|
|
1010
|
+
path: 'Company Handbook 8f2c41d0/Tasks 71cc9e10.csv',
|
|
1011
|
+
outcome: 'imported',
|
|
1012
|
+
pageId: uid(110),
|
|
1013
|
+
// The one `imported` row that carries a reason, exactly as the server writes it: a CSV becomes
|
|
1014
|
+
// a database, and the column types it guessed are the thing somebody has to check.
|
|
1015
|
+
reason:
|
|
1016
|
+
'the first column, Name, became each row’s title; Status: read as a select with 3 choices, from 4 of 4 values; Due: read as a date; Done: read as a checkbox',
|
|
1017
|
+
},
|
|
1018
|
+
{
|
|
1019
|
+
path: 'Company Handbook 8f2c41d0/Tasks 71cc9e10_all.csv',
|
|
1020
|
+
outcome: 'skipped',
|
|
1021
|
+
pageId: null,
|
|
1022
|
+
reason: 'a second view of the same database; its rows came from “Tasks 71cc9e10.csv”',
|
|
1023
|
+
},
|
|
1024
|
+
{
|
|
1025
|
+
path: 'Company Handbook 8f2c41d0/diagram.png',
|
|
1026
|
+
outcome: 'skipped',
|
|
1027
|
+
pageId: null,
|
|
1028
|
+
reason: 'a picture, which an import cannot yet attach to a page',
|
|
1029
|
+
},
|
|
1030
|
+
{
|
|
1031
|
+
path: '__MACOSX/._Company Handbook 8f2c41d0.md',
|
|
1032
|
+
outcome: 'skipped',
|
|
1033
|
+
pageId: null,
|
|
1034
|
+
reason: 'a file the operating system added to the archive',
|
|
1035
|
+
},
|
|
1036
|
+
{
|
|
1037
|
+
path: 'Company Handbook 8f2c41d0/Expenses 04ab7731.md',
|
|
1038
|
+
outcome: 'failed',
|
|
1039
|
+
pageId: null,
|
|
1040
|
+
reason: 'its checksum does not match, so the file is damaged',
|
|
1041
|
+
},
|
|
1042
|
+
{
|
|
1043
|
+
path: 'Archive/Old wiki 22b0cc71.md',
|
|
1044
|
+
outcome: 'failed',
|
|
1045
|
+
pageId: null,
|
|
1046
|
+
reason:
|
|
1047
|
+
'nothing in the archive is at this path, so the link to it in “Company Handbook” is now plain text',
|
|
1048
|
+
},
|
|
1049
|
+
]
|
|
1050
|
+
|
|
1051
|
+
const importJobs: ImportJob[] = [
|
|
1052
|
+
{
|
|
1053
|
+
id: uid(711),
|
|
1054
|
+
workspaceId: '' as ImportJob['workspaceId'],
|
|
1055
|
+
requestedBy: ME as ImportJob['requestedBy'],
|
|
1056
|
+
source: 'notion',
|
|
1057
|
+
targetId: uid(1),
|
|
1058
|
+
sourceFileId: uid(791),
|
|
1059
|
+
state: 'done',
|
|
1060
|
+
error: null,
|
|
1061
|
+
counts: countsOf(seededImportReport),
|
|
1062
|
+
report: seededImportReport,
|
|
1063
|
+
createdAt: iso(1728e5),
|
|
1064
|
+
finishedAt: iso(17274e4),
|
|
1065
|
+
},
|
|
1066
|
+
]
|
|
1067
|
+
|
|
1068
|
+
/** Move a queued export along by however long it has been sitting there. */
|
|
1069
|
+
function advanceExport(row: ExportJob) {
|
|
1070
|
+
if (row.state === 'done' || row.state === 'failed') return
|
|
1071
|
+
const elapsed = Date.now() - new Date(row.createdAt).getTime()
|
|
1072
|
+
if (elapsed < EXPORT_START_MS) return
|
|
1073
|
+
const total = row.counts.total || 12
|
|
1074
|
+
const seen = Math.min(total, Math.floor((elapsed - EXPORT_START_MS) / EXPORT_STEP_MS))
|
|
1075
|
+
// One page withheld, so the "some pages were left out" sentence is reachable in the demo.
|
|
1076
|
+
const skipped = Math.min(total > 4 ? 1 : 0, seen)
|
|
1077
|
+
row.counts = { total, done: seen - skipped, skipped, failed: 0 }
|
|
1078
|
+
row.state = seen >= total ? 'done' : 'running'
|
|
1079
|
+
if (row.state === 'done') {
|
|
1080
|
+
row.fileId = row.fileId ?? nextId()
|
|
1081
|
+
row.finishedAt = new Date().toISOString()
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Move a queued import along, and — when it lands — actually write the pages.
|
|
1087
|
+
*
|
|
1088
|
+
* The pages matter: an import whose report says three pages arrived and whose sidebar is unchanged
|
|
1089
|
+
* is a demo of the wrong thing, and "Open the space" would lead somewhere that has not moved.
|
|
1090
|
+
*/
|
|
1091
|
+
function advanceImport(row: ImportJob) {
|
|
1092
|
+
if (row.state === 'done' || row.state === 'failed') return
|
|
1093
|
+
const elapsed = Date.now() - new Date(row.createdAt).getTime()
|
|
1094
|
+
if (elapsed < EXPORT_START_MS) return
|
|
1095
|
+
if (elapsed < IMPORT_READ_MS) {
|
|
1096
|
+
row.state = 'running'
|
|
1097
|
+
return
|
|
1098
|
+
}
|
|
1099
|
+
const made = ['Company Handbook', 'Getting started', 'How we work'].map((title, at) => {
|
|
1100
|
+
const created = page(++seq, row.targetId, title, `z${at}`, null, {})
|
|
1101
|
+
created.id = uid(seq)
|
|
1102
|
+
created.createdAt = new Date().toISOString()
|
|
1103
|
+
created.updatedAt = created.createdAt
|
|
1104
|
+
pages.push(created)
|
|
1105
|
+
return created.id
|
|
1106
|
+
})
|
|
1107
|
+
row.report = [
|
|
1108
|
+
{ path: 'Company Handbook 8f2c41d0.md', outcome: 'imported', pageId: made[0] ?? null, reason: null },
|
|
1109
|
+
{
|
|
1110
|
+
path: 'Company Handbook 8f2c41d0/Getting started 5b91aa02.md',
|
|
1111
|
+
outcome: 'imported',
|
|
1112
|
+
pageId: made[1] ?? null,
|
|
1113
|
+
reason: null,
|
|
1114
|
+
},
|
|
1115
|
+
{
|
|
1116
|
+
path: 'Company Handbook 8f2c41d0/How we work 90ff1a44.md',
|
|
1117
|
+
outcome: 'imported',
|
|
1118
|
+
pageId: made[2] ?? null,
|
|
1119
|
+
reason: null,
|
|
1120
|
+
},
|
|
1121
|
+
{
|
|
1122
|
+
path: 'Company Handbook 8f2c41d0/logo.png',
|
|
1123
|
+
outcome: 'skipped',
|
|
1124
|
+
pageId: null,
|
|
1125
|
+
reason: 'a picture, which an import cannot yet attach to a page',
|
|
1126
|
+
},
|
|
1127
|
+
{
|
|
1128
|
+
path: 'Company Handbook 8f2c41d0/Notes 04ab7731.md',
|
|
1129
|
+
outcome: 'failed',
|
|
1130
|
+
pageId: null,
|
|
1131
|
+
reason: 'its checksum does not match, so the file is damaged',
|
|
1132
|
+
},
|
|
1133
|
+
]
|
|
1134
|
+
row.counts = countsOf(row.report)
|
|
1135
|
+
row.state = 'done'
|
|
1136
|
+
row.finishedAt = new Date().toISOString()
|
|
1137
|
+
}
|
|
1138
|
+
|
|
921
1139
|
return {
|
|
922
1140
|
spaces: {
|
|
923
1141
|
list: async ({ includeArchived = false }: { includeArchived?: boolean } = {}) =>
|
|
@@ -1380,6 +1598,106 @@ export function createMockQuireApi() {
|
|
|
1380
1598
|
},
|
|
1381
1599
|
},
|
|
1382
1600
|
|
|
1601
|
+
/**
|
|
1602
|
+
* Taking work out.
|
|
1603
|
+
*
|
|
1604
|
+
* `list` and `get` advance every job before answering, which is what makes the progress bar move
|
|
1605
|
+
* in `dev:mock` — there is no worker here, so the passage of time is the worker. `docx` is
|
|
1606
|
+
* refused exactly as the server refuses it, with the same shape of message: a mock that quietly
|
|
1607
|
+
* accepted it would make the one format the product does not have look like the one it does.
|
|
1608
|
+
*/
|
|
1609
|
+
exports: {
|
|
1610
|
+
start: async (input: { scope: ExportScope; targetId: string; format: ExportFormat }) => {
|
|
1611
|
+
if (input.format === 'docx')
|
|
1612
|
+
throw Object.assign(
|
|
1613
|
+
new Error(
|
|
1614
|
+
'Word export is not available yet. Export as HTML or PDF, both of which Word opens, ' +
|
|
1615
|
+
'or as Markdown to move the pages somewhere else.',
|
|
1616
|
+
),
|
|
1617
|
+
{ code: 'BAD_REQUEST' },
|
|
1618
|
+
)
|
|
1619
|
+
if (input.scope === 'space') {
|
|
1620
|
+
if (!spaces.some((s) => s.id === input.targetId)) throw notFound('Space')
|
|
1621
|
+
} else found(input.targetId)
|
|
1622
|
+
const row: ExportJob = {
|
|
1623
|
+
id: nextId(),
|
|
1624
|
+
workspaceId: '' as ExportJob['workspaceId'],
|
|
1625
|
+
requestedBy: ME as ExportJob['requestedBy'],
|
|
1626
|
+
scope: input.scope,
|
|
1627
|
+
targetId: input.targetId,
|
|
1628
|
+
format: input.format,
|
|
1629
|
+
state: 'queued',
|
|
1630
|
+
fileId: null,
|
|
1631
|
+
error: null,
|
|
1632
|
+
counts: { total: 0, done: 0, skipped: 0, failed: 0 },
|
|
1633
|
+
createdAt: new Date().toISOString(),
|
|
1634
|
+
finishedAt: null,
|
|
1635
|
+
}
|
|
1636
|
+
exportJobs.unshift(row)
|
|
1637
|
+
return { ...row, downloadUrl: null }
|
|
1638
|
+
},
|
|
1639
|
+
|
|
1640
|
+
get: async ({ jobId }: { jobId: string }) => {
|
|
1641
|
+
const row = exportJobs.find((j) => j.id === jobId)
|
|
1642
|
+
if (!row) throw notFound('Export')
|
|
1643
|
+
advanceExport(row)
|
|
1644
|
+
/*
|
|
1645
|
+
* The link is minted here and not stored, exactly as the server does it — the download is a
|
|
1646
|
+
* signed URL that expires, so a row carrying one would be a row carrying an address that
|
|
1647
|
+
* stops working. A PDF export hands back the same empty zip: there is nothing to render.
|
|
1648
|
+
*/
|
|
1649
|
+
return { ...row, downloadUrl: row.state === 'done' ? EMPTY_ZIP : null }
|
|
1650
|
+
},
|
|
1651
|
+
|
|
1652
|
+
list: async ({ limit = 20 }: { limit?: number } = {}) => {
|
|
1653
|
+
for (const row of exportJobs) advanceExport(row)
|
|
1654
|
+
return exportJobs.slice(0, limit).map((row) => ({ ...row }))
|
|
1655
|
+
},
|
|
1656
|
+
},
|
|
1657
|
+
|
|
1658
|
+
/**
|
|
1659
|
+
* Getting work in.
|
|
1660
|
+
*
|
|
1661
|
+
* `start` does not read the upload — there is no storage behind `dev:mock` and nothing to read —
|
|
1662
|
+
* but it does keep the two checks that decide whether the job may exist at all: the space is
|
|
1663
|
+
* real, and the archive is a file this mock knows about. The pages appear when the job lands,
|
|
1664
|
+
* so the sidebar changes underneath the report rather than the report claiming pages nobody has.
|
|
1665
|
+
*/
|
|
1666
|
+
imports: {
|
|
1667
|
+
start: async (input: { spaceId: string; source: ImportSource; fileId: string }) => {
|
|
1668
|
+
if (!spaces.some((s) => s.id === input.spaceId)) throw notFound('Space')
|
|
1669
|
+
const row: ImportJob = {
|
|
1670
|
+
id: nextId(),
|
|
1671
|
+
workspaceId: '' as ImportJob['workspaceId'],
|
|
1672
|
+
requestedBy: ME as ImportJob['requestedBy'],
|
|
1673
|
+
source: input.source,
|
|
1674
|
+
targetId: input.spaceId,
|
|
1675
|
+
sourceFileId: input.fileId,
|
|
1676
|
+
state: 'queued',
|
|
1677
|
+
error: null,
|
|
1678
|
+
counts: { total: 0, done: 0, skipped: 0, failed: 0 },
|
|
1679
|
+
report: [],
|
|
1680
|
+
createdAt: new Date().toISOString(),
|
|
1681
|
+
finishedAt: null,
|
|
1682
|
+
}
|
|
1683
|
+
importJobs.unshift(row)
|
|
1684
|
+
return { ...row }
|
|
1685
|
+
},
|
|
1686
|
+
|
|
1687
|
+
get: async ({ jobId }: { jobId: string }) => {
|
|
1688
|
+
const row = importJobs.find((j) => j.id === jobId)
|
|
1689
|
+
if (!row) throw notFound('Import')
|
|
1690
|
+
advanceImport(row)
|
|
1691
|
+
return { ...row }
|
|
1692
|
+
},
|
|
1693
|
+
|
|
1694
|
+
/** Without the reports, as the server answers it — a list of thousands-of-rows reports is not a list. */
|
|
1695
|
+
list: async ({ limit = 20 }: { limit?: number } = {}) => {
|
|
1696
|
+
for (const row of importJobs) advanceImport(row)
|
|
1697
|
+
return importJobs.slice(0, limit).map(({ report: _report, ...summary }) => summary)
|
|
1698
|
+
},
|
|
1699
|
+
},
|
|
1700
|
+
|
|
1383
1701
|
publishing: {
|
|
1384
1702
|
publish: async ({ pageId, label = null }: { pageId: string; label?: string | null }) => {
|
|
1385
1703
|
const row = found(pageId)
|
package/src/client/module.ts
CHANGED
|
@@ -49,6 +49,27 @@ export const quireClientModule = defineClientModule({
|
|
|
49
49
|
component: () => import('./pages/SpacePage.svelte'),
|
|
50
50
|
permission: QUIRE_PERMISSIONS.spaceView,
|
|
51
51
|
},
|
|
52
|
+
/**
|
|
53
|
+
* Work on its way out and on its way in.
|
|
54
|
+
*
|
|
55
|
+
* Two literal segments, so it beats `/quire/:space` however the two are ordered — the shell
|
|
56
|
+
* scores a match as `literals * 1000 + segments`. Its place in this list is for a reader.
|
|
57
|
+
*
|
|
58
|
+
* **No `permission`, on purpose**, and this is the one route here without one. The screen has
|
|
59
|
+
* two halves behind two independent keys — `quire.page.export` is a member by default,
|
|
60
|
+
* `quire.page.import` is owner-and-admin — so a single gate would either hide it from somebody
|
|
61
|
+
* who may import or show it to somebody who may do neither; each half asks its own key instead.
|
|
62
|
+
* And a gate here would misbehave rather than 404: a route the shell refuses falls through to
|
|
63
|
+
* `/quire/:space`, which would tell the person "Space not found" about a space called
|
|
64
|
+
* *transfers*.
|
|
65
|
+
*/
|
|
66
|
+
{
|
|
67
|
+
path: '/quire/transfers',
|
|
68
|
+
component: () => import('./pages/TransfersPage.svelte'),
|
|
69
|
+
get title() {
|
|
70
|
+
return t('transfers')
|
|
71
|
+
},
|
|
72
|
+
},
|
|
52
73
|
/**
|
|
53
74
|
* Declared before `/quire/:space/:page` for a reader's benefit only — the shell resolves by
|
|
54
75
|
* specificity, not by order, so the two literal segments here beat the one in the page route
|
|
@@ -93,6 +114,21 @@ export const quireClientModule = defineClientModule({
|
|
|
93
114
|
permission: QUIRE_PERMISSIONS.spaceManage,
|
|
94
115
|
run: (ctx) => ctx.navigate('/quire?new=1'),
|
|
95
116
|
},
|
|
117
|
+
/*
|
|
118
|
+
* The palette is the second way to a running job, and the one that works from anywhere.
|
|
119
|
+
* Gated on `page.export` rather than left open: the command list is a menu, and an entry that
|
|
120
|
+
* leads to a screen with both halves empty is an entry that teaches people to ignore the list.
|
|
121
|
+
* Somebody who may import and not export reaches the screen from the import dialog instead.
|
|
122
|
+
*/
|
|
123
|
+
{
|
|
124
|
+
id: 'quire.transfers',
|
|
125
|
+
get label() {
|
|
126
|
+
return t('cmd_transfers')
|
|
127
|
+
},
|
|
128
|
+
icon: 'package',
|
|
129
|
+
permission: QUIRE_PERMISSIONS.pageExport,
|
|
130
|
+
run: (ctx) => ctx.navigate('/quire/transfers'),
|
|
131
|
+
},
|
|
96
132
|
],
|
|
97
133
|
|
|
98
134
|
sidebar: [
|
|
@@ -22,6 +22,7 @@ import { untrack } from 'svelte'
|
|
|
22
22
|
import { getQuireApi } from '../api-instance.js'
|
|
23
23
|
import CommentsPanel from '../components/CommentsPanel.svelte'
|
|
24
24
|
import ConfirmDialog from '../components/ConfirmDialog.svelte'
|
|
25
|
+
import ExportDialog from '../components/ExportDialog.svelte'
|
|
25
26
|
import FavoriteStar from '../components/FavoriteStar.svelte'
|
|
26
27
|
import PageEditor from '../components/PageEditor.svelte'
|
|
27
28
|
import PageLabels from '../components/PageLabels.svelte'
|
|
@@ -266,6 +267,15 @@ async function revert() {
|
|
|
266
267
|
let shareOpen = $state(false)
|
|
267
268
|
const canPublish = $derived(canQuire('pagePublish'))
|
|
268
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Taking this page out as a file.
|
|
272
|
+
*
|
|
273
|
+
* Nothing is loaded for it here: the dialog is the only thing that knows what an export needs, and
|
|
274
|
+
* it asks for the space, the tree and any job already running the moment it opens. This screen's
|
|
275
|
+
* whole part in the feature is a flag and a menu entry, which is what an entry point should be.
|
|
276
|
+
*/
|
|
277
|
+
let exportOpen = $state(false)
|
|
278
|
+
|
|
269
279
|
const publicationsQuery = createQuery(() => ({
|
|
270
280
|
queryKey: quireKeys.publications(workspaceId, doc?.spaceId ?? ''),
|
|
271
281
|
enabled: canPublish && Boolean(workspaceId && doc?.spaceId),
|
|
@@ -592,6 +602,25 @@ async function undoTrash(workspace: string, id: string, spaceId: string, title:
|
|
|
592
602
|
},
|
|
593
603
|
]
|
|
594
604
|
: []),
|
|
605
|
+
/*
|
|
606
|
+
* Every kind of page, not only a `page`. A live doc and a database both render through
|
|
607
|
+
* `renderPageDoc` like anything else — a database exports as the table it is — so the
|
|
608
|
+
* restriction that belongs on **Share to the web** does not belong here.
|
|
609
|
+
*
|
|
610
|
+
* `page.export` rather than `page.view`, matching what `exports.start` asks: reading a
|
|
611
|
+
* handbook a page at a time and walking out with the whole thing in a zip are different
|
|
612
|
+
* acts, and an administrator is allowed to think so.
|
|
613
|
+
*/
|
|
614
|
+
...(canQuire('pageExport')
|
|
615
|
+
? [
|
|
616
|
+
{
|
|
617
|
+
id: 'export',
|
|
618
|
+
label: t('export_menu'),
|
|
619
|
+
icon: 'download',
|
|
620
|
+
onSelect: () => (exportOpen = true),
|
|
621
|
+
},
|
|
622
|
+
]
|
|
623
|
+
: []),
|
|
595
624
|
{
|
|
596
625
|
id: 'watch',
|
|
597
626
|
label: watching ? t('watch_stop') : t('watch'),
|
|
@@ -757,6 +786,13 @@ async function undoTrash(workspace: string, id: string, spaceId: string, title:
|
|
|
757
786
|
|
|
758
787
|
<PublishDialog bind:open={shareOpen} {workspaceId} spaceId={doc.spaceId} page={doc} />
|
|
759
788
|
|
|
789
|
+
<ExportDialog
|
|
790
|
+
bind:open={exportOpen}
|
|
791
|
+
{workspaceId}
|
|
792
|
+
spaceId={doc.spaceId}
|
|
793
|
+
page={{ id: doc.id, title: doc.title }}
|
|
794
|
+
/>
|
|
795
|
+
|
|
760
796
|
<!--
|
|
761
797
|
The body says nothing about numbers until it knows them. Naming a count before the tree has
|
|
762
798
|
loaded would be the same silent lie in a smaller size — "it goes to the trash" for a page that
|