@smarterplan/ngx-smarterplan-core 1.4.13 → 1.4.14

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.
@@ -6000,6 +6000,34 @@ const copyFileInS3 = async (sourceKey, destKey) => {
6000
6000
  const result = await Storage.copy({ key: sourceKey, level: "public" }, { key: destKey, level: "public" });
6001
6001
  return result.key;
6002
6002
  };
6003
+ /**
6004
+ * Fallback duplication: downloads the object and re-uploads it under the new key.
6005
+ *
6006
+ * Storage.copy() builds the S3 `x-amz-copy-source` header by plain string concatenation
6007
+ * (see @aws-amplify/storage AWSS3Provider.copy: `${bucket}/${srcPrefix}${srcKey}`), with no
6008
+ * URL-encoding of the key. A key holding a space, an accent or a parenthesis therefore
6009
+ * yields a header that does not match the signed canonical request, and S3 answers
6010
+ * "The request signature we calculated does not match the signature you provided".
6011
+ *
6012
+ * Round-tripping the bytes avoids that header entirely and works for any key, at the cost
6013
+ * of transferring the file through the browser — hence its use as a fallback only.
6014
+ * IMPORTANT: like copyFileInS3, this must NOT swallow errors.
6015
+ */
6016
+ const copyFileInS3ViaDownload = async (sourceKey, destKey) => {
6017
+ const downloaded = await Storage.get(sourceKey, {
6018
+ download: true,
6019
+ level: "public",
6020
+ });
6021
+ const body = downloaded?.Body;
6022
+ if (!body) {
6023
+ throw new Error(`Empty download for "${sourceKey}", cannot duplicate it`);
6024
+ }
6025
+ const result = await Storage.put(destKey, body, {
6026
+ level: "public",
6027
+ contentType: downloaded.ContentType || undefined,
6028
+ });
6029
+ return result.key;
6030
+ };
6003
6031
 
6004
6032
  function getDistanceBetweenTwoPoints(startPoint, endPoint) {
6005
6033
  const dx = endPoint.x - startPoint.x;
@@ -8908,12 +8936,14 @@ class EquipmentService extends BaseObjectService {
8908
8936
  * S3 object itself has already been physically copied by the caller beforehand,
8909
8937
  * so this only needs to record the Note pointing at that new key.
8910
8938
  */
8911
- async createNoteWithExistingAttachment(equipmentID, attachmentKey, type, ownerMissionID) {
8939
+ async createNoteWithExistingAttachment(equipmentID, attachmentKey, type, ownerMissionID, dates = {}) {
8912
8940
  return this.API.__proto__.CreateNote({
8913
8941
  attachment: attachmentKey,
8914
8942
  type,
8915
8943
  equipmentID,
8916
8944
  ownerMissionID,
8945
+ signDate: dates.signDate ?? null,
8946
+ expirationDate: dates.expirationDate ?? null,
8917
8947
  });
8918
8948
  }
8919
8949
  async uploadImage(equipment, imageFile) {
@@ -9627,6 +9657,33 @@ class HashtagService {
9627
9657
  async getHashtag(hashtagID) {
9628
9658
  return this.API.__proto__.GetHashtag(hashtagID);
9629
9659
  }
9660
+ /*
9661
+ * ---------------------------------------------------------------------------
9662
+ * Pure data access — NO side effect on currentHashtags / currentSpaceID.
9663
+ *
9664
+ * `currentHashtags` is shared UI state read by HashtagFromIdPipe to render a
9665
+ * keyword's name. Batch work (space duplication, export, admin tooling) touches
9666
+ * hashtags of spaces the operator is not editing, and must not reshape what the
9667
+ * screens are displaying — hence this side-effect-free trio.
9668
+ * ---------------------------------------------------------------------------
9669
+ */
9670
+ /** Reads a space's keywords without touching the shared cache. */
9671
+ async listHashtagsForSpace(spaceID) {
9672
+ return this.API.__proto__.HashtagsBySpace(spaceID).then((response) => response.items);
9673
+ }
9674
+ /** Creates a keyword in a space without adding it to the shared cache. */
9675
+ async createHashtagForSpace(name, spaceID) {
9676
+ return this.API.__proto__.CreateHashtag({ name, spaceID });
9677
+ }
9678
+ /** Deletes a keyword without touching the shared cache. */
9679
+ async deleteHashtagById(hashtagID) {
9680
+ return this.API.__proto__.DeleteHashtag({ id: hashtagID });
9681
+ }
9682
+ /*
9683
+ * ---------------------------------------------------------------------------
9684
+ * UI-facing operations — same actions, plus maintenance of the shared cache.
9685
+ * ---------------------------------------------------------------------------
9686
+ */
9630
9687
  async createHashtag(hashtag) {
9631
9688
  const hashtagCreated = await this.API.__proto__.CreateHashtag(hashtag);
9632
9689
  this.currentHashtags.push(hashtagCreated);
@@ -9640,7 +9697,7 @@ class HashtagService {
9640
9697
  return this.currentHashtags;
9641
9698
  }
9642
9699
  this.currentSpaceID = spaceID;
9643
- return this.API.__proto__.HashtagsBySpace(spaceID).then((response) => response.items);
9700
+ return this.listHashtagsForSpace(spaceID);
9644
9701
  }
9645
9702
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: HashtagService, deps: [{ token: 'currentAPIService' }], target: i0.ɵɵFactoryTarget.Injectable });
9646
9703
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: HashtagService, providedIn: "root" });
@@ -9660,14 +9717,33 @@ class HashtagFromIdPipe {
9660
9717
  constructor(hashtagService) {
9661
9718
  this.hashtagService = hashtagService;
9662
9719
  }
9720
+ /**
9721
+ * Resolves a hashtag id to its display name.
9722
+ *
9723
+ * The cache is consulted for THIS id, and the network answers whenever the id is not
9724
+ * in it.
9725
+ *
9726
+ * Hashtag ids are globally unique, so a cache holding entries from several spaces can
9727
+ * only ever produce a correct hit or a miss, never a wrong name.
9728
+ */
9663
9729
  async transform(hashtagID) {
9664
- if (this.hashtagService.currentHashtags.length === 0) {
9665
- return this.hashtagService.getHashtag(hashtagID).then((hashtag) => {
9666
- return hashtag.name;
9667
- });
9730
+ if (!hashtagID) {
9731
+ return "";
9732
+ }
9733
+ const cached = this.hashtagService.currentHashtags.find((_hashtag) => _hashtag.id === hashtagID);
9734
+ if (cached) {
9735
+ return cached.name ?? "";
9736
+ }
9737
+ try {
9738
+ const hashtag = await this.hashtagService.getHashtag(hashtagID);
9739
+ return hashtag?.name ?? "";
9740
+ }
9741
+ catch (error) {
9742
+ // A keyword that cannot be read (deleted, network hiccup) must not break the
9743
+ // rendering of the POI that carries it.
9744
+ console.warn(`[HashtagFromIdPipe] Could not resolve the keyword "${hashtagID}":`, error);
9745
+ return "";
9668
9746
  }
9669
- const hashtag = this.hashtagService.currentHashtags.find((_hashtag) => _hashtag.id === hashtagID);
9670
- return hashtag ? hashtag.name : "";
9671
9747
  }
9672
9748
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: HashtagFromIdPipe, deps: [{ token: HashtagService }], target: i0.ɵɵFactoryTarget.Pipe });
9673
9749
  static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "17.3.12", ngImport: i0, type: HashtagFromIdPipe, name: "hashtagFromID" });
@@ -10387,6 +10463,40 @@ class CommentService {
10387
10463
  async createComment(comment) {
10388
10464
  return this.API.__proto__.CreateComment(comment);
10389
10465
  }
10466
+ /**
10467
+ * Returns EVERY comment attached to a feature, following `nextToken` until the
10468
+ * connection is exhausted.
10469
+ *
10470
+ * The `comments` connection nested inside FeaturesBySpace/GetFeature is capped by
10471
+ * the AppSync resolver (`$util.defaultIfNull($context.args.limit, 100)`) and exposes
10472
+ * a `nextToken` that nobody follows, so a media-rich POI silently loses every
10473
+ * attachment past the first page. Anything that must see the complete media set of
10474
+ * a feature (duplication, export, audit) has to use this method rather than
10475
+ * `feature.comments.items`.
10476
+ */
10477
+ async getAllCommentsForFeature(featureID) {
10478
+ return this.getAllCommentsPaginated((limit, nextToken) => this.API.__proto__.CommentsByFeature(featureID, undefined, undefined, limit, nextToken), featureID);
10479
+ }
10480
+ /** Same as getAllCommentsForFeature(), for the attachments of a ticket. */
10481
+ async getAllCommentsForTicket(ticketID) {
10482
+ return this.getAllCommentsPaginated((limit, nextToken) => this.API.__proto__.CommentsByTicket(ticketID, undefined, undefined, limit, nextToken), ticketID);
10483
+ }
10484
+ /** Drains a paginated comment query until its nextToken is exhausted. */
10485
+ async getAllCommentsPaginated(fetchPage, ownerID) {
10486
+ if (!ownerID) {
10487
+ return [];
10488
+ }
10489
+ const all = [];
10490
+ let nextToken = null;
10491
+ do {
10492
+ const page = await fetchPage(1000, nextToken);
10493
+ if (page?.items?.length > 0) {
10494
+ all.push(...page.items.filter((item) => !!item));
10495
+ }
10496
+ nextToken = page?.nextToken ?? null;
10497
+ } while (nextToken);
10498
+ return all;
10499
+ }
10390
10500
  async updateComment(input) {
10391
10501
  return this.API.__proto__.UpdateComment(input);
10392
10502
  }
@@ -11668,6 +11778,31 @@ class FeatureService extends BaseObjectService {
11668
11778
  }
11669
11779
  return features;
11670
11780
  }
11781
+ /**
11782
+ * Returns EVERY feature of a space, following `nextToken` until the index is
11783
+ * exhausted, with no user/archive filtering at all.
11784
+ *
11785
+ * `getFeaturesBySpaceWithoutFiltering()` reads a single page (the AppSync default
11786
+ * caps it at 100 items) and calls `isArchived()`, which dereferences the current
11787
+ * user's mission ON THAT SPACE — so it both truncates large spaces and throws for a
11788
+ * space the operator has no mission on. Duplication has to see the raw, complete
11789
+ * list, hence this separate accessor rather than a change of the existing one.
11790
+ */
11791
+ async getAllFeaturesBySpaceRaw(spaceID) {
11792
+ if (!spaceID) {
11793
+ return [];
11794
+ }
11795
+ const all = [];
11796
+ let nextToken = null;
11797
+ do {
11798
+ const page = await this.API.__proto__.FeaturesBySpace(spaceID, undefined, undefined, 1000, nextToken);
11799
+ if (page?.items?.length > 0) {
11800
+ all.push(...page.items.filter((item) => !!item));
11801
+ }
11802
+ nextToken = page?.nextToken ?? null;
11803
+ } while (nextToken);
11804
+ return all;
11805
+ }
11671
11806
  isBookingPlace(feature) {
11672
11807
  feature.isBookingPlace = feature.type === FeatureType.DESK;
11673
11808
  }
@@ -13714,16 +13849,42 @@ function applyRigidTransformToPoint(p, t) {
13714
13849
  z: t.refTarget.z + (dx * sin + dz * cos)
13715
13850
  };
13716
13851
  }
13717
- /** Applies the Y rotation (in degrees) to an orientation angle (a pose's yRotation). */
13852
+ /**
13853
+ * Applies the Y rotation (in degrees) to an orientation angle (a pose's yRotation).
13854
+ * A null/undefined/non-numeric angle is returned untouched: "no orientation stored"
13855
+ * must stay "no orientation stored", not become the raw rotation delta.
13856
+ */
13718
13857
  function applyRigidTransformToYRotationDeg(yRotationDeg, t) {
13858
+ if (yRotationDeg === null || yRotationDeg === undefined || Number.isNaN(Number(yRotationDeg))) {
13859
+ return yRotationDeg;
13860
+ }
13719
13861
  const deltaDeg = t.rotationYRad * (180 / Math.PI);
13720
- return yRotationDeg + deltaDeg;
13862
+ return Number(yRotationDeg) + deltaDeg;
13721
13863
  }
13722
13864
  /** Euclidean distance between two points, useful to validate landmark quality. */
13723
13865
  function distance(a, b) {
13724
13866
  const dx = a.x - b.x, dy = a.y - b.y, dz = a.z - b.z;
13725
13867
  return Math.sqrt(dx * dx + dy * dy + dz * dz);
13726
13868
  }
