@milaboratories/pl-middle-layer 1.71.15 → 1.72.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/index.cjs +10 -5
- package/dist/index.d.ts +5 -4
- package/dist/index.js +3 -2
- package/dist/middle_layer/build_stamp.cjs +1 -1
- package/dist/middle_layer/build_stamp.js +1 -1
- package/dist/middle_layer/folders.cjs +631 -0
- package/dist/middle_layer/folders.cjs.map +1 -0
- package/dist/middle_layer/folders.d.ts +39 -0
- package/dist/middle_layer/folders.d.ts.map +1 -0
- package/dist/middle_layer/folders.js +613 -0
- package/dist/middle_layer/folders.js.map +1 -0
- package/dist/middle_layer/index.cjs +3 -0
- package/dist/middle_layer/index.d.ts +4 -3
- package/dist/middle_layer/index.js +2 -1
- package/dist/middle_layer/middle_layer.cjs +585 -370
- package/dist/middle_layer/middle_layer.cjs.map +1 -1
- package/dist/middle_layer/middle_layer.d.ts +215 -122
- package/dist/middle_layer/middle_layer.d.ts.map +1 -1
- package/dist/middle_layer/middle_layer.js +593 -378
- package/dist/middle_layer/middle_layer.js.map +1 -1
- package/dist/middle_layer/project_list.cjs +36 -19
- package/dist/middle_layer/project_list.cjs.map +1 -1
- package/dist/middle_layer/project_list.d.ts +1 -1
- package/dist/middle_layer/project_list.d.ts.map +1 -1
- package/dist/middle_layer/project_list.js +37 -21
- package/dist/middle_layer/project_list.js.map +1 -1
- package/dist/middle_layer/sharing_list.cjs +123 -93
- package/dist/middle_layer/sharing_list.cjs.map +1 -1
- package/dist/middle_layer/sharing_list.d.ts +43 -19
- package/dist/middle_layer/sharing_list.d.ts.map +1 -1
- package/dist/middle_layer/sharing_list.js +123 -93
- package/dist/middle_layer/sharing_list.js.map +1 -1
- package/dist/middle_layer/template_list.cjs +45 -20
- package/dist/middle_layer/template_list.cjs.map +1 -1
- package/dist/middle_layer/template_list.d.ts +7 -15
- package/dist/middle_layer/template_list.d.ts.map +1 -1
- package/dist/middle_layer/template_list.js +44 -21
- package/dist/middle_layer/template_list.js.map +1 -1
- package/dist/model/index.cjs +7 -5
- package/dist/model/index.d.ts +2 -2
- package/dist/model/index.js +2 -2
- package/dist/model/sharing_model.cjs +46 -20
- package/dist/model/sharing_model.cjs.map +1 -1
- package/dist/model/sharing_model.d.ts +125 -55
- package/dist/model/sharing_model.d.ts.map +1 -1
- package/dist/model/sharing_model.js +40 -16
- package/dist/model/sharing_model.js.map +1 -1
- package/dist/mutator/list.cjs +30 -0
- package/dist/mutator/list.cjs.map +1 -0
- package/dist/mutator/list.js +29 -0
- package/dist/mutator/list.js.map +1 -0
- package/dist/mutator/project.cjs +14 -1
- package/dist/mutator/project.cjs.map +1 -1
- package/dist/mutator/project.d.ts.map +1 -1
- package/dist/mutator/project.js +15 -2
- package/dist/mutator/project.js.map +1 -1
- package/dist/mutator/sharing.cjs +130 -76
- package/dist/mutator/sharing.cjs.map +1 -1
- package/dist/mutator/sharing.js +130 -76
- package/dist/mutator/sharing.js.map +1 -1
- package/dist/mutator/template.cjs +24 -17
- package/dist/mutator/template.cjs.map +1 -1
- package/dist/mutator/template.js +26 -20
- package/dist/mutator/template.js.map +1 -1
- package/package.json +18 -18
- package/src/middle_layer/folders.test.ts +1068 -0
- package/src/middle_layer/folders.ts +1059 -0
- package/src/middle_layer/folders_read.test.ts +359 -0
- package/src/middle_layer/index.ts +3 -2
- package/src/middle_layer/middle_layer.ts +813 -547
- package/src/middle_layer/project_list.test.ts +183 -0
- package/src/middle_layer/project_list.ts +58 -21
- package/src/middle_layer/sharing.test.ts +183 -0
- package/src/middle_layer/sharing_list.ts +208 -152
- package/src/middle_layer/template_list.test.ts +148 -0
- package/src/middle_layer/template_list.ts +72 -32
- package/src/middle_layer/templates.test.ts +76 -45
- package/src/model/sharing_model.test.ts +69 -2
- package/src/model/sharing_model.ts +161 -73
- package/src/mutator/list.ts +36 -0
- package/src/mutator/project-v3.test.ts +3 -1
- package/src/mutator/project.ts +13 -2
- package/src/mutator/sharing.ts +193 -120
- package/src/mutator/template.ts +37 -29
- package/src/test/with_ml.ts +73 -16
|
@@ -6,10 +6,8 @@ import type {
|
|
|
6
6
|
Role,
|
|
7
7
|
} from "@milaboratories/pl-client";
|
|
8
8
|
import {
|
|
9
|
-
isEveryoneUserLogin,
|
|
10
9
|
field,
|
|
11
10
|
GrantType,
|
|
12
|
-
isNotNullSignedResourceId,
|
|
13
11
|
isNullSignedResourceId,
|
|
14
12
|
resourceIdToString,
|
|
15
13
|
} from "@milaboratories/pl-client";
|
|
@@ -20,10 +18,36 @@ import {
|
|
|
20
18
|
ProjectsField,
|
|
21
19
|
ProjectsResourceType,
|
|
22
20
|
} from "./project_list";
|
|
21
|
+
import type { FoldersListing, FoldersRids } from "./folders";
|
|
22
|
+
import {
|
|
23
|
+
createFolder,
|
|
24
|
+
createFolderList,
|
|
25
|
+
deleteFolder,
|
|
26
|
+
moveFolderItems,
|
|
27
|
+
openFoldersTx,
|
|
28
|
+
previewFolderDeletion,
|
|
29
|
+
previewFoldersMove,
|
|
30
|
+
foldersLocalSubtree,
|
|
31
|
+
FoldersField,
|
|
32
|
+
FoldersResourceType,
|
|
33
|
+
renameFolder,
|
|
34
|
+
resetFolders,
|
|
35
|
+
setFolderDescription,
|
|
36
|
+
} from "./folders";
|
|
37
|
+
import type {
|
|
38
|
+
FolderId,
|
|
39
|
+
FoldersItem,
|
|
40
|
+
FoldersLeafItem,
|
|
41
|
+
FoldersMoveOutcome,
|
|
42
|
+
FoldersMovePlan,
|
|
43
|
+
FoldersMovePlanResult,
|
|
44
|
+
FoldersRemoval,
|
|
45
|
+
FoldersRemovalOutcome,
|
|
46
|
+
FoldersRemovalPlanResult,
|
|
47
|
+
} from "@milaboratories/pl-model-middle-layer";
|
|
23
48
|
import type {
|
|
24
49
|
CreateProjectFromTemplateOutcome,
|
|
25
50
|
SaveProjectAsTemplateOutcome,
|
|
26
|
-
ShareTemplateOutcome,
|
|
27
51
|
StoredTemplateData,
|
|
28
52
|
TemplateId,
|
|
29
53
|
TemplateListEntry,
|
|
@@ -31,11 +55,18 @@ import type {
|
|
|
31
55
|
import {
|
|
32
56
|
createTemplateList,
|
|
33
57
|
decodeStoredTemplateData,
|
|
58
|
+
TemplateDescriptionKey,
|
|
34
59
|
TemplateLabelKey,
|
|
35
60
|
TemplatesField,
|
|
36
61
|
TemplatesResourceType,
|
|
37
62
|
} from "./template_list";
|
|
38
|
-
import {
|
|
63
|
+
import {
|
|
64
|
+
createTemplate,
|
|
65
|
+
deleteTemplate,
|
|
66
|
+
renameTemplate,
|
|
67
|
+
setTemplateDescription,
|
|
68
|
+
} from "../mutator/template";
|
|
69
|
+
import { listedById, notListedError } from "../mutator/list";
|
|
39
70
|
import {
|
|
40
71
|
createProject,
|
|
41
72
|
duplicateProject,
|
|
@@ -44,6 +75,7 @@ import {
|
|
|
44
75
|
} from "../mutator/project";
|
|
45
76
|
import type { ProjectTemplateExportOutcome } from "../model/template_serializer";
|
|
46
77
|
import type { ProjectTemplateV1 } from "@milaboratories/pl-model-common";
|
|
78
|
+
import { asProjectId, asTemplateId } from "@milaboratories/pl-model-common";
|
|
47
79
|
import { extractConfig, ensureError } from "@platforma-sdk/model";
|
|
48
80
|
import type { TemplateApplyProblem, TemplateApplyReport } from "../model/template_apply";
|
|
49
81
|
import { TemplateEntryRejected, kindMismatch } from "../model/template_apply";
|
|
@@ -57,46 +89,44 @@ import { ProjectMetaKey } from "../model/project_model";
|
|
|
57
89
|
import type { ProjectId } from "../model/project_model";
|
|
58
90
|
import type { SynchronizedTreeState } from "@milaboratories/pl-tree";
|
|
59
91
|
import {
|
|
60
|
-
acceptanceFieldLogin,
|
|
61
92
|
canGrantToEveryone,
|
|
62
93
|
canImpersonate,
|
|
63
94
|
decodeEnvelopeData,
|
|
64
|
-
envelopeProjectMap,
|
|
65
|
-
isAcceptanceField,
|
|
66
95
|
SharingOutboxField,
|
|
67
96
|
SharingOutboxResourceType,
|
|
68
97
|
SharingStateField,
|
|
69
98
|
SharingStateResourceType,
|
|
70
|
-
|
|
99
|
+
envelopeFolderRoot,
|
|
100
|
+
newEnvelopeFolderId,
|
|
71
101
|
type EnvelopeData,
|
|
72
|
-
type
|
|
73
|
-
type ProjectFieldUuid,
|
|
102
|
+
type ShareFolderOptions,
|
|
74
103
|
type ShareId,
|
|
104
|
+
type ShareOptions,
|
|
105
|
+
type ShareOutcome,
|
|
75
106
|
type ShareProjectsOptions,
|
|
76
107
|
type ShareTemplateOptions,
|
|
77
108
|
} from "../model/sharing_model";
|
|
78
109
|
import {
|
|
110
|
+
buildFolderShareEnvelope,
|
|
79
111
|
buildShareEnvelope,
|
|
80
112
|
buildTemplateShareEnvelope,
|
|
81
113
|
copyEnvelopeProjectsIntoList,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
writeEnvelopeAcceptance,
|
|
86
|
-
writeSharingDecision,
|
|
114
|
+
writeShareHidden,
|
|
115
|
+
clearShareHidden,
|
|
116
|
+
type EnvelopeFolderSubtree,
|
|
87
117
|
type EnvelopeProjectSource,
|
|
88
118
|
} from "../mutator/sharing";
|
|
89
|
-
import type { LiveEnvelope, OutgoingShare,
|
|
119
|
+
import type { LiveEnvelope, OutgoingShare, AvailableShare } from "./sharing_list";
|
|
90
120
|
import {
|
|
91
121
|
createLiveEnvelopesComputable,
|
|
92
122
|
createOutgoingShares,
|
|
93
|
-
|
|
94
|
-
|
|
123
|
+
createAvailableSharesComputable,
|
|
124
|
+
createAvailableSharesTree,
|
|
95
125
|
createSharingStateTree,
|
|
96
126
|
} from "./sharing_list";
|
|
97
127
|
import { BlockPackPreparer } from "../mutator/block-pack/block_pack";
|
|
98
128
|
import type { MiLogger, Signer } from "@milaboratories/ts-helpers";
|
|
99
|
-
import { BlockEventDispatcher
|
|
129
|
+
import { BlockEventDispatcher } from "@milaboratories/ts-helpers";
|
|
100
130
|
import { HmacSha256Signer } from "@milaboratories/ts-helpers";
|
|
101
131
|
import type { Computable, ComputableStableDefined } from "@milaboratories/computable";
|
|
102
132
|
import { WatchableValue } from "@milaboratories/computable";
|
|
@@ -110,6 +140,12 @@ import type {
|
|
|
110
140
|
ProjectMeta,
|
|
111
141
|
BlockPlatform,
|
|
112
142
|
} from "@milaboratories/pl-model-middle-layer";
|
|
143
|
+
import {
|
|
144
|
+
foldersNameTaken,
|
|
145
|
+
foldersUniqueName,
|
|
146
|
+
inheritedFolder,
|
|
147
|
+
normalizeDescription,
|
|
148
|
+
} from "@milaboratories/pl-model-middle-layer";
|
|
113
149
|
import type { AppliedEntry } from "../model/template_apply";
|
|
114
150
|
import { BlockUpdateWatcher } from "../block_registry/watcher";
|
|
115
151
|
import type { QuickJSWASMModule } from "quickjs-emscripten";
|
|
@@ -183,25 +219,29 @@ export class MiddleLayer {
|
|
|
183
219
|
private readonly templateListResourceId: SignedResourceId,
|
|
184
220
|
private readonly sharingOutboxResourceId: SignedResourceId,
|
|
185
221
|
private readonly sharingStateResourceId: SignedResourceId,
|
|
222
|
+
private readonly foldersResourceId: SignedResourceId,
|
|
186
223
|
private readonly openedProjectsList: WatchableValue<ProjectId[]>,
|
|
187
224
|
private readonly projectListTree: SynchronizedTreeState,
|
|
188
225
|
private readonly templateListTree: SynchronizedTreeState,
|
|
226
|
+
private readonly foldersTree: SynchronizedTreeState,
|
|
189
227
|
private readonly sharingOutboxTree: SynchronizedTreeState,
|
|
190
228
|
private readonly sharingStateTree: SynchronizedTreeState,
|
|
191
|
-
private readonly
|
|
229
|
+
private readonly availableSharesTree: SynchronizedTreeState,
|
|
192
230
|
public readonly blockRegistryProvider: V2RegistryProvider,
|
|
193
231
|
/** Contains a reactive list of projects along with their meta information. */
|
|
194
232
|
public readonly projectList: ComputableStableDefined<ProjectListEntry[]>,
|
|
195
233
|
/** Contains a reactive list of stored templates along with their labels and provenance. */
|
|
196
234
|
public readonly templateList: ComputableStableDefined<TemplateListEntry[]>,
|
|
197
|
-
/**
|
|
198
|
-
*
|
|
235
|
+
/** The folder tree and the project list, already joined. The desktop reads this and never
|
|
236
|
+
* the two halves, so it cannot render a list and a tree that are one refresh apart. */
|
|
237
|
+
public readonly folders: ComputableStableDefined<FoldersListing>,
|
|
238
|
+
/** Reactive view of the donor's outbox — the shares this user has created. */
|
|
199
239
|
public outgoingShares: Computable<OutgoingShare[] | undefined>,
|
|
200
|
-
/**
|
|
240
|
+
/** Shares granted to this user, hidden ones included and flagged as such. Fed by the
|
|
201
241
|
* shared-resource discovery tree. */
|
|
202
|
-
public
|
|
203
|
-
/** Internal: the
|
|
204
|
-
* discovery tree as {@link
|
|
242
|
+
public availableShares: Computable<AvailableShare[] | undefined>,
|
|
243
|
+
/** Internal: the recipient's currently-live envelopes, read from the same shared-resource
|
|
244
|
+
* discovery tree as {@link availableShares}. The single source {@link copyShare} resolves
|
|
205
245
|
* live envelopes from — no second discovery path. */
|
|
206
246
|
private readonly liveEnvelopes: Computable<LiveEnvelope[] | undefined>,
|
|
207
247
|
) {
|
|
@@ -238,7 +278,7 @@ export class MiddleLayer {
|
|
|
238
278
|
/**
|
|
239
279
|
* Whether the connected backend supports project sharing. Synthetic — computed
|
|
240
280
|
* in the middle layer from the backend capabilities the share flow needs (the
|
|
241
|
-
* cross-color field-reference relaxation
|
|
281
|
+
* cross-color field-reference relaxation a copy out of a share rests on). It can absorb
|
|
242
282
|
* additional required capabilities later without a UI change.
|
|
243
283
|
*/
|
|
244
284
|
public get sharingSupported(): boolean {
|
|
@@ -323,12 +363,9 @@ export class MiddleLayer {
|
|
|
323
363
|
|
|
324
364
|
// Cache miss — scan project list fields to find the matching resource
|
|
325
365
|
const rid = await this.pl.withReadTx("ResolveProjectId", async (tx) => {
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if (resourceIdToString(f.value) === (projectId as string)) return f.value;
|
|
330
|
-
}
|
|
331
|
-
throw new Error(`Project ${projectId} not found in project list.`);
|
|
366
|
+
const entry = (await listedById(tx, this.projectListResourceId)).get(projectId);
|
|
367
|
+
if (entry === undefined) throw notListedError("Project", projectId);
|
|
368
|
+
return entry.rid;
|
|
332
369
|
});
|
|
333
370
|
|
|
334
371
|
this.projectIdCache.set(projectId, rid);
|
|
@@ -339,40 +376,291 @@ export class MiddleLayer {
|
|
|
339
376
|
// Project List Manipulation
|
|
340
377
|
//
|
|
341
378
|
|
|
342
|
-
/**
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
379
|
+
/**
|
|
380
|
+
* Creates a project with initial state and adds it to project list.
|
|
381
|
+
*
|
|
382
|
+
* `folder` is where it lands, placed in the transaction that creates it so the project never
|
|
383
|
+
* shows up at the top level first. Left out, or naming a folder that is gone, it lands at the
|
|
384
|
+
* top level.
|
|
385
|
+
*/
|
|
386
|
+
public async createProject(meta: ProjectMeta, folder?: FolderId): Promise<ProjectId> {
|
|
387
|
+
const signedRid = await this.pl.withWriteTx("MLCreateProject", async (tx) => {
|
|
388
|
+
const tree = folder === undefined ? undefined : await openFoldersTx(tx, this.foldersRids);
|
|
389
|
+
const prj = await createProject(tx, meta);
|
|
347
390
|
tx.createField(field(this.projectListResourceId, randomUUID()), "Dynamic", prj);
|
|
391
|
+
const rid = await prj.globalId;
|
|
392
|
+
// A folder deleted while the project was being made costs the project its placement, not
|
|
393
|
+
// its existence.
|
|
394
|
+
if (tree !== undefined && tree.view.folders.some((candidate) => candidate.id === folder))
|
|
395
|
+
tree.place([{ kind: "project", id: asProjectId(resourceIdToString(rid)) }], folder);
|
|
348
396
|
await tx.commit();
|
|
397
|
+
return rid;
|
|
349
398
|
});
|
|
350
|
-
await
|
|
399
|
+
await Promise.all([
|
|
400
|
+
this.projectListTree.refreshState(),
|
|
401
|
+
...(folder === undefined ? [] : [this.foldersTree.refreshState()]),
|
|
402
|
+
]);
|
|
351
403
|
|
|
352
|
-
const
|
|
353
|
-
const projectId = resourceIdToString(signedRid) as ProjectId;
|
|
404
|
+
const projectId = asProjectId(resourceIdToString(signedRid));
|
|
354
405
|
this.projectIdCache.set(projectId, signedRid);
|
|
355
406
|
return projectId;
|
|
356
407
|
}
|
|
357
408
|
|
|
358
|
-
/**
|
|
409
|
+
/**
|
|
410
|
+
* Updates the project metadata fields the caller names, leaving the others as they are.
|
|
411
|
+
*
|
|
412
|
+
* A patch rather than a replacement because the label and the description are edited from two
|
|
413
|
+
* different places: a rename that carried a stale description alongside the new name would
|
|
414
|
+
* undo a description edit that happened in between, and the reverse.
|
|
415
|
+
*
|
|
416
|
+
* The label is stored trimmed, and it goes into the namespace folders share, so a label
|
|
417
|
+
* already carried by something else inside the same folder is refused rather than written — a
|
|
418
|
+
* human typed it, and a name that silently becomes a different name is worse than one that is
|
|
419
|
+
* turned down. The check and the write share one transaction, so two renames racing for the
|
|
420
|
+
* same name cannot both win. Duplicates an account already holds are tolerated and never
|
|
421
|
+
* rewritten; see the name check of {@link openFoldersTx}.
|
|
422
|
+
*/
|
|
359
423
|
public async setProjectMeta(
|
|
360
424
|
id: ProjectId,
|
|
361
|
-
meta: ProjectMeta
|
|
425
|
+
meta: Partial<ProjectMeta>,
|
|
362
426
|
author?: AuthorMarker,
|
|
363
427
|
): Promise<void> {
|
|
364
428
|
const rid = await this.resolveProjectId(id);
|
|
365
|
-
|
|
366
|
-
|
|
429
|
+
const label = meta.label?.trim();
|
|
430
|
+
const patch = label === undefined ? meta : { ...meta, label };
|
|
431
|
+
await this.pl.withWriteTx("ProjectAction: setProjectMeta", async (tx) => {
|
|
432
|
+
if (label !== undefined)
|
|
433
|
+
(await openFoldersTx(tx, this.foldersRids)).assertNameFree({ kind: "project", id }, label);
|
|
434
|
+
await withProjectAuthored(this.env.projectHelper, tx, rid, author, (prj) => {
|
|
435
|
+
prj.updateMeta(patch);
|
|
436
|
+
});
|
|
437
|
+
await tx.commit();
|
|
438
|
+
});
|
|
439
|
+
await this.projectListTree.refreshState();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
//
|
|
443
|
+
// Folders
|
|
444
|
+
//
|
|
445
|
+
|
|
446
|
+
private get foldersRids(): FoldersRids {
|
|
447
|
+
return {
|
|
448
|
+
folders: this.foldersResourceId,
|
|
449
|
+
projects: this.projectListResourceId,
|
|
450
|
+
templates: this.templateListResourceId,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Creates a folder inside `parent`, or at the top level, and returns its id. A name already
|
|
455
|
+
* used there is rejected. */
|
|
456
|
+
public async createFolder(name: string, parent?: FolderId): Promise<FolderId> {
|
|
457
|
+
const id = await createFolder(this.pl, this.foldersRids, name, parent);
|
|
458
|
+
await this.foldersTree.refreshState();
|
|
459
|
+
return id;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Renames a folder. A name already used beside it is rejected. */
|
|
463
|
+
public async renameFolder(folder: FolderId, name: string): Promise<void> {
|
|
464
|
+
await renameFolder(this.pl, this.foldersRids, folder, name);
|
|
465
|
+
await this.foldersTree.refreshState();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Sets what a folder says about itself; blank clears it. Descriptions are in no namespace, so
|
|
469
|
+
* nothing is refused here. */
|
|
470
|
+
public async setFolderDescription(folder: FolderId, description: string): Promise<void> {
|
|
471
|
+
await setFolderDescription(this.pl, this.foldersRids, folder, description);
|
|
472
|
+
await this.foldersTree.refreshState();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** The plan a move would produce — every item and the name it ends up with. Shown for
|
|
476
|
+
* confirmation, then handed back to {@link moveFolderItems} unchanged. */
|
|
477
|
+
public async previewFoldersMove(
|
|
478
|
+
items: readonly FoldersItem[],
|
|
479
|
+
destination?: FolderId,
|
|
480
|
+
): Promise<FoldersMovePlanResult> {
|
|
481
|
+
return await previewFoldersMove(this.pl, this.foldersRids, items, destination);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Moves folders, projects and templates into one destination.
|
|
486
|
+
*
|
|
487
|
+
* The plan is recomputed inside the write transaction and the move commits only when it is
|
|
488
|
+
* identical to `confirmedPlan`; otherwise nothing is written and the fresh plan comes back to
|
|
489
|
+
* be confirmed again.
|
|
490
|
+
*/
|
|
491
|
+
public async moveFolderItems(
|
|
492
|
+
items: readonly FoldersItem[],
|
|
493
|
+
destination: FolderId | undefined,
|
|
494
|
+
confirmedPlan: FoldersMovePlan,
|
|
495
|
+
): Promise<FoldersMoveOutcome> {
|
|
496
|
+
const outcome = await moveFolderItems(
|
|
367
497
|
this.pl,
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
},
|
|
373
|
-
{ name: "setProjectMeta" },
|
|
498
|
+
this.foldersRids,
|
|
499
|
+
items,
|
|
500
|
+
destination,
|
|
501
|
+
confirmedPlan,
|
|
374
502
|
);
|
|
375
|
-
|
|
503
|
+
if (outcome.ok)
|
|
504
|
+
await Promise.all([
|
|
505
|
+
this.foldersTree.refreshState(),
|
|
506
|
+
this.projectListTree.refreshState(),
|
|
507
|
+
this.templateListTree.refreshState(),
|
|
508
|
+
]);
|
|
509
|
+
return outcome;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** What deleting a folder would destroy: the subtree of folders, and everything in it. */
|
|
513
|
+
public async previewFolderDeletion(folder: FolderId): Promise<FoldersRemovalPlanResult> {
|
|
514
|
+
return await previewFolderDeletion(this.pl, this.foldersRids, folder);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Deletes a folder, every folder inside it, and every project and template held anywhere in
|
|
518
|
+
* that subtree.
|
|
519
|
+
* Refused as `needs-confirmation` until the removal it returns is passed back as
|
|
520
|
+
* `confirmedRemoval`, and as `plan-changed` if the subtree has changed since. */
|
|
521
|
+
public async deleteFolder(
|
|
522
|
+
folder: FolderId,
|
|
523
|
+
confirmedRemoval?: FoldersRemoval,
|
|
524
|
+
): Promise<FoldersRemovalOutcome> {
|
|
525
|
+
const outcome = await deleteFolder(this.pl, this.foldersRids, folder, confirmedRemoval);
|
|
526
|
+
if (outcome.ok) {
|
|
527
|
+
// What the folder held is gone from the lists, so an id cached for it would resolve to a
|
|
528
|
+
// resource nothing holds any more.
|
|
529
|
+
for (const id of outcome.removal.projects) this.projectIdCache.delete(id);
|
|
530
|
+
for (const id of outcome.removal.templates) this.templateIdCache.delete(id);
|
|
531
|
+
await Promise.all([
|
|
532
|
+
this.foldersTree.refreshState(),
|
|
533
|
+
this.projectListTree.refreshState(),
|
|
534
|
+
this.templateListTree.refreshState(),
|
|
535
|
+
]);
|
|
536
|
+
}
|
|
537
|
+
return outcome;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Replaces a folder document this build cannot read — one written by a newer version, or one
|
|
542
|
+
* nothing here can parse — with an empty one. Every folder is gone afterwards; every project and
|
|
543
|
+
* template stays and shows at the top level. Refused while the document can be read.
|
|
544
|
+
*/
|
|
545
|
+
public async resetFolders(): Promise<void> {
|
|
546
|
+
await resetFolders(this.pl, this.foldersRids);
|
|
547
|
+
await this.foldersTree.refreshState();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Duplicates a folder and everything under it: the folders inside, a duplicate of every project
|
|
552
|
+
* in them, and a copy of every template.
|
|
553
|
+
*
|
|
554
|
+
* The copy lands beside the source, so only its root needs a name of its own — `X (Copy)`.
|
|
555
|
+
* Nothing inside is renamed: names are compared among siblings, and a copied folder's children
|
|
556
|
+
* are only ever compared with each other, where they came in distinct already.
|
|
557
|
+
*
|
|
558
|
+
* The subtree is read first and rebuilt in one write transaction, so the folders and everything
|
|
559
|
+
* they hold appear together or not at all.
|
|
560
|
+
*/
|
|
561
|
+
public async duplicateFolder(folder: FolderId): Promise<void> {
|
|
562
|
+
const subtree = await this.loadFolderSubtree(folder, () => randomUUID());
|
|
563
|
+
|
|
564
|
+
const created = await this.pl.withWriteTx("MLDuplicateFolder", async (tx) => {
|
|
565
|
+
const tree = await openFoldersTx(tx, this.foldersRids);
|
|
566
|
+
const items: (FoldersLeafItem & { folder: string })[] = [];
|
|
567
|
+
const projects: SignedResourceId[] = [];
|
|
568
|
+
const templates: { id: TemplateId; rid: SignedResourceId }[] = [];
|
|
569
|
+
|
|
570
|
+
for (const project of subtree.projects) {
|
|
571
|
+
// The whole metadata carries over, label included: the duplicate lands in a folder of its
|
|
572
|
+
// own that holds nothing else, so there is nothing there for its name to collide with.
|
|
573
|
+
const meta = await tx.getKValueJson<ProjectMeta>(project.rid, ProjectMetaKey);
|
|
574
|
+
const copy = await duplicateProject(tx, project.rid, meta, this.env.projectHelper);
|
|
575
|
+
tx.createField(field(this.projectListResourceId, randomUUID()), "Dynamic", copy);
|
|
576
|
+
const rid = await copy.globalId;
|
|
577
|
+
projects.push(rid);
|
|
578
|
+
items.push({
|
|
579
|
+
kind: "project",
|
|
580
|
+
id: asProjectId(resourceIdToString(rid)),
|
|
581
|
+
folder: project.folder,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
for (const template of subtree.templates) {
|
|
586
|
+
// A stored template is immutable, so its copy is the same blob under a new resource —
|
|
587
|
+
// provenance and all.
|
|
588
|
+
const copy = createTemplate(
|
|
589
|
+
tx,
|
|
590
|
+
this.templateListResourceId,
|
|
591
|
+
{ label: template.label, description: template.description },
|
|
592
|
+
template.data,
|
|
593
|
+
);
|
|
594
|
+
const rid = await copy.globalId;
|
|
595
|
+
const id = asTemplateId(resourceIdToString(rid));
|
|
596
|
+
templates.push({ id, rid });
|
|
597
|
+
items.push({ kind: "template", id, folder: template.folder });
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
tree.graft({ root: subtree.root, folders: subtree.folders, items }, subtree.parent);
|
|
601
|
+
|
|
602
|
+
await tx.commit();
|
|
603
|
+
return { projects, templates };
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
for (const rid of created.projects)
|
|
607
|
+
this.projectIdCache.set(asProjectId(resourceIdToString(rid)), rid);
|
|
608
|
+
for (const { id, rid } of created.templates) this.templateIdCache.set(id, rid);
|
|
609
|
+
|
|
610
|
+
await Promise.all([
|
|
611
|
+
this.foldersTree.refreshState(),
|
|
612
|
+
this.projectListTree.refreshState(),
|
|
613
|
+
this.templateListTree.refreshState(),
|
|
614
|
+
]);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* A folder subtree as a copy needs it: the folders under ids local to this read, the key the
|
|
619
|
+
* subtree's root goes by, the resource of every project in them, every template read whole, and
|
|
620
|
+
* the folder the source sits in — which is where a duplicate lands.
|
|
621
|
+
*
|
|
622
|
+
* Read in a transaction of its own, before the write that copies it: every template is a read,
|
|
623
|
+
* and none of it has to be atomic with the copying, because a project or a template that
|
|
624
|
+
* disappears meanwhile fails that write on its own.
|
|
625
|
+
*/
|
|
626
|
+
private async loadFolderSubtree<Id extends string>(
|
|
627
|
+
folder: FolderId,
|
|
628
|
+
mint: () => Id,
|
|
629
|
+
): Promise<FolderSubtree<Id>> {
|
|
630
|
+
return await this.pl.withReadTx("MLReadFolderSubtree", async (tx) => {
|
|
631
|
+
const tree = await openFoldersTx(tx, this.foldersRids);
|
|
632
|
+
const source = tree.view.folders.find((candidate) => candidate.id === folder);
|
|
633
|
+
if (source === undefined) throw new Error(`Folder ${folder} does not exist.`);
|
|
634
|
+
|
|
635
|
+
const { inSubtree, localId, folders } = foldersLocalSubtree(tree.view, folder, mint);
|
|
636
|
+
|
|
637
|
+
const projects: FolderSubtree<Id>["projects"] = [];
|
|
638
|
+
for (const project of tree.view.projects) {
|
|
639
|
+
if (project.folder === undefined || !inSubtree.has(project.folder)) continue;
|
|
640
|
+
const rid = tree.projectRids.get(project.id);
|
|
641
|
+
if (rid === undefined) throw notListedError("Project", project.id);
|
|
642
|
+
projects.push({ projectId: project.id, rid, folder: localId(project.folder) });
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const templates: Promise<FolderSubtree<Id>["templates"][number]>[] = [];
|
|
646
|
+
for (const template of tree.view.templates) {
|
|
647
|
+
if (template.folder === undefined || !inSubtree.has(template.folder)) continue;
|
|
648
|
+
const rid = tree.templateRids.get(template.id);
|
|
649
|
+
if (rid === undefined) throw notListedError("Template", template.id);
|
|
650
|
+
const local = localId(template.folder);
|
|
651
|
+
templates.push(
|
|
652
|
+
readStoredTemplate(tx, template.id, rid).then((stored) => ({ ...stored, folder: local })),
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return {
|
|
657
|
+
...(source.parent === undefined ? {} : { parent: source.parent }),
|
|
658
|
+
root: localId(folder),
|
|
659
|
+
folders,
|
|
660
|
+
projects,
|
|
661
|
+
templates: await Promise.all(templates),
|
|
662
|
+
};
|
|
663
|
+
});
|
|
376
664
|
}
|
|
377
665
|
|
|
378
666
|
/**
|
|
@@ -599,41 +887,86 @@ export class MiddleLayer {
|
|
|
599
887
|
* A block that cannot be expressed as a template entry stores nothing at all, and every
|
|
600
888
|
* such block is reported — fixing an unexportable project takes one pass, not one per block.
|
|
601
889
|
*
|
|
890
|
+
* The template lands beside its project, and is named by the rule everything there is named
|
|
891
|
+
* by: a label the caller chose is stored trimmed and refused if it is already used there, and
|
|
892
|
+
* without one the project's own label is taken — suffixed, since the project itself already
|
|
893
|
+
* answers to it.
|
|
894
|
+
*
|
|
895
|
+
* The template's description is the one the caller gives, stored trimmed; blank means none.
|
|
896
|
+
* Without one the template is stored undescribed, whatever the project's own description says.
|
|
897
|
+
*
|
|
602
898
|
* @param projectId project to snapshot
|
|
603
|
-
* @param label label for the template; defaults to the project's own label
|
|
899
|
+
* @param label label for the template; defaults to the project's own label, made free
|
|
900
|
+
* @param description what the template is for; blank or absent stores none
|
|
604
901
|
*/
|
|
605
902
|
public async saveProjectAsTemplate(
|
|
606
903
|
projectId: ProjectId,
|
|
607
904
|
label?: string,
|
|
905
|
+
description?: string,
|
|
608
906
|
): Promise<SaveProjectAsTemplateOutcome> {
|
|
609
907
|
const outcome = await this.exportProjectAsTemplate(projectId);
|
|
610
908
|
if (!outcome.ok) return { ok: false, problems: outcome.problems };
|
|
611
909
|
|
|
612
910
|
const rid = await this.resolveProjectId(projectId);
|
|
613
|
-
|
|
614
|
-
await this.pl.withWriteTx("MLSaveProjectAsTemplate", async (tx) => {
|
|
911
|
+
const wanted = label?.trim();
|
|
912
|
+
const signedRid = await this.pl.withWriteTx("MLSaveProjectAsTemplate", async (tx) => {
|
|
615
913
|
const meta = await tx.getKValueJson<ProjectMeta>(rid, ProjectMetaKey);
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
914
|
+
const tree = await openFoldersTx(tx, this.foldersRids);
|
|
915
|
+
const taken = tree.namesTakenBeside(projectId);
|
|
916
|
+
if (wanted !== undefined && foldersNameTaken(wanted, taken))
|
|
917
|
+
throw new Error(`"${wanted}" is already used here.`);
|
|
918
|
+
// The project's own label is taken beside it even when the tree cannot say what else is.
|
|
919
|
+
const name = wanted ?? foldersUniqueName(meta.label, [meta.label, ...taken]);
|
|
920
|
+
|
|
921
|
+
const tpl = createTemplate(
|
|
922
|
+
tx,
|
|
923
|
+
this.templateListResourceId,
|
|
924
|
+
{ label: name, description },
|
|
925
|
+
{ schemaVersion: 1, document: outcome.document, sourceProjectLabel: meta.label },
|
|
926
|
+
);
|
|
927
|
+
|
|
928
|
+
// A template taken from a project belongs beside that project, and the placement rides the
|
|
929
|
+
// same transaction so the two can never disagree about where it is. Its name was chosen
|
|
930
|
+
// free there, so nothing can send it anywhere else.
|
|
931
|
+
const created = await tpl.globalId;
|
|
932
|
+
tree.place(
|
|
933
|
+
[{ kind: "template", id: asTemplateId(resourceIdToString(created)) }],
|
|
934
|
+
tree.folderOf(projectId),
|
|
935
|
+
);
|
|
621
936
|
await tx.commit();
|
|
937
|
+
return created;
|
|
622
938
|
});
|
|
623
|
-
await this.templateListTree.refreshState();
|
|
939
|
+
await Promise.all([this.templateListTree.refreshState(), this.foldersTree.refreshState()]);
|
|
624
940
|
|
|
625
|
-
const
|
|
626
|
-
const templateId = resourceIdToString(signedRid) as TemplateId;
|
|
941
|
+
const templateId = asTemplateId(resourceIdToString(signedRid));
|
|
627
942
|
this.templateIdCache.set(templateId, signedRid);
|
|
628
943
|
return { ok: true, templateId };
|
|
629
944
|
}
|
|
630
945
|
|
|
631
|
-
/**
|
|
632
|
-
*
|
|
946
|
+
/**
|
|
947
|
+
* Changes a template's label. The stored document is immutable and stays untouched —
|
|
948
|
+
* improving a template means saving a new one.
|
|
949
|
+
*
|
|
950
|
+
* The label is stored trimmed. It is in the namespace folders, projects and templates share,
|
|
951
|
+
* so one already used beside the template is refused, in the transaction that writes it.
|
|
952
|
+
*/
|
|
633
953
|
public async renameTemplate(id: TemplateId, label: string): Promise<void> {
|
|
634
954
|
const rid = await this.resolveTemplateId(id);
|
|
955
|
+
const wanted = label.trim();
|
|
635
956
|
await this.pl.withWriteTx("MLRenameTemplate", async (tx) => {
|
|
636
|
-
|
|
957
|
+
(await openFoldersTx(tx, this.foldersRids)).assertNameFree({ kind: "template", id }, wanted);
|
|
958
|
+
renameTemplate(tx, rid, wanted);
|
|
959
|
+
await tx.commit();
|
|
960
|
+
});
|
|
961
|
+
await this.templateListTree.refreshState();
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** Sets what a stored template says about itself; blank clears it. The stored document is not
|
|
965
|
+
* touched, so it stays byte-identical. */
|
|
966
|
+
public async setTemplateDescription(id: TemplateId, description: string): Promise<void> {
|
|
967
|
+
const rid = await this.resolveTemplateId(id);
|
|
968
|
+
await this.pl.withWriteTx("MLSetTemplateDescription", async (tx) => {
|
|
969
|
+
setTemplateDescription(tx, rid, description);
|
|
637
970
|
await tx.commit();
|
|
638
971
|
});
|
|
639
972
|
await this.templateListTree.refreshState();
|
|
@@ -685,13 +1018,19 @@ export class MiddleLayer {
|
|
|
685
1018
|
* @param id template to apply
|
|
686
1019
|
* @param label label for the new project
|
|
687
1020
|
* @param provider where each entry's block comes from
|
|
688
|
-
* @param options `allowUnstable` widens resolution to pre-release implementations
|
|
1021
|
+
* @param options `allowUnstable` widens resolution to pre-release implementations; `folder` is
|
|
1022
|
+
* where the new project lands
|
|
689
1023
|
*/
|
|
690
1024
|
public async createProjectFromTemplate(
|
|
691
1025
|
id: TemplateId,
|
|
692
1026
|
label: string,
|
|
693
1027
|
provider: BlockPackProvider,
|
|
694
|
-
options: {
|
|
1028
|
+
options: {
|
|
1029
|
+
allowUnstable?: boolean;
|
|
1030
|
+
author?: AuthorMarker;
|
|
1031
|
+
/** Where the project lands; the top level when left out. */
|
|
1032
|
+
folder?: FolderId;
|
|
1033
|
+
} = {},
|
|
695
1034
|
): Promise<CreateProjectFromTemplateOutcome> {
|
|
696
1035
|
const stored = await this.getTemplateData(id);
|
|
697
1036
|
|
|
@@ -700,7 +1039,7 @@ export class MiddleLayer {
|
|
|
700
1039
|
});
|
|
701
1040
|
if (preparation.problems.length > 0) return { ok: false, problems: preparation.problems };
|
|
702
1041
|
|
|
703
|
-
const projectId = await this.createProject({ label });
|
|
1042
|
+
const projectId = await this.createProject({ label }, options.folder);
|
|
704
1043
|
const report = await this.applyPreparedEntries(
|
|
705
1044
|
projectId,
|
|
706
1045
|
stored.document,
|
|
@@ -725,12 +1064,9 @@ export class MiddleLayer {
|
|
|
725
1064
|
|
|
726
1065
|
// Cache miss — scan template list fields to find the matching resource
|
|
727
1066
|
const rid = await this.pl.withReadTx("ResolveTemplateId", async (tx) => {
|
|
728
|
-
const
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
if (resourceIdToString(f.value) === (templateId as string)) return f.value;
|
|
732
|
-
}
|
|
733
|
-
throw new Error(`Template ${templateId} not found in template list.`);
|
|
1067
|
+
const entry = (await listedById(tx, this.templateListResourceId)).get(templateId);
|
|
1068
|
+
if (entry === undefined) throw notListedError("Template", templateId);
|
|
1069
|
+
return entry.rid;
|
|
734
1070
|
});
|
|
735
1071
|
|
|
736
1072
|
this.templateIdCache.set(templateId, rid);
|
|
@@ -741,17 +1077,9 @@ export class MiddleLayer {
|
|
|
741
1077
|
* destruction of all attached objects, like files, analysis results etc. */
|
|
742
1078
|
public async deleteProject(id: ProjectId): Promise<void> {
|
|
743
1079
|
await this.pl.withWriteTx("MLRemoveProject", async (tx) => {
|
|
744
|
-
const
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
if (isNullSignedResourceId(f.value)) continue;
|
|
748
|
-
if (resourceIdToString(f.value) === (id as string)) {
|
|
749
|
-
fieldName = f.name;
|
|
750
|
-
break;
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
if (fieldName === undefined) throw new Error(`Project ${id} not found in project list.`);
|
|
754
|
-
tx.removeField(field(this.projectListResourceId, fieldName));
|
|
1080
|
+
const entry = (await listedById(tx, this.projectListResourceId)).get(id);
|
|
1081
|
+
if (entry === undefined) throw notListedError("Project", id);
|
|
1082
|
+
tx.removeField(field(this.projectListResourceId, entry.fieldName));
|
|
755
1083
|
await tx.commit();
|
|
756
1084
|
});
|
|
757
1085
|
this.projectIdCache.delete(id);
|
|
@@ -759,11 +1087,18 @@ export class MiddleLayer {
|
|
|
759
1087
|
}
|
|
760
1088
|
|
|
761
1089
|
/**
|
|
762
|
-
* Duplicates an existing project and adds the copy to this user's project list
|
|
1090
|
+
* Duplicates an existing project and adds the copy to this user's project list, beside the
|
|
1091
|
+
* project it was copied from.
|
|
1092
|
+
*
|
|
1093
|
+
* Without `rename` the copy is named by the rule everything beside the source is named by: the
|
|
1094
|
+
* source's own label, suffixed to be free there — `X (Copy)`. The name is chosen inside the
|
|
1095
|
+
* transaction that creates the copy, against the tree that transaction reads.
|
|
763
1096
|
*
|
|
764
1097
|
* @param srcProjectId - project id of the project to duplicate
|
|
765
1098
|
* @param rename - optional function that receives the source label and all existing
|
|
766
|
-
* project labels (read within the same transaction), and returns the label for the copy
|
|
1099
|
+
* project labels (read within the same transaction), and returns the label for the copy.
|
|
1100
|
+
* A label chosen this way is the caller's and is never suffixed: when it is already taken
|
|
1101
|
+
* beside the source, the copy lands at the top level instead.
|
|
767
1102
|
*/
|
|
768
1103
|
public async duplicateProject(
|
|
769
1104
|
srcProjectId: ProjectId,
|
|
@@ -772,42 +1107,51 @@ export class MiddleLayer {
|
|
|
772
1107
|
const sourceRid = await this.resolveProjectId(srcProjectId);
|
|
773
1108
|
|
|
774
1109
|
const newPrj: ResourceRef = await this.pl.withWriteTx("MLDuplicateProject", async (tx) => {
|
|
775
|
-
// Read source project meta
|
|
776
1110
|
const sourceMeta = await tx.getKValueJson<ProjectMeta>(sourceRid, ProjectMetaKey);
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
const
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
//
|
|
790
|
-
const newLabel = rename ? rename(sourceMeta.label, existingLabels) : sourceMeta.label;
|
|
791
|
-
|
|
792
|
-
// Create the duplicate
|
|
1111
|
+
const tree = await openFoldersTx(tx, this.foldersRids);
|
|
1112
|
+
|
|
1113
|
+
// The source's own label is taken beside it even when the tree cannot say what else is.
|
|
1114
|
+
const label =
|
|
1115
|
+
rename === undefined
|
|
1116
|
+
? foldersUniqueName(sourceMeta.label, [
|
|
1117
|
+
sourceMeta.label,
|
|
1118
|
+
...tree.namesTakenBeside(srcProjectId),
|
|
1119
|
+
])
|
|
1120
|
+
: rename(sourceMeta.label, await existingProjectLabels(tx, this.projectListResourceId));
|
|
1121
|
+
|
|
1122
|
+
// The whole source metadata carries over, so a copy keeps what the original said about
|
|
1123
|
+
// itself; only the label is the copy's own.
|
|
793
1124
|
const newPrj = await duplicateProject(
|
|
794
1125
|
tx,
|
|
795
1126
|
sourceRid,
|
|
796
|
-
{ label
|
|
1127
|
+
{ ...sourceMeta, label },
|
|
797
1128
|
this.env.projectHelper,
|
|
798
1129
|
);
|
|
799
1130
|
|
|
800
1131
|
// Attach to project list with a random UUID field name
|
|
801
1132
|
tx.createField(field(this.projectListResourceId, randomUUID()), "Dynamic", newPrj);
|
|
1133
|
+
|
|
1134
|
+
// A copy belongs beside the project it was copied from, placed in the same transaction so
|
|
1135
|
+
// it is never shown at the top level first.
|
|
1136
|
+
const created: FoldersLeafItem = {
|
|
1137
|
+
kind: "project",
|
|
1138
|
+
id: asProjectId(resourceIdToString(await newPrj.globalId)),
|
|
1139
|
+
};
|
|
1140
|
+
tree.place(
|
|
1141
|
+
[created],
|
|
1142
|
+
rename === undefined
|
|
1143
|
+
? tree.folderOf(srcProjectId)
|
|
1144
|
+
: inheritedFolder(tree.view, srcProjectId, created, label),
|
|
1145
|
+
);
|
|
802
1146
|
await tx.commit();
|
|
803
1147
|
|
|
804
1148
|
return newPrj;
|
|
805
1149
|
});
|
|
806
1150
|
|
|
807
|
-
await this.projectListTree.refreshState();
|
|
1151
|
+
await Promise.all([this.projectListTree.refreshState(), this.foldersTree.refreshState()]);
|
|
808
1152
|
|
|
809
1153
|
const signedRid = await newPrj.globalId;
|
|
810
|
-
const newProjectId = resourceIdToString(signedRid)
|
|
1154
|
+
const newProjectId = asProjectId(resourceIdToString(signedRid));
|
|
811
1155
|
this.projectIdCache.set(newProjectId, signedRid);
|
|
812
1156
|
return newProjectId;
|
|
813
1157
|
}
|
|
@@ -816,7 +1160,7 @@ export class MiddleLayer {
|
|
|
816
1160
|
* Duplicates a project into another user's root, minted in the TARGET user's color so the target
|
|
817
1161
|
* owns it. Sibling of {@link duplicateProject}, but writes into a different root. The source
|
|
818
1162
|
* project (on the current client root) is referenced cross-color for its block data, kept alive
|
|
819
|
-
* by refcounting, exactly like
|
|
1163
|
+
* by refcounting, exactly like a project copied out of a share. Works both ways: pull (while
|
|
820
1164
|
* impersonating a user, copy their project to yourself) and push (from your own root, copy a
|
|
821
1165
|
* project to a user). Admin cross-root op; requires the crossTreeRefs:v1 backend capability.
|
|
822
1166
|
*/
|
|
@@ -839,21 +1183,15 @@ export class MiddleLayer {
|
|
|
839
1183
|
|
|
840
1184
|
// Source label + the target's existing labels, for collision-aware renaming.
|
|
841
1185
|
const sourceMeta = await tx.getKValueJson<ProjectMeta>(sourceRid, ProjectMetaKey);
|
|
842
|
-
const
|
|
843
|
-
const existingLabels = (
|
|
844
|
-
await Promise.all(
|
|
845
|
-
targetListData.fields
|
|
846
|
-
.map((f) => f.value)
|
|
847
|
-
.filter(isNotNullSignedResourceId)
|
|
848
|
-
.map((rid) => tx.getKValueJson<ProjectMeta>(rid, ProjectMetaKey)),
|
|
849
|
-
)
|
|
850
|
-
).map((m) => m.label);
|
|
1186
|
+
const existingLabels = await existingProjectLabels(tx, targetProjectListRid);
|
|
851
1187
|
const newLabel = rename ? rename(sourceMeta.label, existingLabels) : sourceMeta.label;
|
|
852
1188
|
|
|
1189
|
+
// The whole source metadata carries over, so a copy keeps what the original said about
|
|
1190
|
+
// itself; only the label is the copy's own.
|
|
853
1191
|
const newPrj = await duplicateProject(
|
|
854
1192
|
tx,
|
|
855
1193
|
sourceRid,
|
|
856
|
-
{ label: newLabel },
|
|
1194
|
+
{ ...sourceMeta, label: newLabel },
|
|
857
1195
|
this.env.projectHelper,
|
|
858
1196
|
);
|
|
859
1197
|
tx.createField(field(targetProjectListRid, randomUUID()), "Dynamic", newPrj);
|
|
@@ -866,139 +1204,37 @@ export class MiddleLayer {
|
|
|
866
1204
|
//
|
|
867
1205
|
|
|
868
1206
|
/**
|
|
869
|
-
* Shares the given projects (Copy & Share)
|
|
870
|
-
*
|
|
871
|
-
* and the outbox is left as it was.
|
|
872
|
-
*
|
|
873
|
-
* Two variants (see {@link ShareProjectsOptions}):
|
|
874
|
-
* - `{ recipients }` — one writable grant per named recipient; the envelope expires after the
|
|
875
|
-
* default TTL (`sharedAt + envelopeTtlMs`).
|
|
876
|
-
* - `{ everyone: true }` — one make-public grant (backend rewrites the target to the
|
|
877
|
-
* everyone-user); the envelope's `expiresAt` is `null`, so it never expires.
|
|
1207
|
+
* Shares the given projects (Copy & Share): snapshots them into one envelope, in the transaction
|
|
1208
|
+
* {@link shareEnvelope} describes.
|
|
878
1209
|
*
|
|
879
1210
|
* v1 always passes `mode: "copy"`.
|
|
880
1211
|
*/
|
|
881
1212
|
public async shareProjects(
|
|
882
1213
|
projectIds: ProjectId[],
|
|
883
1214
|
options: ShareProjectsOptions,
|
|
884
|
-
): Promise<
|
|
1215
|
+
): Promise<ShareOutcome> {
|
|
885
1216
|
if (projectIds.length === 0) throw new Error("shareProjects: no projects given");
|
|
886
1217
|
|
|
887
|
-
// Everyone + replace: refresh the existing everyone-share of this project under its stable
|
|
888
|
-
// shareId (so recipients who already decided aren't re-prompted), if one exists. Found
|
|
889
|
-
// automatically by project overlap; falls through to a fresh share when none exists.
|
|
890
|
-
if ("everyone" in options && options.replace) {
|
|
891
|
-
const priorEveryone = (await this.findSupersedableEnvelopes(projectIds)).find(
|
|
892
|
-
(p) => p.everyone,
|
|
893
|
-
);
|
|
894
|
-
if (priorEveryone !== undefined) {
|
|
895
|
-
await this.changeShare(priorEveryone.shareId, { title: options.title });
|
|
896
|
-
return;
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
await this.createNewShare(projectIds, options);
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
/**
|
|
904
|
-
* Mints a fresh share: snapshots the projects into one new envelope (a fresh shareId),
|
|
905
|
-
* supersedes prior shares of the same project, and grants it — all in one atomic write
|
|
906
|
-
* transaction, so a failed grant rolls the whole thing back and the outbox is left as it was.
|
|
907
|
-
* The everyone-refresh path is the {@link changeShare} branch of {@link shareProjects}; this is
|
|
908
|
-
* the mint-a-new-envelope branch.
|
|
909
|
-
*/
|
|
910
|
-
private async createNewShare(
|
|
911
|
-
projectIds: ProjectId[],
|
|
912
|
-
options: ShareProjectsOptions,
|
|
913
|
-
): Promise<void> {
|
|
914
|
-
const everyone = "everyone" in options;
|
|
915
1218
|
const sources: EnvelopeProjectSource[] = await Promise.all(
|
|
916
1219
|
projectIds.map(
|
|
917
1220
|
async (id): Promise<EnvelopeProjectSource> => ({
|
|
918
|
-
kind: "fresh",
|
|
919
1221
|
projectId: id,
|
|
920
1222
|
sourceRid: await this.resolveProjectId(id),
|
|
921
1223
|
}),
|
|
922
1224
|
),
|
|
923
1225
|
);
|
|
924
|
-
const sender = this.currentUserLogin ?? "";
|
|
925
|
-
// Targeted share: sharedAt + ttl. Share-with-everybody: never expires (null).
|
|
926
|
-
const expiresAt = everyone ? null : Date.now() + this.env.ops.envelopeTtlMs;
|
|
927
|
-
|
|
928
|
-
// Supersede prior shares of the same project(s) so they never pile up. Resolved before
|
|
929
|
-
// the write tx (ListGrants is a separate RPC). Everyone-share supersedes a prior
|
|
930
|
-
// everyone-share of the same project; a targeted share pulls each named recipient out of
|
|
931
|
-
// any prior share of that project, deleting that share if it ends up with no recipients.
|
|
932
|
-
const priors = await this.findSupersedableEnvelopes(projectIds);
|
|
933
|
-
|
|
934
|
-
await this.pl.withWriteTx("MLShareProjects", async (tx) => {
|
|
935
|
-
if (everyone) {
|
|
936
|
-
for (const prior of priors) {
|
|
937
|
-
if (prior.everyone) tx.removeField(field(this.sharingOutboxResourceId, prior.fieldName));
|
|
938
|
-
}
|
|
939
|
-
} else {
|
|
940
|
-
const newRecipients = new Set(options.recipients);
|
|
941
|
-
for (const prior of priors) {
|
|
942
|
-
if (prior.everyone) continue; // a single user can't be pulled from an everyone-grant
|
|
943
|
-
const toRemove = prior.recipients.filter((u) => newRecipients.has(u));
|
|
944
|
-
if (toRemove.length === 0) continue;
|
|
945
|
-
const remaining = prior.recipients.filter((u) => !newRecipients.has(u));
|
|
946
|
-
if (remaining.length === 0) {
|
|
947
|
-
// Nobody left on the old share — drop the whole envelope.
|
|
948
|
-
tx.removeField(field(this.sharingOutboxResourceId, prior.fieldName));
|
|
949
|
-
} else {
|
|
950
|
-
for (const u of toRemove) tx.revokeAccess(prior.rid, u);
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
}
|
|
954
1226
|
|
|
955
|
-
|
|
1227
|
+
return await this.shareEnvelope("MLShareProjects", options, { writable: true }, (tx, meta) =>
|
|
1228
|
+
buildShareEnvelope(tx, this.sharingOutboxResourceId, sources, {
|
|
956
1229
|
mode: options.mode,
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
});
|
|
961
|
-
|
|
962
|
-
// Grant in the same transaction, atomic with the create.
|
|
963
|
-
await this.grantShareEnvelope(tx, envelope, everyone, everyone ? [] : options.recipients, {
|
|
964
|
-
writable: true,
|
|
965
|
-
});
|
|
966
|
-
|
|
967
|
-
await tx.commit();
|
|
968
|
-
});
|
|
969
|
-
|
|
970
|
-
await this.sharingOutboxTree.refreshState();
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
/**
|
|
974
|
-
* Grants one freshly built envelope inside the transaction that created it: a single make-public
|
|
975
|
-
* grant for an everyone-share (empty/ignored target, ANY_AUTHORISED — the backend rewrites the
|
|
976
|
-
* target to the everyone-user, gated by role + permission ceiling), or one grant per named
|
|
977
|
-
* recipient.
|
|
978
|
-
*
|
|
979
|
-
* `writable` is not a preference. A project pack needs a writable grant because accepting copies
|
|
980
|
-
* the snapshots out of the envelope, and the cross-color attach rule permits that only to a
|
|
981
|
-
* writable grant holder. A template share copies nothing — the document sits in the envelope's
|
|
982
|
-
* own immutable data — so it is granted read-only, and must be: a writable everyone-grant would
|
|
983
|
-
* hand every user on the server write access to the envelope.
|
|
984
|
-
*/
|
|
985
|
-
private async grantShareEnvelope(
|
|
986
|
-
tx: PlTransaction,
|
|
987
|
-
envelope: ResourceRef,
|
|
988
|
-
everyone: boolean,
|
|
989
|
-
recipients: string[],
|
|
990
|
-
permissions: { writable: boolean },
|
|
991
|
-
): Promise<void> {
|
|
992
|
-
const gid = await envelope.globalId;
|
|
993
|
-
if (everyone) tx.grantAccess(gid, "", permissions, GrantType.ANY_AUTHORISED);
|
|
994
|
-
else for (const r of recipients) tx.grantAccess(gid, r, permissions);
|
|
1230
|
+
...meta,
|
|
1231
|
+
}),
|
|
1232
|
+
);
|
|
995
1233
|
}
|
|
996
1234
|
|
|
997
1235
|
/**
|
|
998
1236
|
* Shares one stored template. The envelope carries the document itself, so there is no project
|
|
999
1237
|
* snapshot and no resource for the recipient to copy out — which is why the grant is read-only.
|
|
1000
|
-
* The cost of that is the donor's receipt: nobody can write an acceptance onto a read-only
|
|
1001
|
-
* envelope, so a template share never reports who accepted it.
|
|
1002
1238
|
*
|
|
1003
1239
|
* Nothing about the document is checked: a stored template is shareable by virtue of existing.
|
|
1004
1240
|
* An entry the recipient cannot resolve — a block installed from a folder on the sender's
|
|
@@ -1008,262 +1244,153 @@ export class MiddleLayer {
|
|
|
1008
1244
|
* @param id template to share
|
|
1009
1245
|
* @param options recipients XOR everyone, plus the title recipients see
|
|
1010
1246
|
*/
|
|
1011
|
-
public async shareTemplate(
|
|
1012
|
-
id
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
const everyone = "everyone" in options;
|
|
1018
|
-
const sender = this.currentUserLogin ?? "";
|
|
1019
|
-
// Targeted share: sharedAt + ttl. Share-with-everybody: never expires (null).
|
|
1020
|
-
const expiresAt = everyone ? null : Date.now() + this.env.ops.envelopeTtlMs;
|
|
1247
|
+
public async shareTemplate(id: TemplateId, options: ShareTemplateOptions): Promise<ShareOutcome> {
|
|
1248
|
+
const rid = await this.resolveTemplateId(id);
|
|
1249
|
+
const stored = await this.pl.withReadTx("MLReadStoredTemplate", (tx) =>
|
|
1250
|
+
readStoredTemplate(tx, id, rid),
|
|
1251
|
+
);
|
|
1021
1252
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
const { envelope, data } = buildTemplateShareEnvelope(
|
|
1253
|
+
return await this.shareEnvelope("MLShareTemplate", options, { writable: false }, (tx, meta) =>
|
|
1254
|
+
buildTemplateShareEnvelope(
|
|
1025
1255
|
tx,
|
|
1026
1256
|
this.sharingOutboxResourceId,
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
await this.sharingOutboxTree.refreshState();
|
|
1038
|
-
return { shareId: shareId! };
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
/** The document and the label of a template about to be shared. The label is what the
|
|
1042
|
-
* recipient's own list will show, so it travels with the document. */
|
|
1043
|
-
private async loadTemplateForShare(
|
|
1044
|
-
id: TemplateId,
|
|
1045
|
-
): Promise<{ document: ProjectTemplateV1; label: string }> {
|
|
1046
|
-
const rid = await this.resolveTemplateId(id);
|
|
1047
|
-
return await this.pl.withReadTx("MLReadTemplateForShare", async (tx) => {
|
|
1048
|
-
const rd = await tx.getResourceData(rid, false);
|
|
1049
|
-
if (rd.data === undefined) throw new Error(`Template ${id} carries no document.`);
|
|
1050
|
-
return {
|
|
1051
|
-
document: decodeStoredTemplateData(rd.data).document,
|
|
1052
|
-
label: await tx.getKValueJson<string>(rid, TemplateLabelKey),
|
|
1053
|
-
};
|
|
1054
|
-
});
|
|
1257
|
+
{
|
|
1258
|
+
document: stored.data.document,
|
|
1259
|
+
label: stored.label,
|
|
1260
|
+
...(stored.description === undefined ? {} : { description: stored.description }),
|
|
1261
|
+
source: id,
|
|
1262
|
+
},
|
|
1263
|
+
meta,
|
|
1264
|
+
),
|
|
1265
|
+
);
|
|
1055
1266
|
}
|
|
1056
1267
|
|
|
1057
1268
|
/**
|
|
1058
|
-
*
|
|
1059
|
-
*
|
|
1060
|
-
* transfers already-decided recipients' accept/reject records (they keep their copy and aren't
|
|
1061
|
-
* re-prompted); re-grants; drops the old envelope.
|
|
1269
|
+
* Shares one folder and everything under it: the subtree's folders, every project in them
|
|
1270
|
+
* snapshotted, and every template carried whole.
|
|
1062
1271
|
*
|
|
1063
|
-
*
|
|
1064
|
-
*
|
|
1065
|
-
*
|
|
1272
|
+
* The grant is writable, because a folder holding projects is copied out of the envelope the
|
|
1273
|
+
* way a project pack is. An everyone-share of a folder therefore hands every user on the server
|
|
1274
|
+
* write access to the envelope — the same trade a project share already makes.
|
|
1066
1275
|
*
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
1069
|
-
* existing snapshot (and its timestamp), `remove` drops the project from the pack. A project not
|
|
1070
|
-
* in the map defaults to `keep`. Omit the whole map for the legacy auto behavior (live sources
|
|
1071
|
-
* updated, gone ones kept) — the everyone-refresh path relies on that.
|
|
1276
|
+
* Folder ids do not travel. What the recipient gets is the shape of the subtree, rebuilt under
|
|
1277
|
+
* a folder of their own choosing with ids their own document mints.
|
|
1072
1278
|
*
|
|
1073
|
-
*
|
|
1074
|
-
*
|
|
1075
|
-
* the one it started from — the caller names the new target. Every other option means the same
|
|
1076
|
-
* thing for both kinds of share.
|
|
1279
|
+
* @param folder folder to share; everything beneath it goes with it
|
|
1280
|
+
* @param options recipients XOR everyone, plus the title recipients see
|
|
1077
1281
|
*/
|
|
1078
|
-
public async
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
throw new Error(`changeShare: no live share with id ${shareId} in the outbox.`);
|
|
1096
|
-
|
|
1097
|
-
const self = this.currentUserLogin ?? "";
|
|
1098
|
-
const grants = await tx.listGrants(old.rid);
|
|
1099
|
-
// A targeted share may be upgraded to everyone; an everyone-share can't be narrowed back.
|
|
1100
|
-
const everyone = grants.some((g) => isEveryoneUserLogin(g.user)) || opts.everyone === true;
|
|
1101
|
-
const priorRecipients = grants
|
|
1102
|
-
.filter((g) => !isEveryoneUserLogin(g.user) && g.user !== self)
|
|
1103
|
-
.map((g) => g.user);
|
|
1104
|
-
|
|
1105
|
-
if (old.data.payload.kind === "template") {
|
|
1106
|
-
if (target === undefined)
|
|
1107
|
-
throw new Error(
|
|
1108
|
-
`changeShare: share ${shareId} carries a template, so it needs an explicit target ` +
|
|
1109
|
-
"template — a stored template never changes, so an improved one is a different template.",
|
|
1110
|
-
);
|
|
1111
|
-
const recipients = everyone ? [] : (opts.recipients ?? priorRecipients);
|
|
1112
|
-
|
|
1113
|
-
// Same shareId, same outbox field name — detach the old field before rebuilding, or they collide.
|
|
1114
|
-
tx.removeField(field(this.sharingOutboxResourceId, old.fieldName));
|
|
1115
|
-
const { envelope } = buildTemplateShareEnvelope(tx, this.sharingOutboxResourceId, target, {
|
|
1116
|
-
sender: self,
|
|
1117
|
-
title: opts.title === undefined ? old.data.title : opts.title.trim(),
|
|
1118
|
-
expiresAt: everyone ? null : Date.now() + this.env.ops.envelopeTtlMs,
|
|
1119
|
-
shareId, // SAME shareId — the essence of change
|
|
1120
|
-
});
|
|
1121
|
-
|
|
1122
|
-
// Nothing to transfer: a read-only grant cannot write an acceptance, so a template share
|
|
1123
|
-
// never accumulated one.
|
|
1124
|
-
await this.grantShareEnvelope(tx, envelope, everyone, recipients, { writable: false });
|
|
1125
|
-
await tx.commit();
|
|
1126
|
-
return;
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
// Read the old envelope's project snapshots (uuid -> rid) and accept/reject records.
|
|
1130
|
-
const oldRd = await tx.getResourceData(old.rid, true);
|
|
1131
|
-
const snapshotByUuid = new Map<string, SignedResourceId>();
|
|
1132
|
-
const acceptances: { login: string; acc: EnvelopeAcceptance }[] = [];
|
|
1133
|
-
for (const f of oldRd.fields) {
|
|
1134
|
-
if (isNullSignedResourceId(f.value)) continue;
|
|
1135
|
-
if (isEnvelopeProjectField(f.name)) {
|
|
1136
|
-
snapshotByUuid.set(envelopeProjectFieldUuid(f.name), f.value);
|
|
1137
|
-
} else if (isAcceptanceField(f.name)) {
|
|
1138
|
-
const raw = (await tx.getResourceData(f.value, false)).data;
|
|
1139
|
-
if (raw === undefined) continue;
|
|
1140
|
-
acceptances.push({
|
|
1141
|
-
login: acceptanceFieldLogin(f.name),
|
|
1142
|
-
acc: cachedDeserialize(raw) as EnvelopeAcceptance,
|
|
1143
|
-
});
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
const decidedLogins = acceptances.map((a) => a.login);
|
|
1147
|
-
|
|
1148
|
-
// Everyone-shares ignore recipients; targeted shares keep decided users plus the edited set.
|
|
1149
|
-
const recipients = everyone
|
|
1150
|
-
? []
|
|
1151
|
-
: Array.from(new Set([...(opts.recipients ?? priorRecipients), ...decidedLogins]));
|
|
1152
|
-
|
|
1153
|
-
// Live source projects by persistable id — these get a fresh snapshot.
|
|
1154
|
-
const liveProjects = new Map<string, SignedResourceId>();
|
|
1155
|
-
const projList = await tx.getResourceData(this.projectListResourceId, true);
|
|
1156
|
-
for (const f of projList.fields) {
|
|
1157
|
-
if (isNullSignedResourceId(f.value)) continue;
|
|
1158
|
-
liveProjects.set(resourceIdToString(f.value), f.value);
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
// Per project (keyed by field uuid), apply the caller's decision (default `keep`); with no
|
|
1162
|
-
// projectActions map, fall back to the legacy auto behavior: update a live source, keep a gone one.
|
|
1163
|
-
const actions = opts.projectActions;
|
|
1164
|
-
const sources: EnvelopeProjectSource[] = [];
|
|
1165
|
-
const oldProjects = envelopeProjectMap(old.data);
|
|
1166
|
-
for (const uuid of Object.keys(oldProjects) as ProjectFieldUuid[]) {
|
|
1167
|
-
const { label, source, updatedAt } = oldProjects[uuid];
|
|
1168
|
-
const liveRid = liveProjects.get(source);
|
|
1169
|
-
|
|
1170
|
-
const action = actions
|
|
1171
|
-
? (actions[source] ?? "keep")
|
|
1172
|
-
: liveRid !== undefined
|
|
1173
|
-
? "update"
|
|
1174
|
-
: "keep";
|
|
1175
|
-
if (action === "remove") continue;
|
|
1176
|
-
|
|
1177
|
-
if (action === "update" && liveRid !== undefined) {
|
|
1178
|
-
sources.push({ kind: "fresh", projectId: source, sourceRid: liveRid });
|
|
1179
|
-
} else {
|
|
1180
|
-
// keep, or an "update" whose source vanished before commit (deleted meanwhile, e.g. from
|
|
1181
|
-
// another client): carry the prior snapshot. Liveness is read inside this write tx — race-safe.
|
|
1182
|
-
const snapshotRid = snapshotByUuid.get(uuid);
|
|
1183
|
-
if (snapshotRid !== undefined)
|
|
1184
|
-
sources.push({ kind: "carry", projectId: source, label, snapshotRid, updatedAt });
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
// Omit (undefined) keeps the current title; a provided value replaces it.
|
|
1189
|
-
const title = opts.title === undefined ? old.data.title : opts.title.trim();
|
|
1190
|
-
const expiresAt = everyone ? null : Date.now() + this.env.ops.envelopeTtlMs;
|
|
1191
|
-
|
|
1192
|
-
// Same shareId, same outbox field name — detach the old field before rebuilding, or they collide.
|
|
1193
|
-
tx.removeField(field(this.sharingOutboxResourceId, old.fieldName));
|
|
1194
|
-
|
|
1195
|
-
const { envelope } = await buildShareEnvelope(tx, this.sharingOutboxResourceId, sources, {
|
|
1196
|
-
mode: old.data.mode,
|
|
1197
|
-
sender: self,
|
|
1198
|
-
title,
|
|
1199
|
-
expiresAt,
|
|
1200
|
-
shareId, // SAME shareId — the essence of change
|
|
1201
|
-
});
|
|
1282
|
+
public async shareFolder(folder: FolderId, options: ShareFolderOptions): Promise<ShareOutcome> {
|
|
1283
|
+
const loaded = await this.loadFolderSubtree(folder, newEnvelopeFolderId);
|
|
1284
|
+
const subtree: EnvelopeFolderSubtree = {
|
|
1285
|
+
source: folder,
|
|
1286
|
+
folders: loaded.folders,
|
|
1287
|
+
projects: loaded.projects.map((project) => ({
|
|
1288
|
+
projectId: project.projectId,
|
|
1289
|
+
sourceRid: project.rid,
|
|
1290
|
+
folder: project.folder,
|
|
1291
|
+
})),
|
|
1292
|
+
templates: loaded.templates.map((template) => ({
|
|
1293
|
+
document: template.data.document,
|
|
1294
|
+
label: template.label,
|
|
1295
|
+
...(template.description === undefined ? {} : { description: template.description }),
|
|
1296
|
+
folder: template.folder,
|
|
1297
|
+
})),
|
|
1298
|
+
};
|
|
1202
1299
|
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
}
|
|
1300
|
+
return await this.shareEnvelope("MLShareFolder", options, { writable: true }, (tx, meta) =>
|
|
1301
|
+
buildFolderShareEnvelope(tx, this.sharingOutboxResourceId, subtree, meta),
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1208
1304
|
|
|
1209
|
-
|
|
1305
|
+
/**
|
|
1306
|
+
* The one transaction every share is made in: the shares named by `options.replace` are
|
|
1307
|
+
* dropped, the envelope `build` makes is created, and it is granted — all at once, so a failed
|
|
1308
|
+
* grant rolls the whole thing back and the outbox is left as it was.
|
|
1309
|
+
*
|
|
1310
|
+
* A share with named recipients grants each of them and expires after the default TTL
|
|
1311
|
+
* (`sharedAt + envelopeTtlMs`). A share with everyone is one make-public grant, and its
|
|
1312
|
+
* `expiresAt` is `null`, so it never expires.
|
|
1313
|
+
*/
|
|
1314
|
+
private async shareEnvelope(
|
|
1315
|
+
txName: string,
|
|
1316
|
+
options: ShareOptions,
|
|
1317
|
+
permissions: { writable: boolean },
|
|
1318
|
+
build: (
|
|
1319
|
+
tx: PlTransaction,
|
|
1320
|
+
meta: { sender: string; title: string; expiresAt: number | null },
|
|
1321
|
+
) =>
|
|
1322
|
+
| { envelope: ResourceRef; data: EnvelopeData }
|
|
1323
|
+
| Promise<{ envelope: ResourceRef; data: EnvelopeData }>,
|
|
1324
|
+
): Promise<ShareOutcome> {
|
|
1325
|
+
const everyone = "everyone" in options;
|
|
1326
|
+
const meta = {
|
|
1327
|
+
sender: this.currentUserLogin ?? "",
|
|
1328
|
+
title: options.title,
|
|
1329
|
+
expiresAt: everyone ? null : Date.now() + this.env.ops.envelopeTtlMs,
|
|
1330
|
+
};
|
|
1210
1331
|
|
|
1332
|
+
const outcome = await this.pl.withWriteTx(txName, async (tx) => {
|
|
1333
|
+
await this.dropShares(tx, options.replace);
|
|
1334
|
+
const { envelope, data } = await build(tx, meta);
|
|
1335
|
+
await this.grantShareEnvelope(
|
|
1336
|
+
tx,
|
|
1337
|
+
envelope,
|
|
1338
|
+
everyone,
|
|
1339
|
+
everyone ? [] : options.recipients,
|
|
1340
|
+
permissions,
|
|
1341
|
+
);
|
|
1211
1342
|
await tx.commit();
|
|
1343
|
+
return { shareId: data.shareId };
|
|
1212
1344
|
});
|
|
1213
1345
|
|
|
1214
1346
|
await this.sharingOutboxTree.refreshState();
|
|
1347
|
+
return outcome;
|
|
1215
1348
|
}
|
|
1216
1349
|
|
|
1217
1350
|
/**
|
|
1218
|
-
*
|
|
1219
|
-
* the
|
|
1220
|
-
*
|
|
1221
|
-
*
|
|
1351
|
+
* Detaches the named shares from the donor's outbox inside the caller's transaction, so a
|
|
1352
|
+
* replacement and the shares it supersedes land together or not at all.
|
|
1353
|
+
*
|
|
1354
|
+
* A share that no longer resolves is skipped rather than reported: the caller names shares the
|
|
1355
|
+
* author saw a moment ago, and one revoked meanwhile is already in the wanted state.
|
|
1222
1356
|
*/
|
|
1223
|
-
private async
|
|
1224
|
-
{
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
}[]
|
|
1231
|
-
> {
|
|
1232
|
-
const wanted = new Set(projectIds);
|
|
1233
|
-
|
|
1234
|
-
const matched = await this.pl.withReadTx("MLFindSupersede", async (tx) => {
|
|
1235
|
-
const outbox = await tx.getResourceData(this.sharingOutboxResourceId, true);
|
|
1236
|
-
const out: { fieldName: string; rid: SignedResourceId; shareId: ShareId }[] = [];
|
|
1237
|
-
for (const f of outbox.fields) {
|
|
1238
|
-
if (isNullSignedResourceId(f.value)) continue;
|
|
1239
|
-
const rd = await tx.getResourceData(f.value, false);
|
|
1240
|
-
if (rd.data === undefined) continue;
|
|
1241
|
-
const data = decodeEnvelopeData(rd.data);
|
|
1242
|
-
if (data === undefined) continue;
|
|
1243
|
-
if (Object.values(envelopeProjectMap(data)).some((p) => wanted.has(p.source)))
|
|
1244
|
-
out.push({ fieldName: f.name, rid: f.value, shareId: data.shareId });
|
|
1245
|
-
}
|
|
1246
|
-
return out;
|
|
1247
|
-
});
|
|
1357
|
+
private async dropShares(tx: PlTransaction, shareIds: ShareId[] | undefined): Promise<void> {
|
|
1358
|
+
for (const shareId of shareIds ?? []) {
|
|
1359
|
+
const target = await this.resolveOutboxEnvelope(tx, shareId);
|
|
1360
|
+
if (target === undefined) continue;
|
|
1361
|
+
tx.removeField(field(this.sharingOutboxResourceId, target.fieldName));
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1248
1364
|
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1365
|
+
/**
|
|
1366
|
+
* Grants one freshly built envelope inside the transaction that created it: a single make-public
|
|
1367
|
+
* grant for an everyone-share (empty/ignored target, ANY_AUTHORISED — the backend rewrites the
|
|
1368
|
+
* target to the everyone-user, gated by role + permission ceiling), or one grant per named
|
|
1369
|
+
* recipient.
|
|
1370
|
+
*
|
|
1371
|
+
* `writable` is not a preference. A project pack needs a writable grant because a copy out of
|
|
1372
|
+
* it takes the snapshots out of the envelope, and the cross-color attach rule permits that only
|
|
1373
|
+
* to a writable grant holder. A template share copies nothing — the document sits in the
|
|
1374
|
+
* envelope's own immutable data — so it is granted read-only, and must be: a writable
|
|
1375
|
+
* everyone-grant would hand every user on the server write access to the envelope.
|
|
1376
|
+
*/
|
|
1377
|
+
private async grantShareEnvelope(
|
|
1378
|
+
tx: PlTransaction,
|
|
1379
|
+
envelope: ResourceRef,
|
|
1380
|
+
everyone: boolean,
|
|
1381
|
+
recipients: string[],
|
|
1382
|
+
permissions: { writable: boolean },
|
|
1383
|
+
): Promise<void> {
|
|
1384
|
+
const gid = await envelope.globalId;
|
|
1385
|
+
if (everyone) tx.grantAccess(gid, "", permissions, GrantType.ANY_AUTHORISED);
|
|
1386
|
+
else for (const r of recipients) tx.grantAccess(gid, r, permissions);
|
|
1261
1387
|
}
|
|
1262
1388
|
|
|
1263
1389
|
/**
|
|
1264
1390
|
* Revokes and deletes an outgoing share for all recipients: detaches and deletes the envelope, and
|
|
1265
|
-
* its grants are revoked along with it.
|
|
1266
|
-
* the
|
|
1391
|
+
* its grants are revoked along with it. Copies recipients already took out of it are unaffected
|
|
1392
|
+
* (ref-counting keeps the resources they point at alive). Idempotent — revoking a share that is
|
|
1393
|
+
* already gone is a no-op.
|
|
1267
1394
|
*/
|
|
1268
1395
|
public async revokeShare(shareId: ShareId): Promise<void> {
|
|
1269
1396
|
await this.pl.withWriteTx("MLRevokeShare", async (tx) => {
|
|
@@ -1303,13 +1430,13 @@ export class MiddleLayer {
|
|
|
1303
1430
|
* the envelope's logical `shareId`.
|
|
1304
1431
|
*
|
|
1305
1432
|
* Reads the {@link liveEnvelopes} Computable — the same shared-resource discovery tree that
|
|
1306
|
-
* feeds {@link
|
|
1307
|
-
* `ListUserResources` re-stream on every
|
|
1433
|
+
* feeds {@link availableShares}. This is the single discovery mechanism: there is no separate
|
|
1434
|
+
* `ListUserResources` re-stream on every copy. `refreshState()` is awaited first so a
|
|
1308
1435
|
* just-granted envelope is observed (the tree's discovery poll may otherwise lag a freshly
|
|
1309
1436
|
* landed grant). The tree is gRPC-only, so this is empty on a REST-connected client.
|
|
1310
1437
|
*/
|
|
1311
1438
|
private async resolveLiveEnvelopes(): Promise<Map<ShareId, LiveEnvelope>> {
|
|
1312
|
-
await this.
|
|
1439
|
+
await this.availableSharesTree.refreshState();
|
|
1313
1440
|
const live = (await this.liveEnvelopes.getValue()) ?? [];
|
|
1314
1441
|
// Dedup by logical shareId (last writer wins — at most one live envelope per shareId).
|
|
1315
1442
|
const map = new Map<ShareId, LiveEnvelope>();
|
|
@@ -1318,31 +1445,32 @@ export class MiddleLayer {
|
|
|
1318
1445
|
}
|
|
1319
1446
|
|
|
1320
1447
|
/**
|
|
1321
|
-
*
|
|
1322
|
-
* pack of projects is duplicated into this user's project list, while a template is added to this
|
|
1323
|
-
* user's own template list and builds nothing — the recipient decides later whether to apply it.
|
|
1324
|
-
* Either way the decision is recorded per share, and a read-write share also gets the
|
|
1325
|
-
* donor-visible acceptance written onto its envelope. Per-share failures (e.g. an expiry race)
|
|
1326
|
-
* are collected, not short-circuited — the rest still get accepted. Accept-all = pass every
|
|
1327
|
-
* current pending shareId.
|
|
1448
|
+
* Copies what one or more shares carry into this user's own tree, optionally into a folder.
|
|
1328
1449
|
*
|
|
1329
|
-
*
|
|
1330
|
-
* the
|
|
1331
|
-
*
|
|
1450
|
+
* A share is a shelf, not an invitation: copying takes nothing off it and records no decision,
|
|
1451
|
+
* so the same share can be copied from again, by this user or anyone else it was granted to.
|
|
1452
|
+
* What a copy produces depends on the payload — a pack of projects lands in the project list,
|
|
1453
|
+
* a template among their templates, building nothing until the recipient applies it, and a
|
|
1454
|
+
* folder is rebuilt whole with everything it held.
|
|
1455
|
+
*
|
|
1456
|
+
* Names are chosen against the destination folder, since that is where the uniqueness rule
|
|
1457
|
+
* applies, and a project and a template follow the same rule. A destination deleted meanwhile
|
|
1458
|
+
* fails the copy rather than spilling it at the top level.
|
|
1459
|
+
* Per-share failures (a revoked envelope, say) are collected rather than short-circuited, so
|
|
1460
|
+
* one dead share does not cost the others.
|
|
1332
1461
|
*/
|
|
1333
|
-
public async
|
|
1462
|
+
public async copyShare(
|
|
1334
1463
|
shareIds: ShareId[],
|
|
1335
|
-
|
|
1464
|
+
destination?: FolderId,
|
|
1336
1465
|
): Promise<{
|
|
1337
|
-
|
|
1338
|
-
|
|
1466
|
+
projects: ProjectId[];
|
|
1467
|
+
templates: TemplateId[];
|
|
1339
1468
|
failed: { shareId: ShareId; error: string }[];
|
|
1340
1469
|
}> {
|
|
1341
1470
|
const live = await this.resolveLiveEnvelopes();
|
|
1342
|
-
const login = this.currentUserLogin;
|
|
1343
1471
|
|
|
1344
|
-
const
|
|
1345
|
-
const
|
|
1472
|
+
const projects: ProjectId[] = [];
|
|
1473
|
+
const templates: TemplateId[] = [];
|
|
1346
1474
|
const failed: { shareId: ShareId; error: string }[] = [];
|
|
1347
1475
|
|
|
1348
1476
|
for (const shareId of shareIds) {
|
|
@@ -1352,64 +1480,130 @@ export class MiddleLayer {
|
|
|
1352
1480
|
continue;
|
|
1353
1481
|
}
|
|
1354
1482
|
try {
|
|
1355
|
-
const now = Date.now();
|
|
1356
1483
|
const payload = envelope.data.payload;
|
|
1357
1484
|
|
|
1358
1485
|
if (payload.kind === "template") {
|
|
1359
|
-
const rid = await this.pl.withWriteTx("
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1486
|
+
const rid = await this.pl.withWriteTx("MLCopyTemplateShare", async (tx) => {
|
|
1487
|
+
const tree = await openFoldersTx(tx, this.foldersRids);
|
|
1488
|
+
// The template lands in their templates, keeping who sent it as its provenance, under
|
|
1489
|
+
// a name that is free where it lands — the same rule a copied project follows. What
|
|
1490
|
+
// the donor said about the template is part of the template, not of the share.
|
|
1491
|
+
const tpl = createTemplate(
|
|
1492
|
+
tx,
|
|
1493
|
+
this.templateListResourceId,
|
|
1494
|
+
{
|
|
1495
|
+
label: foldersUniqueName(payload.label, tree.namesTakenIn(destination)),
|
|
1496
|
+
description: payload.description,
|
|
1497
|
+
},
|
|
1498
|
+
{ schemaVersion: 1, document: payload.document, sender: payload.from },
|
|
1499
|
+
);
|
|
1500
|
+
|
|
1501
|
+
const created = await tpl.globalId;
|
|
1502
|
+
tree.place(
|
|
1503
|
+
[{ kind: "template", id: asTemplateId(resourceIdToString(created)) }],
|
|
1504
|
+
destination,
|
|
1505
|
+
);
|
|
1506
|
+
|
|
1376
1507
|
await tx.commit();
|
|
1377
|
-
return
|
|
1508
|
+
return created;
|
|
1378
1509
|
});
|
|
1379
1510
|
|
|
1380
|
-
const templateId = resourceIdToString(rid)
|
|
1511
|
+
const templateId = asTemplateId(resourceIdToString(rid));
|
|
1381
1512
|
this.templateIdCache.set(templateId, rid);
|
|
1382
|
-
|
|
1513
|
+
templates.push(templateId);
|
|
1514
|
+
continue;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
if (payload.kind === "folder") {
|
|
1518
|
+
const root = envelopeFolderRoot(payload.folders);
|
|
1519
|
+
if (root === undefined)
|
|
1520
|
+
throw new Error("This share does not describe one folder, so nothing can be rebuilt.");
|
|
1521
|
+
|
|
1522
|
+
const copied = await this.pl.withWriteTx("MLCopyFolderShare", async (tx) => {
|
|
1523
|
+
const tree = await openFoldersTx(tx, this.foldersRids);
|
|
1524
|
+
// The subtree is rebuilt whole, so names are only ever compared inside it — except
|
|
1525
|
+
// the root, which lands beside whatever the destination already holds.
|
|
1526
|
+
const created = await copyEnvelopeProjectsIntoList(
|
|
1527
|
+
tx,
|
|
1528
|
+
envelope.rid,
|
|
1529
|
+
this.projectListResourceId,
|
|
1530
|
+
);
|
|
1531
|
+
|
|
1532
|
+
const createdTemplates: TemplateId[] = [];
|
|
1533
|
+
const items: (FoldersLeafItem & { folder: string })[] = [];
|
|
1534
|
+
for (const { uuid, rid } of created) {
|
|
1535
|
+
const inFolder = payload.projects[uuid];
|
|
1536
|
+
items.push({
|
|
1537
|
+
kind: "project",
|
|
1538
|
+
id: asProjectId(resourceIdToString(rid)),
|
|
1539
|
+
// A project whose payload entry is missing still exists; it lands at the root.
|
|
1540
|
+
folder: inFolder?.folder ?? root,
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
for (const carried of payload.templates) {
|
|
1545
|
+
// The template lands in their templates, keeping who sent it as its provenance.
|
|
1546
|
+
const tpl = createTemplate(
|
|
1547
|
+
tx,
|
|
1548
|
+
this.templateListResourceId,
|
|
1549
|
+
{ label: carried.label, description: carried.description },
|
|
1550
|
+
{ schemaVersion: 1, document: carried.document, sender: payload.from },
|
|
1551
|
+
);
|
|
1552
|
+
const id = asTemplateId(resourceIdToString(await tpl.globalId));
|
|
1553
|
+
createdTemplates.push(id);
|
|
1554
|
+
items.push({ kind: "template", id, folder: carried.folder });
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// Folders this build cannot rewrite leave the subtree unbuilt, and the copies land at
|
|
1558
|
+
// the top level. That is deliberate: folders are an arrangement, not the content, and
|
|
1559
|
+
// a copy the user asked for is not held back for their sake.
|
|
1560
|
+
tree.graft({ root, folders: payload.folders, items }, destination);
|
|
1561
|
+
|
|
1562
|
+
await tx.commit();
|
|
1563
|
+
return { projects: created, templates: createdTemplates };
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1566
|
+
for (const { rid } of copied.projects) {
|
|
1567
|
+
const projectId = asProjectId(resourceIdToString(rid));
|
|
1568
|
+
this.projectIdCache.set(projectId, rid);
|
|
1569
|
+
projects.push(projectId);
|
|
1570
|
+
}
|
|
1571
|
+
templates.push(...copied.templates);
|
|
1383
1572
|
continue;
|
|
1384
1573
|
}
|
|
1385
1574
|
|
|
1386
|
-
const createdRids = await this.pl.withWriteTx("
|
|
1575
|
+
const createdRids = await this.pl.withWriteTx("MLCopyShare", async (tx) => {
|
|
1576
|
+
const tree = await openFoldersTx(tx, this.foldersRids);
|
|
1577
|
+
// Scoped to where the copies are going, because that is the only place their names have
|
|
1578
|
+
// to be free. `taken` grows as the pack is copied, so two projects of one name inside a
|
|
1579
|
+
// single share do not land on top of each other either.
|
|
1580
|
+
const taken = [...tree.namesTakenIn(destination)];
|
|
1387
1581
|
const created = await copyEnvelopeProjectsIntoList(
|
|
1388
1582
|
tx,
|
|
1389
1583
|
envelope.rid,
|
|
1390
1584
|
this.projectListResourceId,
|
|
1391
|
-
|
|
1585
|
+
(sourceLabel) => {
|
|
1586
|
+
const name = foldersUniqueName(sourceLabel, taken);
|
|
1587
|
+
taken.push(name);
|
|
1588
|
+
return name;
|
|
1589
|
+
},
|
|
1392
1590
|
);
|
|
1393
1591
|
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
// Read-write share: write the donor-visible acceptance onto the envelope.
|
|
1403
|
-
if (login !== null && envelope.data.mode !== "read-only")
|
|
1404
|
-
writeEnvelopeAcceptance(tx, envelope.rid, login, "accepted", now);
|
|
1592
|
+
tree.place(
|
|
1593
|
+
created.map(({ rid }) => ({
|
|
1594
|
+
kind: "project" as const,
|
|
1595
|
+
id: asProjectId(resourceIdToString(rid)),
|
|
1596
|
+
})),
|
|
1597
|
+
destination,
|
|
1598
|
+
);
|
|
1405
1599
|
|
|
1406
1600
|
await tx.commit();
|
|
1407
1601
|
return created;
|
|
1408
1602
|
});
|
|
1409
|
-
for (const rid of createdRids) {
|
|
1410
|
-
const projectId = resourceIdToString(rid)
|
|
1603
|
+
for (const { rid } of createdRids) {
|
|
1604
|
+
const projectId = asProjectId(resourceIdToString(rid));
|
|
1411
1605
|
this.projectIdCache.set(projectId, rid);
|
|
1412
|
-
|
|
1606
|
+
projects.push(projectId);
|
|
1413
1607
|
}
|
|
1414
1608
|
} catch (e) {
|
|
1415
1609
|
failed.push({ shareId, error: e instanceof Error ? e.message : String(e) });
|
|
@@ -1419,30 +1613,30 @@ export class MiddleLayer {
|
|
|
1419
1613
|
await Promise.all([
|
|
1420
1614
|
this.projectListTree.refreshState(),
|
|
1421
1615
|
this.templateListTree.refreshState(),
|
|
1422
|
-
this.
|
|
1616
|
+
this.foldersTree.refreshState(),
|
|
1423
1617
|
]);
|
|
1424
|
-
return {
|
|
1618
|
+
return { projects, templates, failed };
|
|
1425
1619
|
}
|
|
1426
1620
|
|
|
1427
|
-
/**
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1621
|
+
/**
|
|
1622
|
+
* Puts a share out of this user's sight. Private to them and reversible with
|
|
1623
|
+
* {@link unhideShare}: nothing is deleted, the donor is not told, and what was already copied
|
|
1624
|
+
* out of it is unaffected.
|
|
1625
|
+
*/
|
|
1626
|
+
public async hideShare(shareId: ShareId): Promise<void> {
|
|
1432
1627
|
const now = Date.now();
|
|
1628
|
+
await this.pl.withWriteTx("MLHideShare", async (tx) => {
|
|
1629
|
+
writeShareHidden(tx, this.sharingStateResourceId, shareId, now);
|
|
1630
|
+
await tx.commit();
|
|
1631
|
+
});
|
|
1433
1632
|
|
|
1434
|
-
await this.
|
|
1435
|
-
|
|
1436
|
-
decision: "rejected",
|
|
1437
|
-
timestamp: now,
|
|
1438
|
-
envelopeSharedAt: envelope?.data.sharedAt ?? now,
|
|
1439
|
-
acceptedProjects: [],
|
|
1440
|
-
});
|
|
1441
|
-
|
|
1442
|
-
// Read-write share: write the donor-visible rejection onto the envelope (if still live).
|
|
1443
|
-
if (envelope !== undefined && login !== null && envelope.data.mode !== "read-only")
|
|
1444
|
-
writeEnvelopeAcceptance(tx, envelope.rid, login, "rejected", now);
|
|
1633
|
+
await this.sharingStateTree.refreshState();
|
|
1634
|
+
}
|
|
1445
1635
|
|
|
1636
|
+
/** Brings a hidden share back into this user's list. */
|
|
1637
|
+
public async unhideShare(shareId: ShareId): Promise<void> {
|
|
1638
|
+
await this.pl.withWriteTx("MLUnhideShare", async (tx) => {
|
|
1639
|
+
clearShareHidden(tx, this.sharingStateResourceId, shareId);
|
|
1446
1640
|
await tx.commit();
|
|
1447
1641
|
});
|
|
1448
1642
|
|
|
@@ -1596,9 +1790,10 @@ export class MiddleLayer {
|
|
|
1596
1790
|
await Promise.all([
|
|
1597
1791
|
this.projectListTree.terminate(),
|
|
1598
1792
|
this.templateListTree.terminate(),
|
|
1793
|
+
this.foldersTree.terminate(),
|
|
1599
1794
|
this.sharingOutboxTree.terminate(),
|
|
1600
1795
|
this.sharingStateTree.terminate(),
|
|
1601
|
-
this.
|
|
1796
|
+
this.availableSharesTree.terminate(),
|
|
1602
1797
|
]);
|
|
1603
1798
|
await this.drainSnapshotWrites(SNAPSHOT_DRAIN_TIMEOUT_MS);
|
|
1604
1799
|
await this.env.dispose();
|
|
@@ -1643,15 +1838,17 @@ export class MiddleLayer {
|
|
|
1643
1838
|
)
|
|
1644
1839
|
ops.defaultTreeOptions.traversalMode = getDebugFlags().treeTraversalMode;
|
|
1645
1840
|
|
|
1646
|
-
const { projects, templates, sharingOutbox, sharingState } = await pl.withWriteTx(
|
|
1841
|
+
const { projects, templates, sharingOutbox, sharingState, folders } = await pl.withWriteTx(
|
|
1647
1842
|
"MLInitialization",
|
|
1648
1843
|
async (tx) => {
|
|
1649
1844
|
// Lazily create each clientRoot-attached singleton resource. Returns the existing
|
|
1650
1845
|
// resource id if the field is already populated, otherwise creates + locks + sets it.
|
|
1846
|
+
// A created resource's id is known only once the transaction commits.
|
|
1847
|
+
type Singleton = { existing: SignedResourceId } | { ref: ResourceRef };
|
|
1651
1848
|
const lazyInit = async (
|
|
1652
1849
|
fieldName: string,
|
|
1653
1850
|
type: { name: string; version: string },
|
|
1654
|
-
): Promise<
|
|
1851
|
+
): Promise<Singleton> => {
|
|
1655
1852
|
const f = field(tx.clientRoot, fieldName);
|
|
1656
1853
|
tx.createField(f, "Dynamic");
|
|
1657
1854
|
const fData = await tx.getField(f);
|
|
@@ -1668,14 +1865,20 @@ export class MiddleLayer {
|
|
|
1668
1865
|
const templatesR = await lazyInit(TemplatesField, TemplatesResourceType);
|
|
1669
1866
|
const outboxR = await lazyInit(SharingOutboxField, SharingOutboxResourceType);
|
|
1670
1867
|
const stateR = await lazyInit(SharingStateField, SharingStateResourceType);
|
|
1868
|
+
// The folder tree gets its own root-attached singleton rather than a field on the
|
|
1869
|
+
// projects resource: an extra field there is walked by the released project-list reader
|
|
1870
|
+
// and dereferenced as a project, which takes the whole list down, not just the folders.
|
|
1871
|
+
const foldersR = await lazyInit(FoldersField, FoldersResourceType);
|
|
1671
1872
|
|
|
1672
1873
|
await tx.commit();
|
|
1673
1874
|
|
|
1875
|
+
const idOf = async (r: Singleton) => ("existing" in r ? r.existing : await r.ref.globalId);
|
|
1674
1876
|
return {
|
|
1675
|
-
projects:
|
|
1676
|
-
templates:
|
|
1677
|
-
sharingState:
|
|
1678
|
-
sharingOutbox:
|
|
1877
|
+
projects: await idOf(projectsR),
|
|
1878
|
+
templates: await idOf(templatesR),
|
|
1879
|
+
sharingState: await idOf(stateR),
|
|
1880
|
+
sharingOutbox: await idOf(outboxR),
|
|
1881
|
+
folders: await idOf(foldersR),
|
|
1679
1882
|
};
|
|
1680
1883
|
},
|
|
1681
1884
|
);
|
|
@@ -1759,17 +1962,25 @@ export class MiddleLayer {
|
|
|
1759
1962
|
const openedProjects = new WatchableValue<ProjectId[]>([]);
|
|
1760
1963
|
const projectListTC = await createProjectList(pl, projects, openedProjects, env);
|
|
1761
1964
|
const templateListTC = await createTemplateList(pl, templates, env);
|
|
1965
|
+
const foldersTC = await createFolderList(
|
|
1966
|
+
pl,
|
|
1967
|
+
folders,
|
|
1968
|
+
projectListTC.tree,
|
|
1969
|
+
templateListTC.tree,
|
|
1970
|
+
openedProjects,
|
|
1971
|
+
env,
|
|
1972
|
+
);
|
|
1762
1973
|
|
|
1763
1974
|
// Project sharing trees and reactive views.
|
|
1764
1975
|
const outgoingTC = await createOutgoingShares(pl, sharingOutbox, env);
|
|
1765
1976
|
const sharingStateTree = await createSharingStateTree(pl, sharingState, env);
|
|
1766
|
-
const
|
|
1767
|
-
const
|
|
1768
|
-
|
|
1977
|
+
const availableSharesTree = await createAvailableSharesTree(pl, env);
|
|
1978
|
+
const availableShares = createAvailableSharesComputable(
|
|
1979
|
+
availableSharesTree,
|
|
1769
1980
|
sharingStateTree,
|
|
1770
1981
|
pl.userResources.authUser,
|
|
1771
1982
|
);
|
|
1772
|
-
const liveEnvelopes = createLiveEnvelopesComputable(
|
|
1983
|
+
const liveEnvelopes = createLiveEnvelopesComputable(availableSharesTree);
|
|
1773
1984
|
|
|
1774
1985
|
return new MiddleLayer(
|
|
1775
1986
|
env,
|
|
@@ -1779,17 +1990,20 @@ export class MiddleLayer {
|
|
|
1779
1990
|
templates,
|
|
1780
1991
|
sharingOutbox,
|
|
1781
1992
|
sharingState,
|
|
1993
|
+
folders,
|
|
1782
1994
|
openedProjects,
|
|
1783
1995
|
projectListTC.tree,
|
|
1784
1996
|
templateListTC.tree,
|
|
1997
|
+
foldersTC.tree,
|
|
1785
1998
|
outgoingTC.tree,
|
|
1786
1999
|
sharingStateTree,
|
|
1787
|
-
|
|
2000
|
+
availableSharesTree,
|
|
1788
2001
|
v2RegistryProvider,
|
|
1789
2002
|
projectListTC.computable,
|
|
1790
2003
|
templateListTC.computable,
|
|
2004
|
+
foldersTC.computable,
|
|
1791
2005
|
outgoingTC.computable,
|
|
1792
|
-
|
|
2006
|
+
availableShares,
|
|
1793
2007
|
liveEnvelopes,
|
|
1794
2008
|
);
|
|
1795
2009
|
}
|
|
@@ -1798,3 +2012,55 @@ export class MiddleLayer {
|
|
|
1798
2012
|
//
|
|
1799
2013
|
// Internals
|
|
1800
2014
|
//
|
|
2015
|
+
|
|
2016
|
+
/** A folder subtree read for copying; see {@link MiddleLayer.loadFolderSubtree}. */
|
|
2017
|
+
interface FolderSubtree<Id extends string> {
|
|
2018
|
+
/** Folder holding the subtree's root; absent when the root is at the top level. */
|
|
2019
|
+
readonly parent?: FolderId;
|
|
2020
|
+
/** Local key of the subtree's root. */
|
|
2021
|
+
readonly root: Id;
|
|
2022
|
+
readonly folders: Record<Id, { name: string; parent?: Id; description?: string }>;
|
|
2023
|
+
readonly projects: { projectId: ProjectId; rid: SignedResourceId; folder: Id }[];
|
|
2024
|
+
readonly templates: (StoredTemplate & { folder: Id })[];
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
/** Everything a stored template is: its immutable blob, plus the label and the description the
|
|
2028
|
+
* list shows, both of which live beside the blob rather than in it. */
|
|
2029
|
+
interface StoredTemplate {
|
|
2030
|
+
readonly data: StoredTemplateData;
|
|
2031
|
+
readonly label: string;
|
|
2032
|
+
/** Absent when nobody described the template. */
|
|
2033
|
+
readonly description?: string;
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
/** Reads one stored template within the caller's transaction. A share carries its document on;
|
|
2037
|
+
* a duplicate carries the blob whole, so the copy says of itself what the original did. */
|
|
2038
|
+
async function readStoredTemplate(
|
|
2039
|
+
tx: PlTransaction,
|
|
2040
|
+
id: TemplateId,
|
|
2041
|
+
rid: SignedResourceId,
|
|
2042
|
+
): Promise<StoredTemplate> {
|
|
2043
|
+
const rd = await tx.getResourceData(rid, false);
|
|
2044
|
+
if (rd.data === undefined) throw new Error(`Template ${id} carries no document.`);
|
|
2045
|
+
const [label, description] = await Promise.all([
|
|
2046
|
+
tx.getKValueJson<string>(rid, TemplateLabelKey),
|
|
2047
|
+
tx.getKValueJsonIfExists<string>(rid, TemplateDescriptionKey).then(normalizeDescription),
|
|
2048
|
+
]);
|
|
2049
|
+
return {
|
|
2050
|
+
data: decodeStoredTemplateData(rd.data),
|
|
2051
|
+
label,
|
|
2052
|
+
...(description === undefined ? {} : { description }),
|
|
2053
|
+
};
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
/** The label of every project in a project list, read within the caller's transaction. */
|
|
2057
|
+
async function existingProjectLabels(
|
|
2058
|
+
tx: PlTransaction,
|
|
2059
|
+
listRid: SignedResourceId,
|
|
2060
|
+
): Promise<string[]> {
|
|
2061
|
+
const listed = await listedById(tx, listRid);
|
|
2062
|
+
const metas = await Promise.all(
|
|
2063
|
+
[...listed.values()].map(({ rid }) => tx.getKValueJson<ProjectMeta>(rid, ProjectMetaKey)),
|
|
2064
|
+
);
|
|
2065
|
+
return metas.map((meta) => meta.label);
|
|
2066
|
+
}
|