13869
+ /**
13870
+ * Applies ONLY the rotation part of the transform to a direction vector
13871
+ * (a normal, a stem vector, ...).
13872
+ *
13873
+ * A direction has no origin, so the translation must NOT be applied to it —
13874
+ * doing so would turn a unit normal into a point somewhere in the target model.
13875
+ * Used for a POI's `metadata.normal`, which drives the orientation of the tag's
13876
+ * stem: without this rotation, a pastille copied into a space whose model is
13877
+ * rotated relative to the source ends up pointing into the wall it is stuck on.
13878
+ */
13879
+ function applyRigidTransformToDirection(v, t) {
13880
+ const cos = Math.cos(t.rotationYRad);
13881
+ const sin = Math.sin(t.rotationYRad);
13882
+ return {
13883
+ x: v.x * cos - v.z * sin,
13884
+ y: v.y, // vertical axis is not rotated
13885
+ z: v.x * sin + v.z * cos
13886
+ };
13887
+ }
13727
13888
 
13728
13889
  /* eslint-disable class-methods-use-this */
13729
13890
  class Object3DService {
@@ -14142,6 +14303,8 @@ class MatterportTagCopyService {
14142
14303
  featureService;
14143
14304
  measurementService;
14144
14305
  commentService;
14306
+ hashtagService;
14307
+ spaceService;
14145
14308
  translate;
14146
14309
  // Emits progress updates during copyTagsFromVisit(); null when idle
14147
14310
  copyProgress$ = new BehaviorSubject(null);
@@ -14150,7 +14313,34 @@ class MatterportTagCopyService {
14150
14313
  spModule;
14151
14314
  log = [];
14152
14315
  errorSeq = 0;
14153
- constructor(navigationService, userService, poiService, ticketService, equipmentService, object3DService, featureService, measurementService, commentService, translate) {
14316
+ // ---- Per-run remapping state (reset by resetRunState() at the start of every copy) ----
14317
+ /** source element id -> id of its copy in the target space. */
14318
+ elementIds = {};
14319
+ /** source Comment id -> id of its copy. Needed to remap POI.tagImageID, which is a
14320
+ * reference to a Comment record (see the schema: "connection to comment.id"). */
14321
+ commentIds = new Map();
14322
+ // Hashtags are space-scoped records, so their ids can never be carried over as-is: they
14323
+ // are matched by NAME against the target space, and the missing ones are created there.
14324
+ // That creation is deliberately lazy — see resolveHashtagIds() — and audited, so an
14325
+ // interrupted copy cannot leave keywords behind that no POI references.
14326
+ /** source Hashtag id -> its name. */
14327
+ sourceHashtagNames = new Map();
14328
+ /** normalised hashtag name -> its id in the TARGET space (pre-existing or just created). */
14329
+ targetHashtagIdsByName = new Map();
14330
+ /** ids of the hashtags this run created in the target space. */
14331
+ createdHashtagIds = new Set();
14332
+ /** ids of the hashtags actually persisted on a successfully created feature. Anything in
14333
+ * createdHashtagIds but absent here is an orphan and is deleted at the end of the run. */
14334
+ usedHashtagIds = new Set();
14335
+ /** source ids of the elements whose POIs were already created by an element phase.
14336
+ * Guards the standalone POI phase against creating a second, impoverished copy. */
14337
+ handledElementIds = new Set();
14338
+ /** true when source and target spaces belong to the same organisation. Domains are
14339
+ * organisation-scoped (no spaceID), so domainID survives the copy only in that case. */
14340
+ sameOrganisation = false;
14341
+ /** Target space id of the run in progress — used to re-home copied tag icons. */
14342
+ targetSpaceId = null;
14343
+ constructor(navigationService, userService, poiService, ticketService, equipmentService, object3DService, featureService, measurementService, commentService, hashtagService, spaceService, translate) {
14154
14344
  this.navigationService = navigationService;
14155
14345
  this.userService = userService;
14156
14346
  this.poiService = poiService;
@@ -14160,6 +14350,8 @@ class MatterportTagCopyService {
14160
14350
  this.featureService = featureService;
14161
14351
  this.measurementService = measurementService;
14162
14352
  this.commentService = commentService;
14353
+ this.hashtagService = hashtagService;
14354
+ this.spaceService = spaceService;
14163
14355
  this.translate = translate;
14164
14356
  this.spModule = userService.getSpModule();
14165
14357
  }
@@ -14169,6 +14361,7 @@ class MatterportTagCopyService {
14169
14361
  async copyTagsFromVisit(sourceSpaceId, targetSpaceId, landmarkSourceA, landmarkTargetA, landmarkSourceB, landmarkTargetB) {
14170
14362
  let errorCount = 0;
14171
14363
  this.resetLog();
14364
+ this.resetRunState(targetSpaceId);
14172
14365
  try {
14173
14366
  console.log(`Copying Tags from source ${sourceSpaceId} to target ${targetSpaceId}`);
14174
14367
  this.setStepProgress('collecting', 'collecting', 0);
@@ -14186,6 +14379,9 @@ class MatterportTagCopyService {
14186
14379
  refSource: transform.refSource,
14187
14380
  refTarget: transform.refTarget
14188
14381
  });
14382
+ // Domains are organisation-scoped, hashtags are space-scoped: both need to know
14383
+ // whether we stay inside the same organisation before anything is remapped.
14384
+ this.sameOrganisation = await this.spacesShareOrganisation(sourceSpaceId, targetSpaceId);
14189
14385
  const sourceSweepIds = await this.navigationService.getAllowedSweepsIDsForUserInSpace(sourceSpaceId);
14190
14386
  const targetSweepIds = await this.navigationService.getAllowedSweepsIDsForUserInSpace(targetSpaceId);
14191
14387
  this.setStepProgress('collecting', 'collecting', 30);
@@ -14193,7 +14389,7 @@ class MatterportTagCopyService {
14193
14389
  this.buildNavigationPositionMap(sourceSpaceId, sourceSweepIds),
14194
14390
  this.buildNavigationPositionMap(targetSpaceId, targetSweepIds)
14195
14391
  ]);
14196
- const elementIds = {};
14392
+ const elementIds = this.elementIds;
14197
14393
  let sourceTickets = [];
14198
14394
  let sourceEquipments = [];
14199
14395
  let targetTickets = [];
@@ -14209,9 +14405,23 @@ class MatterportTagCopyService {
14209
14405
  this.setStepProgress('collecting', 'collecting', 55);
14210
14406
  const sourceObjects3D = await this.object3DService.getObjects3DBySpace(sourceSpaceId);
14211
14407
  const targetObjects3D = await this.object3DService.getObjects3DBySpace(targetSpaceId);
14212
- const sourceFeatures = await this.featureService.getFeaturesBySpaceWithoutFiltering(sourceSpaceId);
14213
- const targetFeatures = await this.featureService.getFeaturesBySpaceWithoutFiltering(targetSpaceId);
14408
+ // Paginated: a space routinely holds more than the 100 features a single AppSync
14409
+ // page returns, and the tail was silently dropped.
14410
+ //
14411
+ // The source list keeps the historical "skip what the operator archived" rule, so
14412
+ // the set of copied features does not change — but the check is now null-safe:
14413
+ // FeatureService.isArchived() dereferences currentMission(spaceID).id and threw a
14414
+ // TypeError when the operator had no mission on the source space (the common case
14415
+ // for an archived v1 space), aborting the whole copy.
14416
+ // The target list is NOT filtered: it only feeds duplicate detection, and an
14417
+ // archived feature is still a feature that exists there.
14418
+ const sourceFeatures = (await this.featureService.getAllFeaturesBySpaceRaw(sourceSpaceId))
14419
+ .filter(feature => !this.isArchivedForOperator(feature, sourceSpaceId));
14420
+ const targetFeatures = await this.featureService.getAllFeaturesBySpaceRaw(targetSpaceId);
14214
14421
  this.setStepProgress('collecting', 'collecting', 75);
14422
+ // Keywords: index both vocabularies now (read-only). The keywords the target space
14423
+ // is missing are created later, POI by POI, only for those actually used.
14424
+ await this.loadHashtagIndex(sourceSpaceId, targetSpaceId);
14215
14425
  if (this.spModule == SpModule.IMMO) {
14216
14426
  sourceMeasurements = await this.measurementService.getMeasurementsForSpaceForUser(sourceSpaceId);
14217
14427
  targetMeasurements = await this.measurementService.getMeasurementsForSpaceForUser(targetSpaceId);
@@ -14219,12 +14429,44 @@ class MatterportTagCopyService {
14219
14429
  this.setStepProgress('collecting', 'collecting', 90);
14220
14430
  const sourcePoisAll = await this.poiService.getPoisByMatterportSweeps(sourceSweepIds);
14221
14431
  const targetPoisAll = await this.poiService.getPoisByMatterportSweeps(targetSweepIds);
14432
+ // A POI is "owned" by an element (ticket, equipment, 3D object, feature, measurement)
14433
+ // when its elementID points at one. Those POIs are created by their element's phase,
14434
+ // which is the only place that knows the element's NEW id — so they must be excluded
14435
+ // from the standalone phase, otherwise every feature ends up with two POIs: one
14436
+ // correctly linked but stripped of its tag metadata, and one carrying the metadata
14437
+ // but still pointing at the source space's element.
14438
+ //
14439
+ // Ownership is recognised by TWO independent criteria, and either one is enough to
14440
+ // keep a POI out of the standalone phase:
14441
+ //
14442
+ // - its type is one an element phase is responsible for. This is the historical
14443
+ // rule and it must be kept: a POI of that type whose element was NOT collected
14444
+ // (deleted ticket, equipment outside the current filter, ...) is an orphan, and
14445
+ // copying it standalone would recreate it with an elementID still pointing into
14446
+ // the source space — an orphan the previous version correctly refused to copy.
14447
+ // - its elementID belongs to an element we actually collected. This is the added
14448
+ // rule, needed because the type alone is not a reliable discriminator: historical
14449
+ // data also holds INFORMATION/ROOM POIs bound to a feature, which the type test
14450
+ // lets through and which would end up duplicated.
14222
14451
  const handledPoiTypes = new Set([
14223
14452
  PoiType.TICKET, PoiType.EQUIPMENT, PoiType.DATA, PoiType.DESK,
14224
14453
  PoiType.OBJECT3D, PoiType.MEASURE
14225
14454
  ]);
14226
- const sourcePois = sourcePoisAll.filter(p => !handledPoiTypes.has(p.type));
14227
- const targetPois = targetPoisAll.filter(p => !handledPoiTypes.has(p.type));
14455
+ for (const owner of [...sourceTickets, ...sourceEquipments, ...sourceObjects3D, ...sourceFeatures]) {
14456
+ if (owner?.id) {
14457
+ this.handledElementIds.add(owner.id);
14458
+ }
14459
+ }
14460
+ for (const measurement of sourceMeasurements) {
14461
+ // a measurement POI is bound to the measurement's Comment, not to the measurement
14462
+ if (measurement?.comment?.id) {
14463
+ this.handledElementIds.add(measurement.comment.id);
14464
+ }
14465
+ }
14466
+ const isOwnedByAnElement = (poi) => handledPoiTypes.has(poi.type)
14467
+ || (!!poi.elementID && this.handledElementIds.has(poi.elementID));
14468
+ const sourcePois = sourcePoisAll.filter(p => !isOwnedByAnElement(p));
14469
+ const targetPois = targetPoisAll.filter(p => !!p);
14228
14470
  this.setStepProgress('collecting', 'collecting', 100);
14229
14471
  const totalOverall = sourceTickets.length + sourceEquipments.length + sourcePois.length
14230
14472
  + sourceObjects3D.length + sourceFeatures.length + sourceMeasurements.length;
@@ -14245,7 +14487,7 @@ class MatterportTagCopyService {
14245
14487
  for (let i = 0; i < sourceTickets.length; i++) {
14246
14488
  const sourceObj = sourceTickets[i];
14247
14489
  try {
14248
- const existsInTarget = this.matchesExisting(sourceObj, targetTickets);
14490
+ const existsInTarget = this.matchesExisting(sourceObj, targetTickets, (t) => [t.title, t.description]);
14249
14491
  if (!existsInTarget) {
14250
14492
  const rawSource = sourceObj;
14251
14493
  const ticketInput = {
@@ -14260,28 +14502,51 @@ class MatterportTagCopyService {
14260
14502
  read: rawSource.read || null,
14261
14503
  projectID: rawSource.projectID || null,
14262
14504
  problemCode: rawSource.problemCode || null,
14263
- archivedForMissionID: rawSource.archivedForMissionID || null,
14505
+ // Missions are per-space: a source mission id designates nobody in
14506
+ // the target space. Carrying the list over would look like real data
14507
+ // while hiding the item from no one — reset it, as for features.
14508
+ archivedForMissionID: null,
14264
14509
  spaceID: targetSpaceId,
14510
+ // Remapped in the second pass below, once every ticket exists.
14265
14511
  parentID: null,
14266
- domainID: null,
14512
+ // Domains are organisation-scoped, like for equipments and features.
14513
+ domainID: this.sameOrganisation ? (rawSource.domainID || null) : null,
14267
14514
  ownerMissionID: targetMissionId
14268
14515
  };
14269
14516
  const createdObj = await this.ticketService.createTicket(ticketInput);
14270
14517
  if (createdObj && createdObj.id) {
14271
14518
  elementIds[sourceObj.id] = createdObj.id;
14272
14519
  }
14273
- if (sourceObj.pois.items && sourceObj.pois.items.length > 0) {
14274
- const sourcePoi = sourceObj.pois.items[0];
14275
- const targetSweepId = this.findClosestTargetSweepId(sourcePoi.matterportSweepID, sourceNavPositions, targetNavPositions, transform);
14276
- const adjustedCoordinate = this.getAdjustedCoordinates(sourcePoi.coordinate, transform);
14277
- const poiInput = {
14278
- coordinate: JSON.stringify(adjustedCoordinate),
14279
- type: sourcePoi.type,
14280
- elementID: createdObj.id,
14281
- metadata: sourcePoi.metadata,
14282
- matterportSweepID: targetSweepId
14283
- };
14284
- await this.poiService.createPOIForSpaceID(poiInput);
14520
+ // Attachments of the ticket (photos of the incident, documents...).
14521
+ // They were never copied at all: a duplicated ticket arrived stripped
14522
+ // of every piece of evidence attached to it.
14523
+ if (createdObj) {
14524
+ let sourceComments = [];
14525
+ try {
14526
+ sourceComments = await this.commentService.getAllCommentsForTicket(sourceObj.id);
14527
+ }
14528
+ catch (commentsFetchError) {
14529
+ console.warn(`Falling back to the embedded comment list for ticket "${rawSource.title}":`, commentsFetchError);
14530
+ sourceComments = (sourceObj.comments?.items || []).filter(c => !!c);
14531
+ }
14532
+ for (const sourceComment of this.orderCommentsForCopy(sourceComments)) {
14533
+ try {
14534
+ const created = await this.copyCommentForTicket(sourceComment, createdObj.id, targetSpaceId, targetMissionId);
14535
+ if (created?.id && sourceComment.id) {
14536
+ this.commentIds.set(sourceComment.id, created.id);
14537
+ }
14538
+ }
14539
+ catch (attachmentError) {
14540
+ console.error(`Error copying attachment "${sourceComment?.title}" for ticket "${sourceObj?.title}":`, attachmentError);
14541
+ errorCount++;
14542
+ this.logCopyItemError(attachmentError, 'errorFeatureAttachment', sourceComment?.title || sourceComment?.id, {
14543
+ feature: rawSource.title || sourceObj.id
14544
+ });
14545
+ }
14546
+ }
14547
+ }
14548
+ for (const sourcePoi of (sourceObj.pois?.items || []).filter(p => !!p)) {
14549
+ await this.createCopiedPoi(sourcePoi, createdObj.id, sourceNavPositions, targetNavPositions, transform);
14285
14550
  }
14286
14551
  }
14287
14552
  }
@@ -14295,26 +14560,69 @@ class MatterportTagCopyService {
14295
14560
  const pct = sourceTickets.length > 0 ? Math.round(((i + 1) / sourceTickets.length) * 100) : 100;
14296
14561
  this.setStepProgress('tickets', 'tickets', pct);
14297
14562
  }
14298
- // EQUIPMENTS (title, POI, PHOTO and NOTES/documents all copied — files physically
14299
- // duplicated in S3 first, so the copy stays independent from the source)
14563
+ // Second pass: restore the ticket hierarchy. `parentID` had to be nulled during
14564
+ // creation (the parent may not have existed yet) and was then never restored, so
14565
+ // every sub-ticket came out detached. Same rule as for sub-equipments: a parent
14566
+ // with no copied counterpart is dropped rather than left pointing at the source
14567
+ // space's ticket.
14568
+ for (const sourceObj of sourceTickets) {
14569
+ const rawSource = sourceObj;
14570
+ const newId = elementIds[sourceObj.id];
14571
+ const newParentId = rawSource.parentID ? elementIds[rawSource.parentID] : null;
14572
+ if (newId && newParentId) {
14573
+ try {
14574
+ await this.ticketService.updateTicket({ id: newId, parentID: newParentId });
14575
+ }
14576
+ catch (parentError) {
14577
+ console.error(`Error remapping the parent ticket of "${sourceObj?.title}":`, parentError);
14578
+ errorCount++;
14579
+ this.logCopyItemError(parentError, 'errorTicket', sourceObj?.title || sourceObj?.id);
14580
+ }
14581
+ }
14582
+ }
14583
+ // EQUIPMENTS (identity, POI, PHOTO and NOTES/documents all copied — files
14584
+ // physically duplicated in S3 first, so the copy stays independent from the source)
14585
+ //
14586
+ // CAREFUL: the Equipment model has NO `title` and NO `description` column. It
14587
+ // identifies itself with `name` (String! — required) and annotates itself with
14588
+ // `annotation`. Reading `rawSource.title` therefore always yielded undefined and
14589
+ // the required `name` was never sent, which AppSync rejected with
14590
+ // "Variable 'input' has coerced Null value for NonNull type 'String!'".
14300
14591
  for (let i = 0; i < sourceEquipments.length; i++) {
14301
14592
  const sourceObj = sourceEquipments[i];
14302
14593
  try {
14303
- const existsInTarget = this.matchesExisting(sourceObj, targetEquipments);
14594
+ // Equipment has no `title`/`description`: it identifies itself by name, and by
14595
+ // serial number when one is recorded.
14596
+ const existsInTarget = this.matchesExisting(sourceObj, targetEquipments, (e) => [e.name, e.serialNumber]);
14304
14597
  if (!existsInTarget) {
14305
14598
  const rawSource = sourceObj;
14306
14599
  let copiedPhotoKey = null;
14307
14600
  if (rawSource.photo) {
14308
- copiedPhotoKey = await this.copyS3KeyIfPresent(rawSource.photo, `equipment photo "${rawSource.title}"`);
14601
+ copiedPhotoKey = await this.copyS3KeyIfPresent(rawSource.photo, `equipment photo "${rawSource.name}"`);
14309
14602
  }
14310
14603
  const equipmentInput = {
14311
- title: rawSource.title,
14604
+ name: rawSource.name,
14605
+ annotation: rawSource.annotation ?? null,
14312
14606
  status: rawSource.status,
14313
- description: rawSource.description,
14314
- archivedForMissionID: rawSource.archivedForMissionID || null,
14607
+ // Full identity of the equipment — all of these were dropped
14608
+ family: rawSource.family ?? null,
14609
+ sku: rawSource.sku ?? null,
14610
+ serialNumber: rawSource.serialNumber ?? null,
14611
+ manufacturer: rawSource.manufacturer ?? null,
14612
+ installDate: rawSource.installDate ?? null,
14613
+ warrantyEndDate: rawSource.warrantyEndDate ?? null,
14614
+ metadata: rawSource.metadata ?? null,
14615
+ isCreatedByAI: rawSource.isCreatedByAI ?? null,
14616
+ // Missions are per-space: a source mission id designates nobody in
14617
+ // the target space. Carrying the list over would look like real data
14618
+ // while hiding the item from no one — reset it, as for features.
14619
+ archivedForMissionID: null,
14315
14620
  spaceID: targetSpaceId,
14316
- domainID: null,
14621
+ // Domains are organisation-scoped, like for features and POIs.
14622
+ domainID: this.sameOrganisation ? (rawSource.domainID || null) : null,
14317
14623
  photo: copiedPhotoKey,
14624
+ // `subequipmentsIDs` is deliberately absent: it is remapped to the
14625
+ // copied equipments in the second pass, once they all exist.
14318
14626
  };
14319
14627
  const createdObj = await this.equipmentService.createEquipment(equipmentInput, null, null);
14320
14628
  if (createdObj && createdObj.id) {
@@ -14324,38 +14632,35 @@ class MatterportTagCopyService {
14324
14632
  if (createdObj && rawSource.notes?.items && rawSource.notes.items.length > 0) {
14325
14633
  for (const sourceNote of rawSource.notes.items) {
14326
14634
  try {
14327
- if (!sourceNote.attachment) {
14328
- continue; // nothing to duplicate for this note
14329
- }
14330
- const copiedKey = await this.copyS3KeyIfPresent(sourceNote.attachment, `equipment note for "${sourceObj?.title}"`);
14331
- await this.equipmentService.createNoteWithExistingAttachment(createdObj.id, copiedKey, sourceNote.type, targetMissionId);
14635
+ // A note without a file is still a note: it carries a type
14636
+ // and its signature/expiry dates. Skipping it, as was done
14637
+ // before, silently dropped those records entirely.
14638
+ const copiedKey = sourceNote.attachment
14639
+ ? await this.copyS3KeyIfPresent(sourceNote.attachment, `equipment note for "${sourceObj?.name}"`)
14640
+ : null;
14641
+ await this.equipmentService.createNoteWithExistingAttachment(createdObj.id, copiedKey, sourceNote.type, targetMissionId, {
14642
+ signDate: sourceNote.signDate ?? null,
14643
+ expirationDate: sourceNote.expirationDate ?? null
14644
+ });
14332
14645
  }
14333
14646
  catch (noteError) {
14334
- console.error(`Error copying note for equipment "${sourceObj?.title}":`, noteError);
14647
+ console.error(`Error copying note for equipment "${sourceObj?.name}":`, noteError);
14335
14648
  errorCount++;
14336
- this.logCopyItemError(noteError, 'errorEquipmentNote', sourceObj?.title || sourceObj?.id);
14649
+ this.logCopyItemError(noteError, 'errorEquipmentNote', sourceObj?.name || sourceObj?.id, {
14650
+ equipment: sourceObj?.name || sourceObj?.id
14651
+ });
14337
14652
  }
14338
14653
  }
14339
14654
  }
14340
- if (sourceObj.pois.items && sourceObj.pois.items.length > 0) {
14341
- const sourcePoi = sourceObj.pois.items[0];
14342
- const targetSweepId = this.findClosestTargetSweepId(sourcePoi.matterportSweepID, sourceNavPositions, targetNavPositions, transform);
14343
- const adjustedCoordinate = this.getAdjustedCoordinates(sourcePoi.coordinate, transform);
14344
- const poiInput = {
14345
- coordinate: JSON.stringify(adjustedCoordinate),
14346
- type: sourcePoi.type,
14347
- elementID: createdObj.id,
14348
- metadata: sourcePoi.metadata,
14349
- matterportSweepID: targetSweepId
14350
- };
14351
- await this.poiService.createPOIForSpaceID(poiInput);
14655
+ for (const sourcePoi of (sourceObj.pois?.items || []).filter(p => !!p)) {
14656
+ await this.createCopiedPoi(sourcePoi, createdObj.id, sourceNavPositions, targetNavPositions, transform);
14352
14657
  }
14353
14658
  }
14354
14659
  }
14355
14660
  catch (itemError) {
14356
- console.error(`Error copying equipment "${sourceObj?.title}" (id: ${sourceObj?.id}):`, itemError);
14661
+ console.error(`Error copying equipment "${sourceObj?.name}" (id: ${sourceObj?.id}):`, itemError);
14357
14662
  errorCount++;
14358
- this.logCopyItemError(itemError, 'errorEquipment', sourceObj?.title || sourceObj?.id);
14663
+ this.logCopyItemError(itemError, 'errorEquipment', sourceObj?.name || sourceObj?.id);
14359
14664
  }
14360
14665
  processedOverall++;
14361
14666
  emitProgress('equipments', i + 1, sourceEquipments.length);
@@ -14378,54 +14683,16 @@ class MatterportTagCopyService {
14378
14683
  await this.equipmentService.updateEquipment({ id: newId, subequipmentsIDs: remapped });
14379
14684
  }
14380
14685
  catch (subEquipError) {
14381
- console.error(`Error remapping sub-equipments for "${sourceObj?.title}":`, subEquipError);
14686
+ console.error(`Error remapping sub-equipments for "${sourceObj?.name}":`, subEquipError);
14382
14687
  errorCount++;
14383
- this.logCopyItemError(subEquipError, 'errorEquipmentSubEquip', sourceObj?.title || sourceObj?.id);
14688
+ this.logCopyItemError(subEquipError, 'errorEquipmentSubEquip', sourceObj?.name || sourceObj?.id, {
14689
+ equipment: sourceObj?.name || sourceObj?.id
14690
+ });
14384
14691
  }
14385
14692
  }
14386
14693
  }
14387
14694
  }
14388
14695
  }
14389
- // POIS (standalone — types already handled above are excluded)
14390
- for (let i = 0; i < sourcePois.length; i++) {
14391
- const sourcePoi = sourcePois[i];
14392
- try {
14393
- const existsInTarget = targetPois.some(tp => tp.coordinate === sourcePoi.coordinate && tp.tagDescription === sourcePoi.tagDescription);
14394
- if (!existsInTarget) {
14395
- const targetSweepId = this.findClosestTargetSweepId(sourcePoi.matterportSweepID, sourceNavPositions, targetNavPositions, transform);
14396
- const adjustedCoordinate = this.getAdjustedCoordinates(sourcePoi.coordinate, transform);
14397
- let elementID = sourcePoi.elementID;
14398
- if (elementID && elementIds[elementID]) {
14399
- elementID = elementIds[elementID];
14400
- }
14401
- const copiedTagIcon = await this.duplicateTagIcon(sourcePoi.tagIcon);
14402
- const copiedTagImageID = await this.duplicateTagImageID(sourcePoi.tagImageID);
14403
- const newPoi = {
14404
- coordinate: JSON.stringify(adjustedCoordinate),
14405
- type: sourcePoi.type,
14406
- elementID: elementID,
14407
- tagDescription: sourcePoi.tagDescription,
14408
- tagIcon: copiedTagIcon,
14409
- tagImageID: copiedTagImageID,
14410
- virtual: sourcePoi.virtual,
14411
- xRotation: sourcePoi.xRotation,
14412
- yRotation: sourcePoi.yRotation,
14413
- metadata: sourcePoi.metadata,
14414
- matterportSweepID: targetSweepId
14415
- };
14416
- await this.poiService.createPOIForSpaceID(newPoi);
14417
- }
14418
- }
14419
- catch (itemError) {
14420
- console.error(`Error copying POI (sweep: ${sourcePoi?.matterportSweepID}):`, itemError);
14421
- errorCount++;
14422
- this.logCopyItemError(itemError, 'errorPoi', sourcePoi?.matterportSweepID);
14423
- }
14424
- processedOverall++;
14425
- emitProgress('pois', i + 1, sourcePois.length);
14426
- const pct = sourcePois.length > 0 ? Math.round(((i + 1) / sourcePois.length) * 100) : 100;
14427
- this.setStepProgress('pois', 'pois', pct);
14428
- }
14429
14696
  // OBJECTS 3D (the 3D model file itself — `path` — is physically duplicated in S3)
14430
14697
  for (let i = 0; i < sourceObjects3D.length; i++) {
14431
14698
  const sourceObj = sourceObjects3D[i];
@@ -14433,8 +14700,16 @@ class MatterportTagCopyService {
14433
14700
  const existsInTarget = targetObjects3D.some(targetObj => targetObj.name === sourceObj.name);
14434
14701
  if (!existsInTarget) {
14435
14702
  const rawSource = sourceObj;
14703
+ // `path` means two different things depending on `s3Bucket`, exactly as the
14704
+ // viewer reads it (see viewer.component.ts and matterport-object3d.service.ts):
14705
+ // - s3Bucket === true -> a key of a model file uploaded to S3, ours to duplicate;
14706
+ // - otherwise -> a reference into the 3D object library shipped with
14707
+ // the application ("3DObjects/objects/cube.dae" -> assets/3Dobjects/...),
14708
+ // which exists in every space and must be reused verbatim.
14709
+ // Copying unconditionally meant every library object failed with NoSuchKey and
14710
+ // the whole 3D object was skipped, model AND POI.
14436
14711
  let copiedPath = rawSource.path;
14437
- if (rawSource.path) {
14712
+ if (rawSource.path && rawSource.s3Bucket) {
14438
14713
  copiedPath = await this.copyS3KeyIfPresent(rawSource.path, `3D object "${rawSource.name}"`);
14439
14714
  }
14440
14715
  /**
@@ -14464,39 +14739,65 @@ class MatterportTagCopyService {
14464
14739
  // Ensure we return valid numbers
14465
14740
  return array.map(n => isNaN(n) ? 0 : n);
14466
14741
  };
14742
+ // This service is shared, but the two applications do NOT deploy the same
14743
+ // Object3D schema: `viewFrustum` exists only in smarterplan, and `scale`
14744
+ // is [Float] there but a plain Float in museum. Sending the smarterplan
14745
+ // shape to museum was rejected with "The variables input contains a field
14746
+ // that is not defined for input object type 'CreateObject3DInput'".
14747
+ //
14748
+ // The input therefore MIRRORS the source record instead of imposing a
14749
+ // shape: source and target live in the same deployment, so whatever the
14750
+ // source carries is exactly what the target accepts.
14467
14751
  const object3DInput = {
14468
14752
  path: copiedPath,
14753
+ // Tells the viewer how to interpret `path`. Dropping it turned an
14754
+ // S3-hosted model into a library reference on the copy, which the
14755
+ // viewer then tried to resolve against assets/ and failed to load.
14756
+ s3Bucket: !!rawSource.s3Bucket,
14469
14757
  name: rawSource.name,
14470
14758
  description: rawSource.description || "",
14471
- viewFrustum: !!rawSource.viewFrustum,
14472
14759
  spaceID: targetSpaceId,
14473
14760
  position: toFloatArray(rawSource.position, [0, 0, 0]),
14474
- rotation: toFloatArray(rawSource.rotation, [0, 0, 0]),
14475
- // Enforce scale to be at least 1.0 if it's invalid
14476
- scale: toFloatArray(rawSource.scale, [1, 1, 1])
14761
+ rotation: toFloatArray(rawSource.rotation, [0, 0, 0])
14477
14762
  };
14478
- // Debugging: Inspect the exact object sent to the mutation
14479
- console.log(`[DEBUG] Sending Object3D "${rawSource.name}" to API:`, JSON.stringify(object3DInput));
14763
+ if ('viewFrustum' in rawSource) {
14764
+ object3DInput.viewFrustum = !!rawSource.viewFrustum;
14765
+ }
14766
+ // Scale keeps the source's own shape: a list stays a list, a scalar stays
14767
+ // a scalar. An absent scale is left unset rather than invented, so the copy
14768
+ // renders exactly like the original.
14769
+ if (Array.isArray(rawSource.scale)) {
14770
+ object3DInput.scale = toFloatArray(rawSource.scale, [1, 1, 1]);
14771
+ }
14772
+ else if (rawSource.scale !== null && rawSource.scale !== undefined) {
14773
+ const scalarScale = parseFloat(rawSource.scale);
14774
+ object3DInput.scale = Number.isNaN(scalarScale) ? 1 : scalarScale;
14775
+ }
14480
14776
  const createdObj = await this.object3DService.create(object3DInput);
14481
14777
  if (createdObj && createdObj.id) {
14482
14778
  elementIds[sourceObj.id] = createdObj.id;
14483
14779
  }
14484
- if (sourceObj.pois?.items && sourceObj.pois.items.length > 0) {
14485
- const sourcePoi = sourceObj.pois.items[0];
14486
- const targetSweepId = this.findClosestTargetSweepId(sourcePoi.matterportSweepID, sourceNavPositions, targetNavPositions, transform);
14487
- const adjustedCoordinate = this.getAdjustedCoordinates(sourcePoi.coordinate, transform);
14488
- const poiInput = {
14489
- coordinate: JSON.stringify(adjustedCoordinate),
14490
- type: sourcePoi.type,
14491
- elementID: createdObj.id,
14492
- metadata: sourcePoi.metadata,
14493
- matterportSweepID: targetSweepId
14494
- };
14495
- await this.poiService.createPOIForSpaceID(poiInput);
14780
+ for (const sourcePoi of (sourceObj.pois?.items || []).filter(p => !!p)) {
14781
+ await this.createCopiedPoi(sourcePoi, createdObj.id, sourceNavPositions, targetNavPositions, transform);
14496
14782
  }
14497
14783
  }
14498
14784
  }
14499
14785
  catch (itemError) {
14786
+ // The backend can deny creation of this whole model to the signed-in account
14787
+ // (in MUSEUM, Object3D only grants get/list to userPools). The denial is not
14788
+ // per-item, so every remaining object would fail identically: report it once,
14789
+ // as a permission problem rather than a copy error, and stop the phase instead
14790
+ // of firing one doomed mutation per object.
14791
+ if (this.isUnauthorized(itemError)) {
14792
+ const remaining = sourceObjects3D.length - i;
14793
+ console.warn(`[MatterportTagCopyService] Not allowed to create 3D objects in the target space, skipping ${remaining} of them.`, itemError);
14794
+ errorCount++;
14795
+ this.logItemError('errorObjects3dNotAllowed', { count: remaining });
14796
+ processedOverall += remaining;
14797
+ emitProgress('objects3d', sourceObjects3D.length, sourceObjects3D.length);
14798
+ this.setStepProgress('objects3d', 'objects3d', 100);
14799
+ break;
14800
+ }
14500
14801
  console.error(`Error copying object3D "${sourceObj?.name}":`, itemError);
14501
14802
  errorCount++;
14502
14803
  this.logCopyItemError(itemError, 'errorObject3d', sourceObj?.name || sourceObj?.id);
@@ -14506,76 +14807,86 @@ class MatterportTagCopyService {
14506
14807
  const pct = sourceObjects3D.length > 0 ? Math.round(((i + 1) / sourceObjects3D.length) * 100) : 100;
14507
14808
  this.setStepProgress('objects3d', 'objects3d', pct);
14508
14809
  }
14509
- // FEATURES (title, POI, and attached comments — images/videos/docs/links — all copied,
14510
- // each file physically duplicated in S3 first)
14810
+ // FEATURES — the "POI" of the MUSEUM module: its own record (title, index, locale,
14811
+ // keywords), its media set (one Comment per image/video/audio/document/link/text)
14812
+ // and its POI record (the pastille: icon, tag description, cover image).
14813
+ // Everything is copied here, files physically duplicated in S3 first.
14511
14814
  for (let i = 0; i < sourceFeatures.length; i++) {
14512
14815
  const sourceObj = sourceFeatures[i];
14513
14816
  try {
14514
- const existsInTarget = this.matchesExisting(sourceObj, targetFeatures);
14817
+ const existsInTarget = this.matchesExisting(sourceObj, targetFeatures, (f) => [f.title, f.description]);
14515
14818
  if (!existsInTarget) {
14516
14819
  const rawSource = sourceObj;
14820
+ // Resolved just before the write: a keyword is only created in the target
14821
+ // space when the POI carrying it is about to be created.
14822
+ const resolvedHashtags = await this.resolveHashtagIds(rawSource.hashtags, targetSpaceId);
14517
14823
  const featureInput = {
14518
14824
  title: rawSource.title,
14519
14825
  shortDescription: rawSource.shortDescription,
14520
14826
  level: rawSource.level,
14521
14827
  type: rawSource.type,
14522
- archivedForMissionID: rawSource.archivedForMissionID || null,
14828
+ // `index` drives the ordering of the POI in the space's list, in the
14829
+ // lateral menu and in the guided tour. Dropping it forced a full manual
14830
+ // renumbering of every copied POI.
14831
+ index: rawSource.index ?? null,
14832
+ // Missions are per-space: source mission ids mean nothing in the target
14833
+ // space, so an archive list carried over would hide POIs from nobody
14834
+ // while looking like real data. Reset instead.
14835
+ archivedForMissionID: null,
14523
14836
  stripeProductID: rawSource.stripeProductID || null,
14524
14837
  stripePriceID: rawSource.stripePriceID || null,
14525
14838
  bookingPriceMetadata: rawSource.bookingPriceMetadata || null,
14526
14839
  locale: rawSource.locale || null,
14527
- hashtags: rawSource.hashtags || null,
14840
+ // Hashtags are space-scoped records: the ids must be translated to the
14841
+ // equivalent hashtags of the target space (created on demand).
14842
+ hashtags: resolvedHashtags,
14528
14843
  description: rawSource.description || null,
14529
- domainID: null,
14844
+ // Domains are organisation-scoped, so the category survives a copy
14845
+ // inside one organisation and only has to be dropped across two.
14846
+ domainID: this.sameOrganisation ? (rawSource.domainID || null) : null,
14530
14847
  spaceID: targetSpaceId,
14531
14848
  ownerMissionID: targetMissionId
14532
14849
  };
14533
14850
  const createdObj = await this.featureService.createFeature(featureInput);
14534
14851
  if (createdObj && createdObj.id) {
14535
14852
  elementIds[sourceObj.id] = createdObj.id;
14853
+ // The feature exists: its keywords are now genuinely referenced and
14854
+ // must survive the end-of-run cleanup.
14855
+ this.markHashtagsUsed(resolvedHashtags);
14536
14856
  }
14537
- if (createdObj && sourceObj.comments?.items && sourceObj.comments.items.length > 0) {
14538
- for (const sourceComment of sourceObj.comments.items) {
14857
+ if (createdObj) {
14858
+ // Read the media set through its own paginated query: the `comments`
14859
+ // connection embedded in FeaturesBySpace is capped at 100 items and its
14860
+ // nextToken was never followed, so rich POIs lost attachments silently.
14861
+ let sourceComments = [];
14862
+ try {
14863
+ sourceComments = await this.commentService.getAllCommentsForFeature(sourceObj.id);
14864
+ }
14865
+ catch (commentsFetchError) {
14866
+ console.warn(`Falling back to the embedded comment list for feature "${rawSource.title}":`, commentsFetchError);
14867
+ sourceComments = (sourceObj.comments?.items || []).filter(c => !!c);
14868
+ }
14869
+ for (const sourceComment of this.orderCommentsForCopy(sourceComments)) {
14539
14870
  try {
14540
- const rawComment = sourceComment;
14541
- let copiedAnnexe = null;
14542
- if (rawComment.annexe) {
14543
- copiedAnnexe = await this.copyS3KeyIfPresent(rawComment.annexe, `feature attachment "${rawComment.title}"`);
14871
+ const created = await this.copyCommentForFeature(sourceComment, createdObj.id, targetSpaceId, targetMissionId);
14872
+ if (created?.id && sourceComment.id) {
14873
+ this.commentIds.set(sourceComment.id, created.id);
14544
14874
  }
14545
- const commentInput = {
14546
- title: rawComment.title,
14547
- type: rawComment.type,
14548
- annexe: copiedAnnexe,
14549
- externalLink: rawComment.externalLink || null,
14550
- dimensions: rawComment.dimensions || null,
14551
- index: rawComment.index,
14552
- shownInTag: rawComment.shownInTag,
14553
- isCoverImage: rawComment.isCoverImage,
14554
- featureID: createdObj.id,
14555
- spaceID: targetSpaceId,
14556
- ownerID: targetMissionId
14557
- };
14558
- await this.commentService.createComment(commentInput);
14559
14875
  }
14560
14876
  catch (attachmentError) {
14561
14877
  console.error(`Error copying attachment "${sourceComment?.title}" for feature "${sourceObj?.title}":`, attachmentError);
14562
14878
  errorCount++;
14563
- this.logCopyItemError(attachmentError, 'errorFeatureAttachment', sourceComment?.title || sourceComment?.id);
14879
+ this.logCopyItemError(attachmentError, 'errorFeatureAttachment', sourceComment?.title || sourceComment?.id, {
14880
+ feature: rawSource.title || sourceObj.id
14881
+ });
14564
14882
  }
14565
14883
  }
14566
- }
14567
- if (sourceObj.pois?.items && sourceObj.pois.items.length > 0) {
14568
- const sourcePoi = sourceObj.pois.items[0];
14569
- const targetSweepId = this.findClosestTargetSweepId(sourcePoi.matterportSweepID, sourceNavPositions, targetNavPositions, transform);
14570
- const adjustedCoordinate = this.getAdjustedCoordinates(sourcePoi.coordinate, transform);
14571
- const poiInput = {
14572
- coordinate: JSON.stringify(adjustedCoordinate),
14573
- type: sourcePoi.type,
14574
- elementID: createdObj.id,
14575
- metadata: sourcePoi.metadata,
14576
- matterportSweepID: targetSweepId
14577
- };
14578
- await this.poiService.createPOIForSpaceID(poiInput);
14884
+ // Every POI of the feature, not only the first one: a feature may be
14885
+ // pinned at several places, and only this phase knows the new element id.
14886
+ const sourceFeaturePois = (sourceObj.pois?.items || []).filter(p => !!p);
14887
+ for (const sourcePoi of sourceFeaturePois) {
14888
+ await this.createCopiedPoi(sourcePoi, createdObj.id, sourceNavPositions, targetNavPositions, transform);
14889
+ }
14579
14890
  }
14580
14891
  }
14581
14892
  }
@@ -14604,12 +14915,17 @@ class MatterportTagCopyService {
14604
14915
  }
14605
14916
  const commentInput = {
14606
14917
  title: rawComment.title,
14918
+ description: rawComment.description ?? null,
14919
+ metadata: rawComment.metadata ?? null,
14607
14920
  type: CommentType.MEASURE,
14608
14921
  dimensions: rawComment.dimensions || null,
14609
14922
  annexe: copiedAnnexe,
14610
14923
  spaceID: targetSpaceId,
14611
14924
  };
14612
14925
  const createdComment = await this.commentService.createComment(commentInput);
14926
+ if (createdComment && createdComment.id && rawComment.id) {
14927
+ this.commentIds.set(rawComment.id, createdComment.id);
14928
+ }
14613
14929
  if (createdComment && sourceMeasurement.poi) {
14614
14930
  const targetSweepId = this.findClosestTargetSweepId(sourceMeasurement.poi.matterportSweepID, sourceNavPositions, targetNavPositions, transform);
14615
14931
  const adjustedPosition = this.getAdjustedPositionOnly(sourceMeasurement.poi.coordinate, transform);
@@ -14617,7 +14933,9 @@ class MatterportTagCopyService {
14617
14933
  coordinate: JSON.stringify(adjustedPosition),
14618
14934
  type: PoiType.MEASURE,
14619
14935
  elementID: createdComment.id,
14620
- metadata: sourceMeasurement.poi.metadata,
14936
+ // Same treatment as any other POI: the source's Matterport tag
14937
+ // SID must not travel, and the stem normal has to be rotated.
14938
+ metadata: this.getAdjustedPoiMetadata(sourceMeasurement.poi.metadata, transform),
14621
14939
  matterportSweepID: targetSweepId
14622
14940
  };
14623
14941
  await this.poiService.createPOIForSpaceID(poiInput);
@@ -14635,6 +14953,36 @@ class MatterportTagCopyService {
14635
14953
  this.setStepProgress('measurements', 'measurements', pct);
14636
14954
  }
14637
14955
  }
14956
+ // POIS — standalone ones only: every POI owned by an element was already created by
14957
+ // that element's phase, with the element's NEW id.
14958
+ //
14959
+ // This phase runs LAST on purpose. It used to run third, before 3D objects, features
14960
+ // and measurements existed in the target space, so `elementIds` and `commentIds` were
14961
+ // still empty when it needed them: elementID stayed pointing at the source space and
14962
+ // tagImageID (a reference to a Comment record) could never be resolved.
14963
+ for (let i = 0; i < sourcePois.length; i++) {
14964
+ const sourcePoi = sourcePois[i];
14965
+ try {
14966
+ const existsInTarget = targetPois.some(tp => tp.coordinate === sourcePoi.coordinate && tp.tagDescription === sourcePoi.tagDescription);
14967
+ if (!existsInTarget) {
14968
+ const elementID = sourcePoi.elementID
14969
+ ? (elementIds[sourcePoi.elementID] || sourcePoi.elementID)
14970
+ : sourcePoi.elementID;
14971
+ await this.createCopiedPoi(sourcePoi, elementID, sourceNavPositions, targetNavPositions, transform);
14972
+ }
14973
+ }
14974
+ catch (itemError) {
14975
+ console.error(`Error copying POI (sweep: ${sourcePoi?.matterportSweepID}):`, itemError);
14976
+ errorCount++;
14977
+ this.logCopyItemError(itemError, 'errorPoi', sourcePoi?.matterportSweepID, {
14978
+ sweep: sourcePoi?.matterportSweepID
14979
+ });
14980
+ }
14981
+ processedOverall++;
14982
+ emitProgress('pois', i + 1, sourcePois.length);
14983
+ const pct = sourcePois.length > 0 ? Math.round(((i + 1) / sourcePois.length) * 100) : 100;
14984
+ this.setStepProgress('pois', 'pois', pct);
14985
+ }
14638
14986
  this.copyProgress$.next({
14639
14987
  phase: 'done',
14640
14988
  processedInPhase: 0, totalInPhase: 0,
@@ -14652,6 +15000,12 @@ class MatterportTagCopyService {
14652
15000
  this.copyProgress$.next(null);
14653
15001
  return false;
14654
15002
  }
15003
+ finally {
15004
+ // Every exit path — success, partial success, or general failure — gives back the
15005
+ // keywords this run created but no copied POI ended up using. Placed in `finally`
15006
+ // so an exception thrown mid-copy cannot leave them behind either.
15007
+ await this.cleanupUnusedCreatedHashtags();
15008
+ }
14655
15009
  }
14656
15010
  //
14657
15011
  // ---------- Progress / log helpers ----------
@@ -14680,6 +15034,20 @@ class MatterportTagCopyService {
14680
15034
  this.errorSeq = 0;
14681
15035
  this.copyLog$.next([]);
14682
15036
  }
15037
+ /** Clears every id map carried over from a previous run. The service is a root-level
15038
+ * singleton, so leftover mappings would silently link a new copy to records created
15039
+ * during the previous one. */
15040
+ resetRunState(targetSpaceId) {
15041
+ this.elementIds = {};
15042
+ this.commentIds = new Map();
15043
+ this.sourceHashtagNames = new Map();
15044
+ this.targetHashtagIdsByName = new Map();
15045
+ this.createdHashtagIds = new Set();
15046
+ this.usedHashtagIds = new Set();
15047
+ this.handledElementIds = new Set();
15048
+ this.sameOrganisation = false;
15049
+ this.targetSpaceId = targetSpaceId;
15050
+ }
14683
15051
  /** Builds and sets a step's log line as "<translated label>: <percent>%", no step numbering. */
14684
15052
  setStepProgress(stepId, translationKey, percent) {
14685
15053
  const label = this.translate.instant(`matterportCopy.${translationKey}`);
@@ -14693,28 +15061,408 @@ class MatterportTagCopyService {
14693
15061
  /** Logs an item-level error, using a specific "missing file / orphaned reference"
14694
15062
  * translation key when the cause is a MissingS3FileError, and the generic per-type
14695
15063
  * key otherwise. Keeps the copy log readable: a missing file is not a bug to
14696
- * investigate the same way a network/permission error is. */
14697
- logCopyItemError(error, genericKey, itemLabel) {
15064
+ * investigate the same way a network/permission error is.
15065
+ * `extraParams` carries the placeholders a few keys need on top of {{name}}/{{error}}
15066
+ * (e.g. errorFeatureAttachment's {{feature}}, errorPoi's {{sweep}}), which used to be
15067
+ * rendered as raw "{{feature}}" text in the log. */
15068
+ logCopyItemError(error, genericKey, itemLabel, extraParams = {}) {
14698
15069
  if (error instanceof MissingS3FileError) {
14699
15070
  this.logItemError('errorMissingS3FileSkipped', {
15071
+ ...extraParams,
14700
15072
  name: itemLabel,
14701
15073
  file: error.sourceKey
14702
15074
  });
14703
15075
  }
14704
15076
  else {
14705
- this.logItemError(genericKey, { name: itemLabel, error: toErrorMessage(error) });
15077
+ this.logItemError(genericKey, { ...extraParams, name: itemLabel, error: toErrorMessage(error) });
15078
+ }
15079
+ }
15080
+ /**
15081
+ * Null-safe equivalent of FeatureService.isArchived(): true when the operator has archived
15082
+ * this feature on that space. "No mission on the space" answers false (nothing archived)
15083
+ * instead of throwing, which is what made a copy from a space the operator is not a member
15084
+ * of fail outright.
15085
+ */
15086
+ isArchivedForOperator(feature, spaceId) {
15087
+ const archivedFor = feature?.archivedForMissionID;
15088
+ if (!archivedFor || archivedFor.length === 0) {
15089
+ return false;
15090
+ }
15091
+ const mission = this.userService.currentMission(spaceId);
15092
+ return mission?.id ? archivedFor.includes(mission.id) : false;
15093
+ }
15094
+ //
15095
+ // ---------- Cross-space reference remapping ----------
15096
+ //
15097
+ /**
15098
+ * True when both spaces belong to the same organisation.
15099
+ * Domains (POI/Feature categories) are organisation-scoped records with no spaceID, so
15100
+ * a domainID stays meaningful inside one organisation and only becomes a dangling
15101
+ * reference across two. A failed lookup answers "no": dropping a category is recoverable,
15102
+ * pointing at another organisation's domain is not.
15103
+ */
15104
+ async spacesShareOrganisation(sourceSpaceId, targetSpaceId) {
15105
+ try {
15106
+ const [sourceSpace, targetSpace] = await Promise.all([
15107
+ this.spaceService.getSpace(sourceSpaceId),
15108
+ this.spaceService.getSpace(targetSpaceId)
15109
+ ]);
15110
+ const sourceOrg = sourceSpace?.organisationID;
15111
+ const targetOrg = targetSpace?.organisationID;
15112
+ return !!sourceOrg && !!targetOrg && sourceOrg === targetOrg;
15113
+ }
15114
+ catch (error) {
15115
+ console.warn('[MatterportTagCopyService] Could not compare the spaces\' organisations, categories will be dropped:', error);
15116
+ return false;
15117
+ }
15118
+ }
15119
+ /** Normalised lookup key for a hashtag name, so "Moyen Âge" and "moyen âge " match. */
15120
+ hashtagKey(name) {
15121
+ return name.trim().toLowerCase();
15122
+ }
15123
+ /**
15124
+ * READ-ONLY indexing of both spaces' hashtags. Creates nothing.
15125
+ *
15126
+ * `Feature.hashtags` stores Hashtag *ids*, and a Hashtag is a per-space record, so the
15127
+ * ids have to be translated by name. The translation table is built here; the missing
15128
+ * keywords are created later, one at a time, only when a POI that actually uses them is
15129
+ * about to be written (see resolveHashtagIds()).
15130
+ */
15131
+ async loadHashtagIndex(sourceSpaceId, targetSpaceId) {
15132
+ let sourceHashtags = [];
15133
+ let targetHashtags = [];
15134
+ try {
15135
+ [sourceHashtags, targetHashtags] = await Promise.all([
15136
+ this.hashtagService.listHashtagsForSpace(sourceSpaceId),
15137
+ this.hashtagService.listHashtagsForSpace(targetSpaceId)
15138
+ ]);
15139
+ }
15140
+ catch (error) {
15141
+ console.warn('[MatterportTagCopyService] Could not read the hashtags, keywords will be dropped:', error);
15142
+ return;
15143
+ }
15144
+ for (const hashtag of sourceHashtags || []) {
15145
+ if (hashtag?.id && hashtag.name) {
15146
+ this.sourceHashtagNames.set(hashtag.id, hashtag.name);
15147
+ }
15148
+ }
15149
+ for (const hashtag of targetHashtags || []) {
15150
+ if (hashtag?.id && hashtag.name) {
15151
+ this.targetHashtagIdsByName.set(this.hashtagKey(hashtag.name), hashtag.id);
15152
+ }
15153
+ }
15154
+ }
15155
+ /**
15156
+ * Translates one feature's hashtag ids into target-space ids, creating on the fly the
15157
+ * keywords the target space does not have yet.
15158
+ *
15159
+ * Creation is LAZY on purpose. Doing it up front, for the whole source vocabulary, meant
15160
+ * that a copy which failed or was closed halfway left behind keywords no POI referenced —
15161
+ * pollution the operator had to clean up by hand. Here a keyword is only ever written
15162
+ * when a POI that carries it is about to be created, so an interrupted copy stops
15163
+ * creating keywords at the same POI it stops creating content.
15164
+ *
15165
+ * The residual window (keyword created, then createFeature fails) is closed by
15166
+ * markHashtagsUsed() + cleanupUnusedCreatedHashtags().
15167
+ *
15168
+ * A keyword that can neither be found nor created is dropped rather than left as a
15169
+ * dangling id.
15170
+ */
15171
+ async resolveHashtagIds(sourceHashtagIds, targetSpaceId) {
15172
+ if (!sourceHashtagIds || sourceHashtagIds.length === 0) {
15173
+ return null;
15174
+ }
15175
+ const resolved = [];
15176
+ for (const sourceId of sourceHashtagIds) {
15177
+ if (!sourceId) {
15178
+ continue;
15179
+ }
15180
+ const name = this.sourceHashtagNames.get(sourceId);
15181
+ if (!name) {
15182
+ // the source id points at a hashtag that no longer exists — nothing to carry over
15183
+ continue;
15184
+ }
15185
+ const key = this.hashtagKey(name);
15186
+ let targetId = this.targetHashtagIdsByName.get(key);
15187
+ if (!targetId) {
15188
+ try {
15189
+ const created = await this.hashtagService.createHashtagForSpace(name, targetSpaceId);
15190
+ targetId = created?.id;
15191
+ if (targetId) {
15192
+ this.targetHashtagIdsByName.set(key, targetId);
15193
+ this.createdHashtagIds.add(targetId);
15194
+ }
15195
+ }
15196
+ catch (error) {
15197
+ console.warn(`[MatterportTagCopyService] Could not create the hashtag "${name}" in the target space:`, error);
15198
+ continue;
15199
+ }
15200
+ }
15201
+ if (targetId && !resolved.includes(targetId)) {
15202
+ resolved.push(targetId);
15203
+ }
15204
+ }
15205
+ return resolved.length > 0 ? resolved : null;
15206
+ }
15207
+ /** Records that these hashtags are now referenced by a feature that really exists, so
15208
+ * the end-of-run cleanup leaves them alone. */
15209
+ markHashtagsUsed(hashtagIds) {
15210
+ for (const id of hashtagIds || []) {
15211
+ this.usedHashtagIds.add(id);
15212
+ }
15213
+ }
15214
+ /**
15215
+ * Deletes the hashtags this run created that no copied POI ended up referencing.
15216
+ *
15217
+ * Runs on every exit path — success, general failure, or exception — so the target space
15218
+ * never keeps keywords that came from an aborted copy. Only ids created by THIS run are
15219
+ * ever considered: a pre-existing keyword of the target space is never touched, even if
15220
+ * no copied POI uses it.
15221
+ */
15222
+ async cleanupUnusedCreatedHashtags() {
15223
+ const orphans = [...this.createdHashtagIds].filter(id => !this.usedHashtagIds.has(id));
15224
+ if (orphans.length === 0) {
15225
+ return;
15226
+ }
15227
+ console.log(`[MatterportTagCopyService] Removing ${orphans.length} keyword(s) created but left unused by this copy.`);
15228
+ for (const hashtagId of orphans) {
15229
+ try {
15230
+ await this.hashtagService.deleteHashtagById(hashtagId);
15231
+ this.createdHashtagIds.delete(hashtagId);
15232
+ }
15233
+ catch (error) {
15234
+ // Cleanup is best-effort: a leftover keyword is harmless next to a failed copy,
15235
+ // and must never be the reason the whole operation reports a failure.
15236
+ console.warn(`[MatterportTagCopyService] Could not remove the unused keyword "${hashtagId}":`, error);
15237
+ }
15238
+ }
15239
+ }
15240
+ //
15241
+ // ---------- Media content (Comments) ----------
15242
+ //
15243
+ /**
15244
+ * Orders a feature's media set exactly the way the UI will read it back, and repairs
15245
+ * the ordering key when the source data cannot express the user's intended order.
15246
+ *
15247
+ * The viewer sorts each media family on its own (`setImages`, `setVideos`, `setAudio`,
15248
+ * `setDocuments`, ... all do `.sort((a, b) => a.index - b.index)`), so `index` only has
15249
+ * to be consistent WITHIN a CommentType. The attachment form however defaults `index`
15250
+ * to 1 for every new attachment, so a multi-image POI very often holds several images
15251
+ * all sharing index 1 — or null. Sorting those is left to DynamoDB's arbitrary scan
15252
+ * order, which is why the copy came out in a different order than the original.
15253
+ *
15254
+ * Strategy: sort deterministically (index, then creation date, then id — i.e. the
15255
+ * upload order the user actually saw), and only rewrite the indices of a family when
15256
+ * they are ambiguous (null or duplicated). A family whose indices are already unique
15257
+ * is copied untouched, so a deliberate ordering is never overwritten.
15258
+ */
15259
+ orderCommentsForCopy(comments) {
15260
+ const valid = (comments || []).filter(c => !!c);
15261
+ const byType = new Map();
15262
+ for (const comment of valid) {
15263
+ const key = comment.type || 'UNTYPED';
15264
+ if (!byType.has(key)) {
15265
+ byType.set(key, []);
15266
+ }
15267
+ byType.get(key).push(comment);
15268
+ }
15269
+ const ordered = [];
15270
+ for (const family of byType.values()) {
15271
+ family.sort((a, b) => {
15272
+ const ai = this.orderingIndexOf(a);
15273
+ const bi = this.orderingIndexOf(b);
15274
+ if (ai !== bi) {
15275
+ return ai - bi;
15276
+ }
15277
+ const ad = this.creationTimeOf(a);
15278
+ const bd = this.creationTimeOf(b);
15279
+ if (ad !== bd) {
15280
+ return ad - bd;
15281
+ }
15282
+ return String(a.id || '').localeCompare(String(b.id || ''));
15283
+ });
15284
+ const indices = family.map(c => c.index);
15285
+ const isAmbiguous = indices.some(i => i === null || i === undefined)
15286
+ || new Set(indices).size !== indices.length;
15287
+ family.forEach((comment, position) => {
15288
+ comment.__copyIndex = isAmbiguous ? position + 1 : comment.index;
15289
+ });
15290
+ ordered.push(...family);
15291
+ }
15292
+ return ordered;
15293
+ }
15294
+ /** Sort key for a comment's position; a missing index sorts last, not first. */
15295
+ orderingIndexOf(comment) {
15296
+ const index = comment?.index;
15297
+ return index === null || index === undefined || Number.isNaN(Number(index))
15298
+ ? Number.MAX_SAFE_INTEGER
15299
+ : Number(index);
15300
+ }
15301
+ /** Creation timestamp of a comment, used as the tie-breaker: it reflects the order in
15302
+ * which the user uploaded the media, which is the order they saw. */
15303
+ creationTimeOf(comment) {
15304
+ const createdAt = comment?.createdAt;
15305
+ if (!createdAt) {
15306
+ return 0;
14706
15307
  }
15308
+ const time = new Date(createdAt).getTime();
15309
+ return Number.isNaN(time) ? 0 : time;
15310
+ }
15311
+ /**
15312
+ * Copies ONE media item (image, video, audio, document, text, link, embed, iframe...)
15313
+ * of a feature, duplicating its file in S3 first when it has one.
15314
+ *
15315
+ * Every column of the Comment model is carried over. `description` and `metadata` were
15316
+ * previously dropped, which emptied the captions of images/videos, the body of TEXT
15317
+ * items — where the whole content lives, since a TEXT comment has no file — and any
15318
+ * per-media settings stored in metadata.
15319
+ */
15320
+ async copyCommentForFeature(sourceComment, targetFeatureId, targetSpaceId, targetMissionId) {
15321
+ return this.copyComment(sourceComment, targetSpaceId, targetMissionId, { featureID: targetFeatureId });
15322
+ }
15323
+ /** Same, for an attachment carried by a ticket rather than by a feature. */
15324
+ async copyCommentForTicket(sourceComment, targetTicketId, targetSpaceId, targetMissionId) {
15325
+ return this.copyComment(sourceComment, targetSpaceId, targetMissionId, { ticketID: targetTicketId });
15326
+ }
15327
+ /** Shared body of the two above: `owner` carries whichever foreign key links the copy back
15328
+ * to its new parent, everything else is identical. */
15329
+ async copyComment(sourceComment, targetSpaceId, targetMissionId, owner) {
15330
+ const rawComment = sourceComment;
15331
+ let copiedAnnexe = null;
15332
+ if (rawComment.annexe) {
15333
+ copiedAnnexe = await this.copyS3KeyIfPresent(rawComment.annexe, `attachment "${rawComment.title}"`);
15334
+ }
15335
+ const commentInput = {
15336
+ title: rawComment.title,
15337
+ description: rawComment.description ?? null,
15338
+ metadata: rawComment.metadata ?? null,
15339
+ type: rawComment.type,
15340
+ annexe: copiedAnnexe,
15341
+ externalLink: rawComment.externalLink || null,
15342
+ dimensions: rawComment.dimensions || null,
15343
+ index: rawComment.__copyIndex ?? rawComment.index ?? null,
15344
+ shownInTag: rawComment.shownInTag,
15345
+ isCoverImage: rawComment.isCoverImage,
15346
+ spaceID: targetSpaceId,
15347
+ ownerID: targetMissionId,
15348
+ ...owner
15349
+ };
15350
+ return this.commentService.createComment(commentInput);
15351
+ }
15352
+ //
15353
+ // ---------- POI creation ----------
15354
+ //
15355
+ /**
15356
+ * Creates the target-space copy of a source POI, with EVERY column of the POI model.
15357
+ *
15358
+ * Single entry point for all phases: the element phases (ticket, equipment, 3D object,
15359
+ * feature, measurement) used to build their POI inline with only 5 fields — coordinate,
15360
+ * type, elementID, metadata, sweep — which is precisely how museum POIs lost their
15361
+ * pastille: `tagDescription` (the popup text), `tagIcon` (the custom icon) and
15362
+ * `tagImageID` (the pastille's image) never left the source space.
15363
+ */
15364
+ async createCopiedPoi(sourcePoi, elementID, sourceNavPositions, targetNavPositions, transform) {
15365
+ const raw = sourcePoi;
15366
+ const targetSweepId = this.findClosestTargetSweepId(sourcePoi.matterportSweepID, sourceNavPositions, targetNavPositions, transform);
15367
+ const adjustedCoordinate = this.getAdjustedCoordinates(sourcePoi.coordinate, transform);
15368
+ const poiInput = {
15369
+ coordinate: adjustedCoordinate === null || adjustedCoordinate === undefined
15370
+ ? null
15371
+ : JSON.stringify(adjustedCoordinate),
15372
+ type: sourcePoi.type,
15373
+ elementID: elementID ?? null,
15374
+ matterportSweepID: targetSweepId,
15375
+ // --- pastille (tag popup) ---
15376
+ tagDescription: sourcePoi.tagDescription ?? null,
15377
+ tagIcon: await this.duplicateTagIcon(sourcePoi.tagIcon),
15378
+ tagImageID: this.resolveTagImageID(sourcePoi.tagImageID),
15379
+ // --- rendering / visibility flags, carried over verbatim ---
15380
+ specific: raw.specific ?? null,
15381
+ private: raw.private ?? null,
15382
+ global: raw.global ?? null,
15383
+ level: raw.level ?? null,
15384
+ role: raw.role ?? null,
15385
+ category: raw.category ?? null,
15386
+ domainID: this.sameOrganisation ? (raw.domainID || null) : null,
15387
+ // --- Matterport anchoring ---
15388
+ metadata: this.getAdjustedPoiMetadata(sourcePoi.metadata, transform)
15389
+ };
15390
+ return this.poiService.createPOIForSpaceID(poiInput);
15391
+ }
15392
+ /**
15393
+ * Adapts a POI's `metadata` (see MattertagData.getMetadataString(): { icon, normal, sid })
15394
+ * to the target space.
15395
+ *
15396
+ * - `sid` is the id of the Mattertag inside the SOURCE Matterport model. Carried over,
15397
+ * the viewer tries to re-create the target's tag with an id belonging to another
15398
+ * model ("already in use"), so the pastille fails to attach. It is dropped: the
15399
+ * viewer then allocates a fresh sid on first render.
15400
+ * - `normal` is the stem direction. It is a direction, so only the rotation applies
15401
+ * (never the translation); without it, a copied pastille points into the wall
15402
+ * whenever the two models are not aligned on the same axis.
15403
+ * - `icon` and anything else is kept as-is.
15404
+ */
15405
+ getAdjustedPoiMetadata(metadata, transform) {
15406
+ if (!metadata) {
15407
+ return metadata ?? null;
15408
+ }
15409
+ try {
15410
+ const parsed = JSON.parse(metadata);
15411
+ if (!parsed || typeof parsed !== 'object') {
15412
+ return metadata;
15413
+ }
15414
+ if (parsed.normal
15415
+ && typeof parsed.normal === 'object'
15416
+ && typeof parsed.normal.x === 'number'
15417
+ && typeof parsed.normal.y === 'number'
15418
+ && typeof parsed.normal.z === 'number') {
15419
+ parsed.normal = applyRigidTransformToDirection(parsed.normal, transform);
15420
+ }
15421
+ delete parsed.sid;
15422
+ return JSON.stringify(parsed);
15423
+ }
15424
+ catch {
15425
+ // Not JSON, or unexpected shape — leave untouched rather than guess.
15426
+ return metadata;
15427
+ }
15428
+ }
15429
+ /**
15430
+ * Resolves a POI's `tagImageID` — which the schema documents as a "connection to
15431
+ * comment.id", i.e. the id of the Comment holding the image shown in the pastille.
15432
+ *
15433
+ * It is NOT an S3 key, so the previous "does it contain a slash?" heuristic never
15434
+ * matched and the value travelled unchanged, leaving every copied pastille pointing at
15435
+ * a Comment of the source space. It is now translated through the comment id map built
15436
+ * while duplicating the media, and dropped when it cannot be resolved (the pastille
15437
+ * then falls back to the feature's cover image instead of a broken reference).
15438
+ */
15439
+ resolveTagImageID(tagImageID) {
15440
+ if (!tagImageID) {
15441
+ return null;
15442
+ }
15443
+ return this.commentIds.get(tagImageID) ?? null;
14707
15444
  }
14708
15445
  //
14709
15446
  // ---------- Duplicate detection ----------
14710
15447
  //
14711
15448
  /**
14712
- * Heuristic duplicate-detection: compares title AND description together instead of
14713
- * title alone, to reduce false positives when several source items happen to share
14714
- * the same title. Not a perfect guarantee, but meaningfully more robust.
15449
+ * Heuristic duplicate-detection: compares several identifying fields together rather than
15450
+ * a single label, to reduce false positives when items share one of them.
15451
+ *
15452
+ * The fields are supplied by the caller because the models do not agree on them. The
15453
+ * previous version hard-coded `title`/`description`, which do not exist on Equipment at
15454
+ * all: both sides read `undefined`, every equipment therefore "matched" every other one,
15455
+ * and a target space holding a single equipment silently caused the whole equipment phase
15456
+ * to copy nothing.
14715
15457
  */
14716
- matchesExisting(source, targetList) {
14717
- return targetList.some(t => t.title === source.title && (t.description || null) === (source.description || null));
15458
+ matchesExisting(source, targetList, identity) {
15459
+ const sourceKey = this.identityKey(identity(source));
15460
+ return targetList.some(target => this.identityKey(identity(target)) === sourceKey);
15461
+ }
15462
+ /** Serialises the identifying fields unambiguously, so that ["a", "b"] and ["ab", ""]
15463
+ * can never produce the same key. */
15464
+ identityKey(parts) {
15465
+ return JSON.stringify(parts.map(part => part ?? ''));
14718
15466
  }
14719
15467
  //
14720
15468
  // ---------- Sweep positions / rigid transform ----------
@@ -14808,10 +15556,12 @@ class MatterportTagCopyService {
14808
15556
  /**
14809
15557
  * Builds a new S3 key for a copied attachment and sanitizes the filename
14810
15558
  * to prevent AWS Amplify SignatureDoesNotMatch errors.
15559
+ * `folderOverride` re-homes the copy into another prefix — used for tag icons, which
15560
+ * are stored per space (`icons-tags/<spaceId>/`) and must follow the target space.
14811
15561
  */
14812
- buildCopiedAttachmentKey(sourceAnnexe) {
15562
+ buildCopiedAttachmentKey(sourceAnnexe, folderOverride = null) {
14813
15563
  const lastSlash = sourceAnnexe.lastIndexOf('/');
14814
- const folder = sourceAnnexe.substring(0, lastSlash + 1);
15564
+ const folder = folderOverride ?? sourceAnnexe.substring(0, lastSlash + 1);
14815
15565
  let filename = sourceAnnexe.substring(lastSlash + 1);
14816
15566
  // Strip a leading "<8 hex chars>_" id prefix if present
14817
15567
  const prefixMatch = filename.match(/^[0-9a-fA-F-]{8}_(.+)$/);
@@ -14831,12 +15581,12 @@ class MatterportTagCopyService {
14831
15581
  * skip the whole item entirely, rather than creating a record with a broken file link.
14832
15582
  * - Any other kind of failure (permissions, network, etc.) still throws a generic Error.
14833
15583
  */
14834
- async copyS3KeyIfPresent(key, contextLabel) {
15584
+ async copyS3KeyIfPresent(key, contextLabel, folderOverride = null) {
14835
15585
  if (!key) {
14836
15586
  return null;
14837
15587
  }
14838
15588
  const sourceKey = this.extractS3KeyFromValue(key);
14839
- const destKey = this.buildCopiedAttachmentKey(sourceKey);
15589
+ const destKey = this.buildCopiedAttachmentKey(sourceKey, folderOverride);
14840
15590
  // Log the source key to ensure it matches the real S3 object path
14841
15591
  console.log(`[DEBUG] Attempting S3 copy from source: "${sourceKey}" to dest: "${destKey}"`);
14842
15592
  try {
@@ -14847,49 +15597,101 @@ class MatterportTagCopyService {
14847
15597
  return copied;
14848
15598
  }
14849
15599
  catch (error) {
14850
- const isMissingFile = error instanceof MissingS3FileError
14851
- || error?.code === 'NoSuchKey'
14852
- || /NoSuchKey|does not exist/i.test(toErrorMessage(error));
14853
- if (isMissingFile) {
15600
+ if (this.isMissingS3File(error)) {
14854
15601
  console.warn(`[MatterportTagCopyService] Orphaned S3 reference, skipping item: "${sourceKey}" (${contextLabel})`);
14855
15602
  throw new MissingS3FileError(sourceKey, contextLabel);
14856
15603
  }
15604
+ // Storage.copy() cannot handle a key holding a space, an accent or a parenthesis:
15605
+ // it puts the key unencoded into `x-amz-copy-source`, which then disagrees with the
15606
+ // signed request. Those files are perfectly readable — only that header is broken —
15607
+ // so fall back to downloading and re-uploading them.
15608
+ if (this.isSignatureMismatch(error)) {
15609
+ console.warn(`[MatterportTagCopyService] Storage.copy() signature mismatch on "${sourceKey}", falling back to download + re-upload.`);
15610
+ try {
15611
+ const copied = await copyFileInS3ViaDownload(sourceKey, destKey);
15612
+ if (!copied) {
15613
+ throw new MissingS3FileError(sourceKey, contextLabel);
15614
+ }
15615
+ return copied;
15616
+ }
15617
+ catch (fallbackError) {
15618
+ if (this.isMissingS3File(fallbackError)) {
15619
+ throw new MissingS3FileError(sourceKey, contextLabel);
15620
+ }
15621
+ throw fallbackError;
15622
+ }
15623
+ }
14857
15624
  throw error;
14858
15625
  }
14859
15626
  }
15627
+ /** True when the failure means the source object simply is not on S3 (orphaned reference). */
15628
+ isMissingS3File(error) {
15629
+ return error instanceof MissingS3FileError
15630
+ || error?.code === 'NoSuchKey'
15631
+ || error?.name === 'NoSuchKey'
15632
+ || /NoSuchKey|does not exist|NotFound/i.test(toErrorMessage(error));
15633
+ }
15634
+ /** True when AppSync refused the operation for lack of permission, as opposed to failing it.
15635
+ * Nothing can be retried or worked around client-side: the account simply is not allowed
15636
+ * to write that model, which is a backend authorisation rule (@auth) to change. */
15637
+ isUnauthorized(error) {
15638
+ const message = toErrorMessage(error);
15639
+ return error?.errorType === 'Unauthorized'
15640
+ || /Unauthorized|Not Authorized to access/i.test(message);
15641
+ }
15642
+ /** True for the SigV4 mismatch Storage.copy() raises on keys with special characters. */
15643
+ isSignatureMismatch(error) {
15644
+ return error?.code === 'SignatureDoesNotMatch'
15645
+ || error?.name === 'SignatureDoesNotMatch'
15646
+ || /signature we calculated does not match|SignatureDoesNotMatch/i.test(toErrorMessage(error));
15647
+ }
14860
15648
  /**
14861
15649
  * Duplicates the file referenced by a POI's tagIcon (a JSON string shaped like
14862
- * { src, opacity }, per form-feature.component.ts). If the JSON can't be parsed or
14863
- * has no `src`, the value is returned unchanged rather than risk corrupting it.
15650
+ * { src, opacity }, per form-feature.component.ts).
15651
+ *
15652
+ * Two behaviours matter here:
15653
+ * - the copy is re-homed under the TARGET space's icon folder
15654
+ * (`icons-tags/<targetSpaceId>/`, the layout form-feature.component.ts uploads to).
15655
+ * Keeping the source folder produced a target space whose icons all lived under
15656
+ * another space's prefix, so cleaning up the source space broke the copy's icons.
15657
+ * - a missing icon file is NOT fatal. It used to bubble up as a MissingS3FileError and
15658
+ * abort the whole POI — losing the pastille, its text and its media because of a
15659
+ * decorative file. The POI is now created with the icon reference dropped, which
15660
+ * renders the module's default icon, and a log line reports it.
14864
15661
  */
14865
15662
  async duplicateTagIcon(tagIconRaw) {
14866
15663
  if (!tagIconRaw) {
14867
- return tagIconRaw;
15664
+ return tagIconRaw ?? null;
14868
15665
  }
15666
+ let parsed;
14869
15667
  try {
14870
- const parsed = JSON.parse(tagIconRaw);
14871
- if (parsed && parsed.src) {
14872
- parsed.src = await this.copyS3KeyIfPresent(parsed.src, 'tag icon');
14873
- }
14874
- return JSON.stringify(parsed);
15668
+ parsed = JSON.parse(tagIconRaw);
14875
15669
  }
14876
15670
  catch {
14877
- // Not JSON, or unexpected shape — leave untouched rather than guess.
15671
+ // Not JSON — leave untouched rather than guess.
14878
15672
  return tagIconRaw;
14879
15673
  }
14880
- }
14881
- /**
14882
- * Duplicates tagImageID if it looks like a reference to a file (contains a path
14883
- * separator, whether raw key or pre-signed URL). UNCONFIRMED ASSUMPTION: this
14884
- * field's exact semantics weren't verified — if it's actually an opaque reference
14885
- * to another DB record rather than a file, this heuristic may incorrectly attempt
14886
- * a copy. Confirm the real structure to tighten this if it causes issues.
14887
- */
14888
- async duplicateTagImageID(tagImageID) {
14889
- if (!tagImageID || !tagImageID.includes('/')) {
14890
- return tagImageID;
15674
+ if (!parsed || typeof parsed !== 'object' || !parsed.src) {
15675
+ return tagIconRaw;
15676
+ }
15677
+ try {
15678
+ parsed.src = await this.copyS3KeyIfPresent(parsed.src, 'tag icon', this.buildTargetIconFolder());
15679
+ }
15680
+ catch (iconError) {
15681
+ if (!(iconError instanceof MissingS3FileError)) {
15682
+ // A real failure (permissions, network, ...) still aborts the POI: it means
15683
+ // the copy cannot be trusted, unlike a file that is simply already gone.
15684
+ throw iconError;
15685
+ }
15686
+ console.warn(`[MatterportTagCopyService] Tag icon missing on S3, falling back to the default icon: "${iconError.sourceKey}"`);
15687
+ this.logItemError('warningTagIconMissing', { file: iconError.sourceKey });
15688
+ delete parsed.src;
14891
15689
  }
14892
- return this.copyS3KeyIfPresent(tagImageID, 'tag image');
15690
+ return JSON.stringify(parsed);
15691
+ }
15692
+ /** Icon folder of the space being copied into, matching what the feature form uploads to. */
15693
+ buildTargetIconFolder() {
15694
+ return this.targetSpaceId ? `icons-tags/${this.targetSpaceId}/` : null;
14893
15695
  }
14894
15696
  /**
14895
15697
  * Extracts and normalizes the raw S3 key from a stored value (which may be a full URL
@@ -14932,13 +15734,13 @@ class MatterportTagCopyService {
14932
15734
  // for the CopySource header. Do NOT re-encode it here.
14933
15735
  return extracted;
14934
15736
  }
14935
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagCopyService, deps: [{ token: NavigationService }, { token: BaseUserService }, { token: PoiService }, { token: TicketsService }, { token: EquipmentService }, { token: Object3DService }, { token: FeatureService }, { token: MeasurementService }, { token: CommentService }, { token: i1.TranslateService }], target: i0.ɵɵFactoryTarget.Injectable });
15737
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagCopyService, deps: [{ token: NavigationService }, { token: BaseUserService }, { token: PoiService }, { token: TicketsService }, { token: EquipmentService }, { token: Object3DService }, { token: FeatureService }, { token: MeasurementService }, { token: CommentService }, { token: HashtagService }, { token: SpaceService }, { token: i1.TranslateService }], target: i0.ɵɵFactoryTarget.Injectable });
14936
15738
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagCopyService, providedIn: 'root' });
14937
15739
  }
14938
15740
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagCopyService, decorators: [{
14939
15741
  type: Injectable,
14940
15742
  args: [{ providedIn: 'root' }]
14941
- }], ctorParameters: () => [{ type: NavigationService }, { type: BaseUserService }, { type: PoiService }, { type: TicketsService }, { type: EquipmentService }, { type: Object3DService }, { type: FeatureService }, { type: MeasurementService }, { type: CommentService }, { type: i1.TranslateService }] });
15743
+ }], ctorParameters: () => [{ type: NavigationService }, { type: BaseUserService }, { type: PoiService }, { type: TicketsService }, { type: EquipmentService }, { type: Object3DService }, { type: FeatureService }, { type: MeasurementService }, { type: CommentService }, { type: HashtagService }, { type: SpaceService }, { type: i1.TranslateService }] });
14942
15744
 
14943
15745
  /* eslint-disable class-methods-use-this */
14944
15746
  class OrganisationService {
@@ -15147,5 +15949,5 @@ function floatValidator() {
15147
15949
  * Generated bundle index. Do not edit.
15148
15950
  */
15149
15951
 
15150
- export { AffectationService, AvatarComponent, BaseLateralTabService, BaseTagService, BaseUserService, BaseVisibilityService, CameraMode, CaptureService, CaptureViewer, CommentService, CommentType, Config, ContentService, CsvExportComponent, DomainService, DomainType, DurationToStringPipe, EmailStatus, EquipmentService, EventService, EventStatus, EventType, FeatureService, FeatureType, FilterService, HashtagFromIdPipe, HashtagService, InterventionService, InventoryStatus, LayerService, LevelStatus, LoaderComponent, Locale, LocaleService, MATTERPORT_SDK_KEY, MatterportImportService, MatterportService, MatterportTagCopyService, MatterportViewType, MattertagActionMode, MattertagData, MeasurementService, MenuBarComponent, MissionService, NavigationService, NavigatorService, NgxSmarterplanCoreModule, NgxSmarterplanCoreService, NodeService, Object3DService, OperationService, OrganisationService, PaymentStatus, PlanService, PoiService, PoiType, ProfileEntity, ProfileService, ProfileStatus, PropertyService, PropertyType, RoleStatus, SafeUrlPipe, SearchBarComponent, SearchObjectType, SearchService, SpModule, SpaceService, SpaceStatus, StatusEquipment, SupportModalComponent, SupportService, TagAction, TemplateService, TicketPriority, TicketStatus, TicketType, TicketsService, TimeDateToLocalStringPipe, TypeNote, UsernameFromIdPipe, ValidatorsService, ViewerInteractions, ViewerService, VisitService, ZoneChangeService, ZoneService, applyRigidTransformToPoint, applyRigidTransformToYRotationDeg, arraysContainSameElements, checkElementById, computeRigidTransform, convertTimestampToLocalZone, copyFileInS3, dateHasExpired, dateTimeToLocalString, deleteFromS3, distance, downloadBlob, downloadEquipmentDocument, downloadFile, downloadFileAsObject, durationToString, emailValidator, enumToArray, filterUniqueArrayByID, floatValidator, getBufferForFileFromS3, getCoefficientsForImage, getCurrentLang, getDistanceBetweenTwoPoints, getHighestLevelForMissions, getHighestRoleForMissions, getLevelsBelow, getLocaleLong, getLocaleShort, getMetaForImage, getRolesBelowForManager, getSignedFile, getSignedImageUrlForEquipment, getSignedImageUrlForProfile, getSignedImageUrlForSpace, getSpaceIDFromUrl, getVisitUrl, isEmptyObject, listFilesInFolder, mean, noEmptyValidator, numberToDateString, openDocument, openModalForVisitSwitch, poiTypeToString, pointToString, removeAllFilesFromFolderS3, removeNullKeysFromObject, showScanPointsOnPlanInDiv, shuffleArray, sortAlphabeticallyOnName, stringLocaleToEnum, stringToLowercaseNoSpaces, styleButton, textValidator, translateDatePeriod, uploadBase64Image, uploadBase64ImageWithRetry, uploadFileToS3, uploadJsonToS3, validEmail, wait, waitUntil };
15952
+ export { AffectationService, AvatarComponent, BaseLateralTabService, BaseTagService, BaseUserService, BaseVisibilityService, CameraMode, CaptureService, CaptureViewer, CommentService, CommentType, Config, ContentService, CsvExportComponent, DomainService, DomainType, DurationToStringPipe, EmailStatus, EquipmentService, EventService, EventStatus, EventType, FeatureService, FeatureType, FilterService, HashtagFromIdPipe, HashtagService, InterventionService, InventoryStatus, LayerService, LevelStatus, LoaderComponent, Locale, LocaleService, MATTERPORT_SDK_KEY, MatterportImportService, MatterportService, MatterportTagCopyService, MatterportViewType, MattertagActionMode, MattertagData, MeasurementService, MenuBarComponent, MissionService, NavigationService, NavigatorService, NgxSmarterplanCoreModule, NgxSmarterplanCoreService, NodeService, Object3DService, OperationService, OrganisationService, PaymentStatus, PlanService, PoiService, PoiType, ProfileEntity, ProfileService, ProfileStatus, PropertyService, PropertyType, RoleStatus, SafeUrlPipe, SearchBarComponent, SearchObjectType, SearchService, SpModule, SpaceService, SpaceStatus, StatusEquipment, SupportModalComponent, SupportService, TagAction, TemplateService, TicketPriority, TicketStatus, TicketType, TicketsService, TimeDateToLocalStringPipe, TypeNote, UsernameFromIdPipe, ValidatorsService, ViewerInteractions, ViewerService, VisitService, ZoneChangeService, ZoneService, applyRigidTransformToDirection, applyRigidTransformToPoint, applyRigidTransformToYRotationDeg, arraysContainSameElements, checkElementById, computeRigidTransform, convertTimestampToLocalZone, copyFileInS3, copyFileInS3ViaDownload, dateHasExpired, dateTimeToLocalString, deleteFromS3, distance, downloadBlob, downloadEquipmentDocument, downloadFile, downloadFileAsObject, durationToString, emailValidator, enumToArray, filterUniqueArrayByID, floatValidator, getBufferForFileFromS3, getCoefficientsForImage, getCurrentLang, getDistanceBetweenTwoPoints, getHighestLevelForMissions, getHighestRoleForMissions, getLevelsBelow, getLocaleLong, getLocaleShort, getMetaForImage, getRolesBelowForManager, getSignedFile, getSignedImageUrlForEquipment, getSignedImageUrlForProfile, getSignedImageUrlForSpace, getSpaceIDFromUrl, getVisitUrl, isEmptyObject, listFilesInFolder, mean, noEmptyValidator, numberToDateString, openDocument, openModalForVisitSwitch, poiTypeToString, pointToString, removeAllFilesFromFolderS3, removeNullKeysFromObject, showScanPointsOnPlanInDiv, shuffleArray, sortAlphabeticallyOnName, stringLocaleToEnum, stringToLowercaseNoSpaces, styleButton, textValidator, translateDatePeriod, uploadBase64Image, uploadBase64ImageWithRetry, uploadFileToS3, uploadJsonToS3, validEmail, wait, waitUntil };
15151
15953
  //# sourceMappingURL=smarterplan-ngx-smarterplan-core.mjs.map