@smarterplan/ngx-smarterplan-core 1.4.14 → 1.4.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/esm2022/lib/pipes/hashtag-from-id.pipe.mjs +1 -1
- package/esm2022/lib/services/matterport-tag-copy.service.mjs +448 -62
- package/esm2022/lib/services/models/domain.service.mjs +20 -1
- package/esm2022/lib/services/models/equipment.service.mjs +4 -2
- package/esm2022/lib/services/models/feature.service.mjs +1 -7
- package/esm2022/lib/services/models/mission.service.mjs +17 -1
- package/fesm2022/smarterplan-ngx-smarterplan-core.mjs +555 -141
- package/fesm2022/smarterplan-ngx-smarterplan-core.mjs.map +1 -1
- package/lib/services/matterport-tag-copy.service.d.ts +137 -17
- package/lib/services/models/domain.service.d.ts +8 -0
- package/lib/services/models/feature.service.d.ts +0 -6
- package/lib/services/models/mission.service.d.ts +5 -0
- package/package.json +1 -1
|
@@ -1207,6 +1207,22 @@ class MissionService {
|
|
|
1207
1207
|
async getMissionsForSpace(spaceID) {
|
|
1208
1208
|
return this.API.__proto__.MissionsBySpace(spaceID).then((missions) => missions.items);
|
|
1209
1209
|
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Same as getMissionsForSpace(), but follows `nextToken` until the connection is
|
|
1212
|
+
* exhausted instead of returning only the first page.
|
|
1213
|
+
*/
|
|
1214
|
+
async getAllMissionsForSpace(spaceID) {
|
|
1215
|
+
const all = [];
|
|
1216
|
+
let nextToken = null;
|
|
1217
|
+
do {
|
|
1218
|
+
const page = await this.API.__proto__.MissionsBySpace(spaceID, undefined, undefined, 1000, nextToken);
|
|
1219
|
+
if (page?.items?.length > 0) {
|
|
1220
|
+
all.push(...page.items.filter((mis) => !!mis && !mis.deletedAt));
|
|
1221
|
+
}
|
|
1222
|
+
nextToken = page?.nextToken ?? null;
|
|
1223
|
+
} while (nextToken);
|
|
1224
|
+
return all;
|
|
1225
|
+
}
|
|
1210
1226
|
activateMission(mis) {
|
|
1211
1227
|
if (mis &&
|
|
1212
1228
|
(!mis.groups || !mis.groups.includes(ProfileStatus.ACTIVE))) {
|
|
@@ -8799,7 +8815,9 @@ class EquipmentService extends BaseObjectService {
|
|
|
8799
8815
|
const subEquips = [];
|
|
8800
8816
|
await Promise.all(equip.subequipmentsIDs.map(async (subId) => {
|
|
8801
8817
|
const subEquip = await this.API.GetEquipment(subId);
|
|
8802
|
-
|
|
8818
|
+
if (subEquip) {
|
|
8819
|
+
subEquips.push(subEquip);
|
|
8820
|
+
}
|
|
8803
8821
|
}));
|
|
8804
8822
|
equip.subEquipments = subEquips;
|
|
8805
8823
|
}
|
|
@@ -11781,12 +11799,6 @@ class FeatureService extends BaseObjectService {
|
|
|
11781
11799
|
/**
|
|
11782
11800
|
* Returns EVERY feature of a space, following `nextToken` until the index is
|
|
11783
11801
|
* 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
11802
|
*/
|
|
11791
11803
|
async getAllFeaturesBySpaceRaw(spaceID) {
|
|
11792
11804
|
if (!spaceID) {
|
|
@@ -14252,6 +14264,100 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
14252
14264
|
args: ['currentAPIService']
|
|
14253
14265
|
}] }, { type: BaseUserService }, { type: NavigatorService }, { type: ZoneChangeService }, { type: FilterService }] });
|
|
14254
14266
|
|
|
14267
|
+
class DomainService {
|
|
14268
|
+
API; //AWS
|
|
14269
|
+
constructor(apiInjected) {
|
|
14270
|
+
this.API = apiInjected;
|
|
14271
|
+
}
|
|
14272
|
+
async getGenericDomainsAndForOrganisation(organisationID) {
|
|
14273
|
+
return this.API.__proto__.ListDomains().then((domains) => {
|
|
14274
|
+
return domains.items.filter((dom) => {
|
|
14275
|
+
return (dom.organisationID === organisationID || !dom.organisationID);
|
|
14276
|
+
});
|
|
14277
|
+
});
|
|
14278
|
+
}
|
|
14279
|
+
/**
|
|
14280
|
+
* Same as getGenericDomainsAndForOrganisation(), but drains every page instead of only
|
|
14281
|
+
* the first. `ListDomains` has no per-organisation index — every call already scans the
|
|
14282
|
+
* WHOLE table across every organisation before filtering client-side — so a catalogue
|
|
14283
|
+
* past the first page was silently invisible to callers that need the complete list
|
|
14284
|
+
* (e.g. matching an organisation-specific domain by name across a space-to-space copy).
|
|
14285
|
+
*/
|
|
14286
|
+
async getAllGenericDomainsAndForOrganisation(organisationID) {
|
|
14287
|
+
const all = [];
|
|
14288
|
+
let nextToken = null;
|
|
14289
|
+
do {
|
|
14290
|
+
const page = await this.API.__proto__.ListDomains(undefined, 1000, nextToken);
|
|
14291
|
+
if (page?.items?.length > 0) {
|
|
14292
|
+
all.push(...page.items.filter((dom) => !!dom));
|
|
14293
|
+
}
|
|
14294
|
+
nextToken = page?.nextToken ?? null;
|
|
14295
|
+
} while (nextToken);
|
|
14296
|
+
return all.filter(dom => dom.organisationID === organisationID || !dom.organisationID);
|
|
14297
|
+
}
|
|
14298
|
+
async getGenericDomainsAndForOrgAndOrderedOrg(orgID, orderedOrgID = null) {
|
|
14299
|
+
// TODO: Change this (UUID can change depending on DB)
|
|
14300
|
+
if (orgID === "6e28201f-4679-4c29-9d2d-8a325f57cee9") {
|
|
14301
|
+
// I am SmarterPlan, i can see all
|
|
14302
|
+
return this.API.__proto__.ListDomains().then((response) => response.items);
|
|
14303
|
+
}
|
|
14304
|
+
const currentOrgDomains = await this.getGenericDomainsAndForOrganisation(orgID);
|
|
14305
|
+
if (!orderedOrgID || orgID === orderedOrgID) {
|
|
14306
|
+
return currentOrgDomains;
|
|
14307
|
+
}
|
|
14308
|
+
const orderedOrgDomains = await this.getGenericDomainsAndForOrganisation(orderedOrgID);
|
|
14309
|
+
const merged = [...currentOrgDomains, ...orderedOrgDomains];
|
|
14310
|
+
return filterUniqueArrayByID(merged);
|
|
14311
|
+
}
|
|
14312
|
+
async getDomainsForOrgExcludingTypes(types, organisationID) {
|
|
14313
|
+
return this.getGenericDomainsAndForOrganisation(organisationID).then((domains) => {
|
|
14314
|
+
return domains.filter((dom) => !types.includes(dom.type));
|
|
14315
|
+
});
|
|
14316
|
+
}
|
|
14317
|
+
async getDomain(id) {
|
|
14318
|
+
return this.API.__proto__.GetDomain(id);
|
|
14319
|
+
}
|
|
14320
|
+
async updateDomain(input) {
|
|
14321
|
+
return this.API.__proto__.UpdateDomain(input);
|
|
14322
|
+
}
|
|
14323
|
+
async deleteDomain(id) {
|
|
14324
|
+
return this.API.__proto__.DeleteDomain({ id });
|
|
14325
|
+
}
|
|
14326
|
+
async getRootDomains(orgID) {
|
|
14327
|
+
const allDomains = await this.getGenericDomainsAndForOrganisation(orgID);
|
|
14328
|
+
const root = allDomains.filter((dom) => !dom.parentID);
|
|
14329
|
+
return root.sort((a, b) => a.name.localeCompare(b.name));
|
|
14330
|
+
}
|
|
14331
|
+
async createDomain(input) {
|
|
14332
|
+
return this.API.__proto__.CreateDomain(input);
|
|
14333
|
+
}
|
|
14334
|
+
async getDomainsForOrgForType(type, organisationID, orderedOrg = null) {
|
|
14335
|
+
return this.getGenericDomainsAndForOrgAndOrderedOrg(organisationID, orderedOrg).then((domains) => {
|
|
14336
|
+
return domains.filter((dom) => dom.type === type);
|
|
14337
|
+
});
|
|
14338
|
+
}
|
|
14339
|
+
async getParentDomainsForOrgAndOrderedOrgForType(type, organisationID, orderedOrg = null) {
|
|
14340
|
+
return this.getGenericDomainsAndForOrgAndOrderedOrg(organisationID, orderedOrg).then((domains) => {
|
|
14341
|
+
return domains.filter((dom) => dom.type === type && !dom.parentID);
|
|
14342
|
+
});
|
|
14343
|
+
}
|
|
14344
|
+
async getDomainsForMissionsForOrg(orgID) {
|
|
14345
|
+
const domains = await this.getDomainsForOrgExcludingTypes([DomainType.BOOKING, DomainType.FEATURE, DomainType.MISSION], orgID);
|
|
14346
|
+
return domains.filter((dom) => !dom.parentID);
|
|
14347
|
+
}
|
|
14348
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DomainService, deps: [{ token: 'currentAPIService' }], target: i0.ɵɵFactoryTarget.Injectable });
|
|
14349
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DomainService, providedIn: "root" });
|
|
14350
|
+
}
|
|
14351
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DomainService, decorators: [{
|
|
14352
|
+
type: Injectable,
|
|
14353
|
+
args: [{
|
|
14354
|
+
providedIn: "root",
|
|
14355
|
+
}]
|
|
14356
|
+
}], ctorParameters: () => [{ type: undefined, decorators: [{
|
|
14357
|
+
type: Inject,
|
|
14358
|
+
args: ['currentAPIService']
|
|
14359
|
+
}] }] });
|
|
14360
|
+
|
|
14255
14361
|
/** Safely extracts a readable message from an unknown catch value, including Amplify's
|
|
14256
14362
|
* GraphQL error shape ({ data, errors: [{ errorType, message }] }). */
|
|
14257
14363
|
function toErrorMessage(err) {
|
|
@@ -14304,7 +14410,9 @@ class MatterportTagCopyService {
|
|
|
14304
14410
|
measurementService;
|
|
14305
14411
|
commentService;
|
|
14306
14412
|
hashtagService;
|
|
14307
|
-
|
|
14413
|
+
missionService;
|
|
14414
|
+
operationService;
|
|
14415
|
+
domainService;
|
|
14308
14416
|
translate;
|
|
14309
14417
|
// Emits progress updates during copyTagsFromVisit(); null when idle
|
|
14310
14418
|
copyProgress$ = new BehaviorSubject(null);
|
|
@@ -14312,7 +14420,8 @@ class MatterportTagCopyService {
|
|
|
14312
14420
|
copyLog$ = new BehaviorSubject([]);
|
|
14313
14421
|
spModule;
|
|
14314
14422
|
log = [];
|
|
14315
|
-
|
|
14423
|
+
/** Unique-id sequence shared by every permanent (non-step) log line — errors and healed-item notices alike. */
|
|
14424
|
+
logSeq = 0;
|
|
14316
14425
|
// ---- Per-run remapping state (reset by resetRunState() at the start of every copy) ----
|
|
14317
14426
|
/** source element id -> id of its copy in the target space. */
|
|
14318
14427
|
elementIds = {};
|
|
@@ -14335,12 +14444,36 @@ class MatterportTagCopyService {
|
|
|
14335
14444
|
/** source ids of the elements whose POIs were already created by an element phase.
|
|
14336
14445
|
* Guards the standalone POI phase against creating a second, impoverished copy. */
|
|
14337
14446
|
handledElementIds = new Set();
|
|
14338
|
-
|
|
14339
|
-
|
|
14340
|
-
|
|
14447
|
+
// Comment authorship: `Comment.ownerID` is a foreign key to a Mission, and a Mission is
|
|
14448
|
+
// scoped to ONE space (userID + spaceID). A source comment's ownerID therefore points at
|
|
14449
|
+
// nobody in the target space, so it has to be translated to the Mission of the SAME user
|
|
14450
|
+
// there — see resolveCommentOwnerID().
|
|
14451
|
+
/** source Mission id -> its userID. */
|
|
14452
|
+
sourceMissionUserIds = new Map();
|
|
14453
|
+
/** userID -> that user's Mission id in the TARGET space (only set when they have one). */
|
|
14454
|
+
targetMissionIdsByUserId = new Map();
|
|
14455
|
+
// Domain (equipment/ticket/feature/POI category) resolution mirrors the exact rule the
|
|
14456
|
+
// edit forms use to populate their own category dropdown —
|
|
14457
|
+
// DomainService.getDomainsForOrgForType(type, mission.organisationID,
|
|
14458
|
+
// mission.orderedOrganisationID) — keyed to the OPERATOR's own Mission on each space,
|
|
14459
|
+
// NOT to the Space's own organisationID. Two spaces in different organisations can
|
|
14460
|
+
// still share a domain through that Mission link (an operator whose mission on the
|
|
14461
|
+
// target space carries an `orderedOrganisationID` matching the domain's own
|
|
14462
|
+
// organisationID sees — and can reuse — it directly). See resolveDomainID().
|
|
14463
|
+
/** id -> Domain, for every domain visible from EITHER space's operator mission (its
|
|
14464
|
+
* organisationID, its orderedOrganisationID, or generic/no-organisation domains) — a
|
|
14465
|
+
* superset covering whatever a source object's domainID could point at. */
|
|
14466
|
+
domainsById = new Map();
|
|
14467
|
+
/** organisationIDs the TARGET operator's mission can see domains from. A domain whose
|
|
14468
|
+
* own organisationID is in this set (or has none at all) is already directly
|
|
14469
|
+
* selectable in the target space's own dropdown, so its id is reused unchanged. */
|
|
14470
|
+
targetVisibleDomainOrgIds = new Set();
|
|
14471
|
+
/** `${type}::${normalised name}` -> that domain's id, among domains visible to the
|
|
14472
|
+
* TARGET operator — used to find an equivalent when the exact id isn't reusable. */
|
|
14473
|
+
targetDomainIdsByTypeAndName = new Map();
|
|
14341
14474
|
/** Target space id of the run in progress — used to re-home copied tag icons. */
|
|
14342
14475
|
targetSpaceId = null;
|
|
14343
|
-
constructor(navigationService, userService, poiService, ticketService, equipmentService, object3DService, featureService, measurementService, commentService, hashtagService,
|
|
14476
|
+
constructor(navigationService, userService, poiService, ticketService, equipmentService, object3DService, featureService, measurementService, commentService, hashtagService, missionService, operationService, domainService, translate) {
|
|
14344
14477
|
this.navigationService = navigationService;
|
|
14345
14478
|
this.userService = userService;
|
|
14346
14479
|
this.poiService = poiService;
|
|
@@ -14351,7 +14484,9 @@ class MatterportTagCopyService {
|
|
|
14351
14484
|
this.measurementService = measurementService;
|
|
14352
14485
|
this.commentService = commentService;
|
|
14353
14486
|
this.hashtagService = hashtagService;
|
|
14354
|
-
this.
|
|
14487
|
+
this.missionService = missionService;
|
|
14488
|
+
this.operationService = operationService;
|
|
14489
|
+
this.domainService = domainService;
|
|
14355
14490
|
this.translate = translate;
|
|
14356
14491
|
this.spModule = userService.getSpModule();
|
|
14357
14492
|
}
|
|
@@ -14360,6 +14495,7 @@ class MatterportTagCopyService {
|
|
|
14360
14495
|
//
|
|
14361
14496
|
async copyTagsFromVisit(sourceSpaceId, targetSpaceId, landmarkSourceA, landmarkTargetA, landmarkSourceB, landmarkTargetB) {
|
|
14362
14497
|
let errorCount = 0;
|
|
14498
|
+
let healedCount = 0;
|
|
14363
14499
|
this.resetLog();
|
|
14364
14500
|
this.resetRunState(targetSpaceId);
|
|
14365
14501
|
try {
|
|
@@ -14369,7 +14505,7 @@ class MatterportTagCopyService {
|
|
|
14369
14505
|
phase: 'collecting',
|
|
14370
14506
|
processedInPhase: 0, totalInPhase: 0,
|
|
14371
14507
|
processedOverall: 0, totalOverall: 0,
|
|
14372
|
-
percent: 0, errorCount: 0
|
|
14508
|
+
percent: 0, errorCount: 0, healedCount: 0
|
|
14373
14509
|
});
|
|
14374
14510
|
const targetMission = this.userService.currentMission(targetSpaceId);
|
|
14375
14511
|
const targetMissionId = targetMission ? targetMission.id : null;
|
|
@@ -14379,9 +14515,6 @@ class MatterportTagCopyService {
|
|
|
14379
14515
|
refSource: transform.refSource,
|
|
14380
14516
|
refTarget: transform.refTarget
|
|
14381
14517
|
});
|
|
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);
|
|
14385
14518
|
const sourceSweepIds = await this.navigationService.getAllowedSweepsIDsForUserInSpace(sourceSpaceId);
|
|
14386
14519
|
const targetSweepIds = await this.navigationService.getAllowedSweepsIDsForUserInSpace(targetSpaceId);
|
|
14387
14520
|
this.setStepProgress('collecting', 'collecting', 30);
|
|
@@ -14422,6 +14555,14 @@ class MatterportTagCopyService {
|
|
|
14422
14555
|
// Keywords: index both vocabularies now (read-only). The keywords the target space
|
|
14423
14556
|
// is missing are created later, POI by POI, only for those actually used.
|
|
14424
14557
|
await this.loadHashtagIndex(sourceSpaceId, targetSpaceId);
|
|
14558
|
+
// Missions: index both spaces now (read-only), so copied comments can credit
|
|
14559
|
+
// their real author instead of whoever is running the copy.
|
|
14560
|
+
await this.loadMissionIndex(sourceSpaceId, targetSpaceId);
|
|
14561
|
+
// Domains: index every domain either operator's mission can see (read-only), so
|
|
14562
|
+
// a category survives a copy across two DIFFERENT organisations whenever it
|
|
14563
|
+
// genuinely would be selectable in the target space's own dropdown — see
|
|
14564
|
+
// loadDomainIndex() for why this is keyed to Mission, not to the Space itself.
|
|
14565
|
+
await this.loadDomainIndex(sourceSpaceId, targetSpaceId);
|
|
14425
14566
|
if (this.spModule == SpModule.IMMO) {
|
|
14426
14567
|
sourceMeasurements = await this.measurementService.getMeasurementsForSpaceForUser(sourceSpaceId);
|
|
14427
14568
|
targetMeasurements = await this.measurementService.getMeasurementsForSpaceForUser(targetSpaceId);
|
|
@@ -14479,7 +14620,7 @@ class MatterportTagCopyService {
|
|
|
14479
14620
|
processedOverall,
|
|
14480
14621
|
totalOverall,
|
|
14481
14622
|
percent: totalOverall > 0 ? Math.round((processedOverall / totalOverall) * 100) : 0,
|
|
14482
|
-
errorCount
|
|
14623
|
+
errorCount, healedCount
|
|
14483
14624
|
});
|
|
14484
14625
|
};
|
|
14485
14626
|
if (this.spModule == SpModule.IMMO) {
|
|
@@ -14487,8 +14628,8 @@ class MatterportTagCopyService {
|
|
|
14487
14628
|
for (let i = 0; i < sourceTickets.length; i++) {
|
|
14488
14629
|
const sourceObj = sourceTickets[i];
|
|
14489
14630
|
try {
|
|
14490
|
-
const
|
|
14491
|
-
if (!
|
|
14631
|
+
const existingMatch = this.findExisting(sourceObj, targetTickets, (t) => [t.title, t.description]);
|
|
14632
|
+
if (!existingMatch) {
|
|
14492
14633
|
const rawSource = sourceObj;
|
|
14493
14634
|
const ticketInput = {
|
|
14494
14635
|
title: rawSource.title,
|
|
@@ -14509,8 +14650,9 @@ class MatterportTagCopyService {
|
|
|
14509
14650
|
spaceID: targetSpaceId,
|
|
14510
14651
|
// Remapped in the second pass below, once every ticket exists.
|
|
14511
14652
|
parentID: null,
|
|
14512
|
-
//
|
|
14513
|
-
|
|
14653
|
+
// Reused unchanged in the same organisation, matched by name
|
|
14654
|
+
// across two — see resolveDomainID().
|
|
14655
|
+
domainID: this.resolveDomainID(rawSource.domainID),
|
|
14514
14656
|
ownerMissionID: targetMissionId
|
|
14515
14657
|
};
|
|
14516
14658
|
const createdObj = await this.ticketService.createTicket(ticketInput);
|
|
@@ -14531,7 +14673,7 @@ class MatterportTagCopyService {
|
|
|
14531
14673
|
}
|
|
14532
14674
|
for (const sourceComment of this.orderCommentsForCopy(sourceComments)) {
|
|
14533
14675
|
try {
|
|
14534
|
-
const created = await this.copyCommentForTicket(sourceComment, createdObj.id, targetSpaceId
|
|
14676
|
+
const created = await this.copyCommentForTicket(sourceComment, createdObj.id, targetSpaceId);
|
|
14535
14677
|
if (created?.id && sourceComment.id) {
|
|
14536
14678
|
this.commentIds.set(sourceComment.id, created.id);
|
|
14537
14679
|
}
|
|
@@ -14549,6 +14691,18 @@ class MatterportTagCopyService {
|
|
|
14549
14691
|
await this.createCopiedPoi(sourcePoi, createdObj.id, sourceNavPositions, targetNavPositions, transform);
|
|
14550
14692
|
}
|
|
14551
14693
|
}
|
|
14694
|
+
else {
|
|
14695
|
+
// Same reasoning as the EQUIPMENTS phase (see the comment there):
|
|
14696
|
+
// a ticket matching this one already exists in the target space,
|
|
14697
|
+
// most likely from an earlier, incomplete run. Fill in whatever
|
|
14698
|
+
// identity field it is missing rather than leaving it untouched.
|
|
14699
|
+
elementIds[sourceObj.id] = existingMatch.id;
|
|
14700
|
+
const healedFields = await this.healTicketIdentity(sourceObj, existingMatch);
|
|
14701
|
+
if (healedFields.length > 0) {
|
|
14702
|
+
healedCount++;
|
|
14703
|
+
this.logHealedItem('ticketHealed', sourceObj?.title || sourceObj?.id, healedFields);
|
|
14704
|
+
}
|
|
14705
|
+
}
|
|
14552
14706
|
}
|
|
14553
14707
|
catch (itemError) {
|
|
14554
14708
|
console.error(`Error copying ticket "${sourceObj?.title}" (id: ${sourceObj?.id}):`, itemError);
|
|
@@ -14593,8 +14747,8 @@ class MatterportTagCopyService {
|
|
|
14593
14747
|
try {
|
|
14594
14748
|
// Equipment has no `title`/`description`: it identifies itself by name, and by
|
|
14595
14749
|
// serial number when one is recorded.
|
|
14596
|
-
const
|
|
14597
|
-
if (!
|
|
14750
|
+
const existingMatch = this.findExisting(sourceObj, targetEquipments, (e) => [e.name, e.serialNumber]);
|
|
14751
|
+
if (!existingMatch) {
|
|
14598
14752
|
const rawSource = sourceObj;
|
|
14599
14753
|
let copiedPhotoKey = null;
|
|
14600
14754
|
if (rawSource.photo) {
|
|
@@ -14602,6 +14756,12 @@ class MatterportTagCopyService {
|
|
|
14602
14756
|
}
|
|
14603
14757
|
const equipmentInput = {
|
|
14604
14758
|
name: rawSource.name,
|
|
14759
|
+
// Unlike every other model this file copies, Equipment declares
|
|
14760
|
+
// `createdAt` as a plain client-settable field rather than an
|
|
14761
|
+
// AppSync-managed timestamp (see schema.graphql) — so, uniquely
|
|
14762
|
+
// here, the original commissioning date can actually be kept
|
|
14763
|
+
// instead of the copy silently getting today's date.
|
|
14764
|
+
createdAt: rawSource.createdAt ?? null,
|
|
14605
14765
|
annotation: rawSource.annotation ?? null,
|
|
14606
14766
|
status: rawSource.status,
|
|
14607
14767
|
// Full identity of the equipment — all of these were dropped
|
|
@@ -14618,8 +14778,9 @@ class MatterportTagCopyService {
|
|
|
14618
14778
|
// while hiding the item from no one — reset it, as for features.
|
|
14619
14779
|
archivedForMissionID: null,
|
|
14620
14780
|
spaceID: targetSpaceId,
|
|
14621
|
-
//
|
|
14622
|
-
|
|
14781
|
+
// Reused unchanged in the same organisation, matched by name
|
|
14782
|
+
// across two — see resolveDomainID().
|
|
14783
|
+
domainID: this.resolveDomainID(rawSource.domainID),
|
|
14623
14784
|
photo: copiedPhotoKey,
|
|
14624
14785
|
// `subequipmentsIDs` is deliberately absent: it is remapped to the
|
|
14625
14786
|
// copied equipments in the second pass, once they all exist.
|
|
@@ -14656,6 +14817,30 @@ class MatterportTagCopyService {
|
|
|
14656
14817
|
await this.createCopiedPoi(sourcePoi, createdObj.id, sourceNavPositions, targetNavPositions, transform);
|
|
14657
14818
|
}
|
|
14658
14819
|
}
|
|
14820
|
+
else {
|
|
14821
|
+
// An equipment matching this one already exists in the target space —
|
|
14822
|
+
// most often because a PREVIOUS run of this copy already created it.
|
|
14823
|
+
// That earlier run may have been interrupted, or hit a bug that has
|
|
14824
|
+
// since been fixed (missing family/manufacturer/domainID/... were once
|
|
14825
|
+
// dropped entirely — see the comment above), leaving an equipment with
|
|
14826
|
+
// its identity fields empty forever: re-running the copy used to skip
|
|
14827
|
+
// it outright, since it already "exists", so the gap could never heal.
|
|
14828
|
+
//
|
|
14829
|
+
// Re-running the tool must be able to repair that: any identity field
|
|
14830
|
+
// the existing target equipment is missing, and the source has, is
|
|
14831
|
+
// filled in. Fields the target already carries a value for are left
|
|
14832
|
+
// untouched, so a manual edit made in the target space is never
|
|
14833
|
+
// overwritten by an older or different source value. Notes and POIs are
|
|
14834
|
+
// NOT re-created here: unlike scalar fields they cannot be "missing vs.
|
|
14835
|
+
// present" checked the same way, and re-adding them on every re-run of
|
|
14836
|
+
// an already-healthy copy would duplicate them.
|
|
14837
|
+
elementIds[sourceObj.id] = existingMatch.id;
|
|
14838
|
+
const healedFields = await this.healEquipmentIdentity(sourceObj, existingMatch);
|
|
14839
|
+
if (healedFields.length > 0) {
|
|
14840
|
+
healedCount++;
|
|
14841
|
+
this.logHealedItem('equipmentHealed', sourceObj?.name || sourceObj?.id, healedFields);
|
|
14842
|
+
}
|
|
14843
|
+
}
|
|
14659
14844
|
}
|
|
14660
14845
|
catch (itemError) {
|
|
14661
14846
|
console.error(`Error copying equipment "${sourceObj?.name}" (id: ${sourceObj?.id}):`, itemError);
|
|
@@ -14692,6 +14877,50 @@ class MatterportTagCopyService {
|
|
|
14692
14877
|
}
|
|
14693
14878
|
}
|
|
14694
14879
|
}
|
|
14880
|
+
// OPERATIONS — the Ticket<->Equipment join (drives "Tickets actuels" on the
|
|
14881
|
+
// equipment page and the linked-equipment list on the ticket page). Both the
|
|
14882
|
+
// TICKETS and EQUIPMENTS phases above copy the record itself, but neither one
|
|
14883
|
+
// touches this join table, so every link between a copied ticket and a copied
|
|
14884
|
+
// equipment was silently dropped: both sides existed in the target space, but
|
|
14885
|
+
// pointing at nobody.
|
|
14886
|
+
//
|
|
14887
|
+
// Runs after both phases, over every SOURCE ticket that ended up mapped in
|
|
14888
|
+
// the target space (freshly created OR healed — elementIds covers both), so a
|
|
14889
|
+
// re-run over an already-copied pair of spaces can still pick up links that
|
|
14890
|
+
// an earlier, incomplete run never created.
|
|
14891
|
+
for (const sourceTicket of sourceTickets) {
|
|
14892
|
+
const newTicketId = elementIds[sourceTicket.id];
|
|
14893
|
+
if (!newTicketId) {
|
|
14894
|
+
continue;
|
|
14895
|
+
}
|
|
14896
|
+
try {
|
|
14897
|
+
const sourceOps = await this.operationService.getOperationsByTicketID(sourceTicket.id);
|
|
14898
|
+
if (sourceOps.length === 0) {
|
|
14899
|
+
continue;
|
|
14900
|
+
}
|
|
14901
|
+
// Read what the target ticket is already linked to, so a re-run never
|
|
14902
|
+
// creates a duplicate Operation for a link that already exists.
|
|
14903
|
+
const targetOps = await this.operationService.getOperationsByTicketID(newTicketId);
|
|
14904
|
+
const linkedEquipmentIds = new Set(targetOps.map(op => op.equipmentID));
|
|
14905
|
+
for (const sourceOp of sourceOps) {
|
|
14906
|
+
const newEquipmentId = sourceOp.equipmentID ? elementIds[sourceOp.equipmentID] : null;
|
|
14907
|
+
// No copied counterpart for the source equipment (not collected,
|
|
14908
|
+
// outside the current filter, ...) — same rule as everywhere else
|
|
14909
|
+
// in this file: drop the link rather than point it at nothing or
|
|
14910
|
+
// at the source space's equipment.
|
|
14911
|
+
if (!newEquipmentId || linkedEquipmentIds.has(newEquipmentId)) {
|
|
14912
|
+
continue;
|
|
14913
|
+
}
|
|
14914
|
+
await this.operationService.createOperation({ ticketID: newTicketId, equipmentID: newEquipmentId });
|
|
14915
|
+
linkedEquipmentIds.add(newEquipmentId);
|
|
14916
|
+
}
|
|
14917
|
+
}
|
|
14918
|
+
catch (opError) {
|
|
14919
|
+
console.error(`Error copying ticket-equipment links for "${sourceTicket?.title}":`, opError);
|
|
14920
|
+
errorCount++;
|
|
14921
|
+
this.logCopyItemError(opError, 'errorOperation', sourceTicket?.title || sourceTicket?.id);
|
|
14922
|
+
}
|
|
14923
|
+
}
|
|
14695
14924
|
}
|
|
14696
14925
|
// OBJECTS 3D (the 3D model file itself — `path` — is physically duplicated in S3)
|
|
14697
14926
|
for (let i = 0; i < sourceObjects3D.length; i++) {
|
|
@@ -14814,8 +15043,8 @@ class MatterportTagCopyService {
|
|
|
14814
15043
|
for (let i = 0; i < sourceFeatures.length; i++) {
|
|
14815
15044
|
const sourceObj = sourceFeatures[i];
|
|
14816
15045
|
try {
|
|
14817
|
-
const
|
|
14818
|
-
if (!
|
|
15046
|
+
const existingMatch = this.findExisting(sourceObj, targetFeatures, (f) => [f.title, f.description]);
|
|
15047
|
+
if (!existingMatch) {
|
|
14819
15048
|
const rawSource = sourceObj;
|
|
14820
15049
|
// Resolved just before the write: a keyword is only created in the target
|
|
14821
15050
|
// space when the POI carrying it is about to be created.
|
|
@@ -14841,9 +15070,9 @@ class MatterportTagCopyService {
|
|
|
14841
15070
|
// equivalent hashtags of the target space (created on demand).
|
|
14842
15071
|
hashtags: resolvedHashtags,
|
|
14843
15072
|
description: rawSource.description || null,
|
|
14844
|
-
//
|
|
14845
|
-
//
|
|
14846
|
-
domainID: this.
|
|
15073
|
+
// Reused unchanged in the same organisation, matched by name
|
|
15074
|
+
// across two — see resolveDomainID().
|
|
15075
|
+
domainID: this.resolveDomainID(rawSource.domainID),
|
|
14847
15076
|
spaceID: targetSpaceId,
|
|
14848
15077
|
ownerMissionID: targetMissionId
|
|
14849
15078
|
};
|
|
@@ -14868,7 +15097,7 @@ class MatterportTagCopyService {
|
|
|
14868
15097
|
}
|
|
14869
15098
|
for (const sourceComment of this.orderCommentsForCopy(sourceComments)) {
|
|
14870
15099
|
try {
|
|
14871
|
-
const created = await this.copyCommentForFeature(sourceComment, createdObj.id, targetSpaceId
|
|
15100
|
+
const created = await this.copyCommentForFeature(sourceComment, createdObj.id, targetSpaceId);
|
|
14872
15101
|
if (created?.id && sourceComment.id) {
|
|
14873
15102
|
this.commentIds.set(sourceComment.id, created.id);
|
|
14874
15103
|
}
|
|
@@ -14889,6 +15118,18 @@ class MatterportTagCopyService {
|
|
|
14889
15118
|
}
|
|
14890
15119
|
}
|
|
14891
15120
|
}
|
|
15121
|
+
else {
|
|
15122
|
+
// Same reasoning as the EQUIPMENTS phase (see the comment there): a
|
|
15123
|
+
// feature matching this one already exists in the target space, most
|
|
15124
|
+
// likely from an earlier, incomplete run. Fill in whatever identity
|
|
15125
|
+
// field it is missing rather than leaving it untouched.
|
|
15126
|
+
elementIds[sourceObj.id] = existingMatch.id;
|
|
15127
|
+
const healedFields = await this.healFeatureIdentity(sourceObj, existingMatch);
|
|
15128
|
+
if (healedFields.length > 0) {
|
|
15129
|
+
healedCount++;
|
|
15130
|
+
this.logHealedItem('featureHealed', sourceObj?.title || sourceObj?.id, healedFields);
|
|
15131
|
+
}
|
|
15132
|
+
}
|
|
14892
15133
|
}
|
|
14893
15134
|
catch (itemError) {
|
|
14894
15135
|
console.error(`Error copying feature "${sourceObj?.title}" (id: ${sourceObj?.id}):`, itemError);
|
|
@@ -14921,6 +15162,9 @@ class MatterportTagCopyService {
|
|
|
14921
15162
|
dimensions: rawComment.dimensions || null,
|
|
14922
15163
|
annexe: copiedAnnexe,
|
|
14923
15164
|
spaceID: targetSpaceId,
|
|
15165
|
+
// Same authorship rule as every other copied comment — see
|
|
15166
|
+
// resolveCommentOwnerID().
|
|
15167
|
+
ownerID: this.resolveCommentOwnerID(rawComment.ownerID),
|
|
14924
15168
|
};
|
|
14925
15169
|
const createdComment = await this.commentService.createComment(commentInput);
|
|
14926
15170
|
if (createdComment && createdComment.id && rawComment.id) {
|
|
@@ -14987,11 +15231,17 @@ class MatterportTagCopyService {
|
|
|
14987
15231
|
phase: 'done',
|
|
14988
15232
|
processedInPhase: 0, totalInPhase: 0,
|
|
14989
15233
|
processedOverall, totalOverall,
|
|
14990
|
-
percent: 100, errorCount
|
|
15234
|
+
percent: 100, errorCount, healedCount
|
|
14991
15235
|
});
|
|
14992
15236
|
this.setStepLog('done', errorCount > 0
|
|
14993
15237
|
? this.translate.instant('matterportCopy.doneWithErrors', { count: errorCount })
|
|
14994
15238
|
: this.translate.instant('matterportCopy.done'));
|
|
15239
|
+
// Separate summary line, on top of (not instead of) the done/doneWithErrors one
|
|
15240
|
+
// above: healing is orthogonal to success/failure, an already-complete run can
|
|
15241
|
+
// still have repaired items left over from an earlier, incomplete attempt.
|
|
15242
|
+
if (healedCount > 0) {
|
|
15243
|
+
this.setStepLog('healedSummary', this.translate.instant('matterportCopy.healedSummary', { count: healedCount }));
|
|
15244
|
+
}
|
|
14995
15245
|
return true;
|
|
14996
15246
|
}
|
|
14997
15247
|
catch (error) {
|
|
@@ -15007,6 +15257,17 @@ class MatterportTagCopyService {
|
|
|
15007
15257
|
await this.cleanupUnusedCreatedHashtags();
|
|
15008
15258
|
}
|
|
15009
15259
|
}
|
|
15260
|
+
/**
|
|
15261
|
+
* Serialises the current copy log (step lines as last emitted, plus every error and
|
|
15262
|
+
* healed-item notice) to a plain-text file and triggers a browser download — wired to a
|
|
15263
|
+
* "download log" button in the copy UI, so an operator can keep or share the full report
|
|
15264
|
+
* of a run rather than only the ~200px scrollable panel showing it.
|
|
15265
|
+
*/
|
|
15266
|
+
downloadLog(filename) {
|
|
15267
|
+
const text = this.log.map(entry => entry.text).join('\n');
|
|
15268
|
+
const defaultName = `matterport-copy-log-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`;
|
|
15269
|
+
downloadBlob(new Blob([text], { type: 'text/plain;charset=utf-8' }), filename || defaultName);
|
|
15270
|
+
}
|
|
15010
15271
|
//
|
|
15011
15272
|
// ---------- Progress / log helpers ----------
|
|
15012
15273
|
//
|
|
@@ -15024,14 +15285,21 @@ class MatterportTagCopyService {
|
|
|
15024
15285
|
}
|
|
15025
15286
|
/** Appends a new, permanent log line (never overwritten), then republishes the log. */
|
|
15026
15287
|
appendErrorLog(text) {
|
|
15027
|
-
this.
|
|
15028
|
-
this.log.push({ id: `error-${this.
|
|
15288
|
+
this.logSeq++;
|
|
15289
|
+
this.log.push({ id: `error-${this.logSeq}`, type: 'error', text });
|
|
15290
|
+
this.copyLog$.next([...this.log]);
|
|
15291
|
+
}
|
|
15292
|
+
/** Appends a permanent log line for an item that was repaired rather than newly
|
|
15293
|
+
* created — see healEquipmentIdentity() and its ticket/feature equivalents. */
|
|
15294
|
+
appendHealedLog(text) {
|
|
15295
|
+
this.logSeq++;
|
|
15296
|
+
this.log.push({ id: `healed-${this.logSeq}`, type: 'healed', text });
|
|
15029
15297
|
this.copyLog$.next([...this.log]);
|
|
15030
15298
|
}
|
|
15031
15299
|
/** Resets the log for a new run. */
|
|
15032
15300
|
resetLog() {
|
|
15033
15301
|
this.log = [];
|
|
15034
|
-
this.
|
|
15302
|
+
this.logSeq = 0;
|
|
15035
15303
|
this.copyLog$.next([]);
|
|
15036
15304
|
}
|
|
15037
15305
|
/** Clears every id map carried over from a previous run. The service is a root-level
|
|
@@ -15045,7 +15313,11 @@ class MatterportTagCopyService {
|
|
|
15045
15313
|
this.createdHashtagIds = new Set();
|
|
15046
15314
|
this.usedHashtagIds = new Set();
|
|
15047
15315
|
this.handledElementIds = new Set();
|
|
15048
|
-
this.
|
|
15316
|
+
this.sourceMissionUserIds = new Map();
|
|
15317
|
+
this.targetMissionIdsByUserId = new Map();
|
|
15318
|
+
this.domainsById = new Map();
|
|
15319
|
+
this.targetVisibleDomainOrgIds = new Set();
|
|
15320
|
+
this.targetDomainIdsByTypeAndName = new Map();
|
|
15049
15321
|
this.targetSpaceId = targetSpaceId;
|
|
15050
15322
|
}
|
|
15051
15323
|
/** Builds and sets a step's log line as "<translated label>: <percent>%", no step numbering. */
|
|
@@ -15058,6 +15330,16 @@ class MatterportTagCopyService {
|
|
|
15058
15330
|
const text = this.translate.instant(`matterportCopy.${translationKey}`, params);
|
|
15059
15331
|
this.appendErrorLog(text);
|
|
15060
15332
|
}
|
|
15333
|
+
/** Logs a translated line for an item that already existed in the target space and had
|
|
15334
|
+
* its missing identity fields filled in, so the operator can tell it apart from a
|
|
15335
|
+
* freshly created item in the copy report. */
|
|
15336
|
+
logHealedItem(translationKey, itemLabel, healedFields) {
|
|
15337
|
+
const text = this.translate.instant(`matterportCopy.${translationKey}`, {
|
|
15338
|
+
name: itemLabel,
|
|
15339
|
+
fields: healedFields.join(', ')
|
|
15340
|
+
});
|
|
15341
|
+
this.appendHealedLog(text);
|
|
15342
|
+
}
|
|
15061
15343
|
/** Logs an item-level error, using a specific "missing file / orphaned reference"
|
|
15062
15344
|
* translation key when the cause is a MissingS3FileError, and the generic per-type
|
|
15063
15345
|
* key otherwise. Keeps the copy log readable: a missing file is not a bug to
|
|
@@ -15094,27 +15376,87 @@ class MatterportTagCopyService {
|
|
|
15094
15376
|
//
|
|
15095
15377
|
// ---------- Cross-space reference remapping ----------
|
|
15096
15378
|
//
|
|
15379
|
+
/** Normalised lookup key for a domain: type disambiguates same-named domains used for
|
|
15380
|
+
* different purposes (equipment vs. ticket vs. feature categories, ...). */
|
|
15381
|
+
domainKey(type, name) {
|
|
15382
|
+
return `${type || ''}::${(name || '').trim().toLowerCase()}`;
|
|
15383
|
+
}
|
|
15097
15384
|
/**
|
|
15098
|
-
*
|
|
15099
|
-
*
|
|
15100
|
-
*
|
|
15101
|
-
*
|
|
15102
|
-
*
|
|
15385
|
+
* READ-ONLY indexing of every domain either operator's Mission can see. Creates
|
|
15386
|
+
* nothing — unlike hashtags, a domain is a business taxonomy entry an operator manages
|
|
15387
|
+
* deliberately; this tool only ever reuses an EXISTING equivalent, it never invents one.
|
|
15388
|
+
*
|
|
15389
|
+
* Visibility is keyed to `currentMission(spaceId).organisationID` /
|
|
15390
|
+
* `.orderedOrganisationID` for EACH space — exactly what
|
|
15391
|
+
* `DomainService.getDomainsForOrgForType()` uses to populate the category dropdown on
|
|
15392
|
+
* the equipment/ticket/feature edit forms (see form-equipment.component.ts,
|
|
15393
|
+
* batch-edit-equipment.component.ts, form-feature.component.ts). Comparing the spaces'
|
|
15394
|
+
* OWN `organisationID` instead — the previous approach — missed every domain reachable
|
|
15395
|
+
* only through the operator's `orderedOrganisationID` (set when a mission is created "on
|
|
15396
|
+
* behalf of" another organisation — see form-mission.component.ts), which is exactly how
|
|
15397
|
+
* a domain ends up visible on two spaces that belong to different organisations.
|
|
15103
15398
|
*/
|
|
15104
|
-
async
|
|
15399
|
+
async loadDomainIndex(sourceSpaceId, targetSpaceId) {
|
|
15400
|
+
const sourceMission = this.userService.currentMission(sourceSpaceId);
|
|
15401
|
+
const targetMission = this.userService.currentMission(targetSpaceId);
|
|
15402
|
+
const sourceOrgIds = [sourceMission?.organisationID, sourceMission?.orderedOrganisationID]
|
|
15403
|
+
.filter((id) => !!id);
|
|
15404
|
+
const targetOrgIds = [targetMission?.organisationID, targetMission?.orderedOrganisationID]
|
|
15405
|
+
.filter((id) => !!id);
|
|
15406
|
+
this.targetVisibleDomainOrgIds = new Set(targetOrgIds);
|
|
15407
|
+
const allOrgIds = Array.from(new Set([...sourceOrgIds, ...targetOrgIds]));
|
|
15408
|
+
if (allOrgIds.length === 0) {
|
|
15409
|
+
// The operator has no mission on either space — nothing to resolve against;
|
|
15410
|
+
// every domainID will be dropped, same as an unresolvable lookup elsewhere.
|
|
15411
|
+
return;
|
|
15412
|
+
}
|
|
15105
15413
|
try {
|
|
15106
|
-
const
|
|
15107
|
-
|
|
15108
|
-
|
|
15109
|
-
|
|
15110
|
-
|
|
15111
|
-
|
|
15112
|
-
|
|
15414
|
+
const domainLists = await Promise.all(allOrgIds.map(orgId => this.domainService.getAllGenericDomainsAndForOrganisation(orgId)));
|
|
15415
|
+
for (const list of domainLists) {
|
|
15416
|
+
for (const domain of list || []) {
|
|
15417
|
+
if (domain?.id) {
|
|
15418
|
+
this.domainsById.set(domain.id, domain);
|
|
15419
|
+
}
|
|
15420
|
+
}
|
|
15421
|
+
}
|
|
15113
15422
|
}
|
|
15114
15423
|
catch (error) {
|
|
15115
|
-
console.warn('[MatterportTagCopyService] Could not
|
|
15116
|
-
return
|
|
15424
|
+
console.warn('[MatterportTagCopyService] Could not read the domains, cross-organisation categories will be dropped:', error);
|
|
15425
|
+
return;
|
|
15426
|
+
}
|
|
15427
|
+
for (const domain of this.domainsById.values()) {
|
|
15428
|
+
const visibleToTarget = !domain.organisationID || this.targetVisibleDomainOrgIds.has(domain.organisationID);
|
|
15429
|
+
if (visibleToTarget && domain.name) {
|
|
15430
|
+
this.targetDomainIdsByTypeAndName.set(this.domainKey(domain.type, domain.name), domain.id);
|
|
15431
|
+
}
|
|
15432
|
+
}
|
|
15433
|
+
}
|
|
15434
|
+
/**
|
|
15435
|
+
* Resolves a source domainID (equipment/ticket/feature/POI category) for use in the
|
|
15436
|
+
* target space.
|
|
15437
|
+
*
|
|
15438
|
+
* - generic domain (no organisationID of its own — a shared taxonomy entry, e.g. a
|
|
15439
|
+
* standard category created by the platform): reused unchanged, valid everywhere.
|
|
15440
|
+
* - the domain's own organisationID is already one the TARGET operator's mission can
|
|
15441
|
+
* see (its organisationID, or its orderedOrganisationID): reused unchanged too — the
|
|
15442
|
+
* id is directly selectable in the target space's own dropdown as-is.
|
|
15443
|
+
* - otherwise: matched by (type, name) against the domains visible to the TARGET
|
|
15444
|
+
* operator (loadDomainIndex()); dropped when no equivalent exists there.
|
|
15445
|
+
*/
|
|
15446
|
+
resolveDomainID(sourceDomainId) {
|
|
15447
|
+
if (!sourceDomainId) {
|
|
15448
|
+
return null;
|
|
15449
|
+
}
|
|
15450
|
+
const domain = this.domainsById.get(sourceDomainId);
|
|
15451
|
+
if (!domain) {
|
|
15452
|
+
// Not visible to either operator's mission (lookup failed, or it belongs to
|
|
15453
|
+
// neither) — unchanged, safest to drop.
|
|
15454
|
+
return null;
|
|
15117
15455
|
}
|
|
15456
|
+
if (!domain.organisationID || this.targetVisibleDomainOrgIds.has(domain.organisationID)) {
|
|
15457
|
+
return sourceDomainId;
|
|
15458
|
+
}
|
|
15459
|
+
return this.targetDomainIdsByTypeAndName.get(this.domainKey(domain.type, domain.name)) ?? null;
|
|
15118
15460
|
}
|
|
15119
15461
|
/** Normalised lookup key for a hashtag name, so "Moyen Âge" and "moyen âge " match. */
|
|
15120
15462
|
hashtagKey(name) {
|
|
@@ -15204,6 +15546,61 @@ class MatterportTagCopyService {
|
|
|
15204
15546
|
}
|
|
15205
15547
|
return resolved.length > 0 ? resolved : null;
|
|
15206
15548
|
}
|
|
15549
|
+
/**
|
|
15550
|
+
* READ-ONLY indexing of both spaces' missions.
|
|
15551
|
+
*
|
|
15552
|
+
* A Mission is scoped to one space (it pairs a userID with a spaceID), so the id a
|
|
15553
|
+
* source comment's `ownerID` points at never designates anyone in the target space.
|
|
15554
|
+
* This builds the two lookup tables resolveCommentOwnerID() needs to translate it:
|
|
15555
|
+
* which user a source Mission id belongs to, and which Mission (if any) that same
|
|
15556
|
+
* user already holds in the target space.
|
|
15557
|
+
*/
|
|
15558
|
+
async loadMissionIndex(sourceSpaceId, targetSpaceId) {
|
|
15559
|
+
let sourceMissions = [];
|
|
15560
|
+
let targetMissions = [];
|
|
15561
|
+
try {
|
|
15562
|
+
[sourceMissions, targetMissions] = await Promise.all([
|
|
15563
|
+
this.missionService.getAllMissionsForSpace(sourceSpaceId),
|
|
15564
|
+
this.missionService.getAllMissionsForSpace(targetSpaceId)
|
|
15565
|
+
]);
|
|
15566
|
+
}
|
|
15567
|
+
catch (error) {
|
|
15568
|
+
console.warn('[MatterportTagCopyService] Could not read the missions, comment authorship will be dropped:', error);
|
|
15569
|
+
return;
|
|
15570
|
+
}
|
|
15571
|
+
for (const mission of sourceMissions || []) {
|
|
15572
|
+
if (mission?.id && mission.userID) {
|
|
15573
|
+
this.sourceMissionUserIds.set(mission.id, mission.userID);
|
|
15574
|
+
}
|
|
15575
|
+
}
|
|
15576
|
+
for (const mission of targetMissions || []) {
|
|
15577
|
+
if (mission?.id && mission.userID && !this.targetMissionIdsByUserId.has(mission.userID)) {
|
|
15578
|
+
this.targetMissionIdsByUserId.set(mission.userID, mission.id);
|
|
15579
|
+
}
|
|
15580
|
+
}
|
|
15581
|
+
}
|
|
15582
|
+
/**
|
|
15583
|
+
* Translates a source comment's `ownerID` (a per-space Mission id) into the Mission of
|
|
15584
|
+
* the SAME user in the target space, so the copy keeps crediting its real author.
|
|
15585
|
+
*
|
|
15586
|
+
* Substituting the current operator's mission — the way `ownerMissionID` legitimately
|
|
15587
|
+
* does for tickets/equipment/features, which really do change owner on a copy — would
|
|
15588
|
+
* misattribute every comment to whoever ran the copy instead of who actually wrote it.
|
|
15589
|
+
*
|
|
15590
|
+
* When the original author has no mission in the target space (never granted access
|
|
15591
|
+
* there), the comment is left with no owner rather than a wrong one: an absent author
|
|
15592
|
+
* is honest, a misattributed one is not.
|
|
15593
|
+
*/
|
|
15594
|
+
resolveCommentOwnerID(sourceOwnerID) {
|
|
15595
|
+
if (!sourceOwnerID) {
|
|
15596
|
+
return null;
|
|
15597
|
+
}
|
|
15598
|
+
const userID = this.sourceMissionUserIds.get(sourceOwnerID);
|
|
15599
|
+
if (!userID) {
|
|
15600
|
+
return null;
|
|
15601
|
+
}
|
|
15602
|
+
return this.targetMissionIdsByUserId.get(userID) ?? null;
|
|
15603
|
+
}
|
|
15207
15604
|
/** Records that these hashtags are now referenced by a feature that really exists, so
|
|
15208
15605
|
* the end-of-run cleanup leaves them alone. */
|
|
15209
15606
|
markHashtagsUsed(hashtagIds) {
|
|
@@ -15317,16 +15714,16 @@ class MatterportTagCopyService {
|
|
|
15317
15714
|
* items — where the whole content lives, since a TEXT comment has no file — and any
|
|
15318
15715
|
* per-media settings stored in metadata.
|
|
15319
15716
|
*/
|
|
15320
|
-
async copyCommentForFeature(sourceComment, targetFeatureId, targetSpaceId
|
|
15321
|
-
return this.copyComment(sourceComment, targetSpaceId,
|
|
15717
|
+
async copyCommentForFeature(sourceComment, targetFeatureId, targetSpaceId) {
|
|
15718
|
+
return this.copyComment(sourceComment, targetSpaceId, { featureID: targetFeatureId });
|
|
15322
15719
|
}
|
|
15323
15720
|
/** Same, for an attachment carried by a ticket rather than by a feature. */
|
|
15324
|
-
async copyCommentForTicket(sourceComment, targetTicketId, targetSpaceId
|
|
15325
|
-
return this.copyComment(sourceComment, targetSpaceId,
|
|
15721
|
+
async copyCommentForTicket(sourceComment, targetTicketId, targetSpaceId) {
|
|
15722
|
+
return this.copyComment(sourceComment, targetSpaceId, { ticketID: targetTicketId });
|
|
15326
15723
|
}
|
|
15327
15724
|
/** Shared body of the two above: `owner` carries whichever foreign key links the copy back
|
|
15328
15725
|
* to its new parent, everything else is identical. */
|
|
15329
|
-
async copyComment(sourceComment, targetSpaceId,
|
|
15726
|
+
async copyComment(sourceComment, targetSpaceId, owner) {
|
|
15330
15727
|
const rawComment = sourceComment;
|
|
15331
15728
|
let copiedAnnexe = null;
|
|
15332
15729
|
if (rawComment.annexe) {
|
|
@@ -15344,7 +15741,11 @@ class MatterportTagCopyService {
|
|
|
15344
15741
|
shownInTag: rawComment.shownInTag,
|
|
15345
15742
|
isCoverImage: rawComment.isCoverImage,
|
|
15346
15743
|
spaceID: targetSpaceId,
|
|
15347
|
-
|
|
15744
|
+
// Keeps crediting the ORIGINAL author when they also have a mission in the
|
|
15745
|
+
// target space, and leaves the comment un-owned otherwise — see
|
|
15746
|
+
// resolveCommentOwnerID(). Never the current operator's own mission: that
|
|
15747
|
+
// would silently reassign authorship to whoever happens to run the copy.
|
|
15748
|
+
ownerID: this.resolveCommentOwnerID(rawComment.ownerID),
|
|
15348
15749
|
...owner
|
|
15349
15750
|
};
|
|
15350
15751
|
return this.commentService.createComment(commentInput);
|
|
@@ -15383,7 +15784,7 @@ class MatterportTagCopyService {
|
|
|
15383
15784
|
level: raw.level ?? null,
|
|
15384
15785
|
role: raw.role ?? null,
|
|
15385
15786
|
category: raw.category ?? null,
|
|
15386
|
-
domainID: this.
|
|
15787
|
+
domainID: this.resolveDomainID(raw.domainID),
|
|
15387
15788
|
// --- Matterport anchoring ---
|
|
15388
15789
|
metadata: this.getAdjustedPoiMetadata(sourcePoi.metadata, transform)
|
|
15389
15790
|
};
|
|
@@ -15447,7 +15848,9 @@ class MatterportTagCopyService {
|
|
|
15447
15848
|
//
|
|
15448
15849
|
/**
|
|
15449
15850
|
* Heuristic duplicate-detection: compares several identifying fields together rather than
|
|
15450
|
-
* a single label, to reduce false positives when items share one of them.
|
|
15851
|
+
* a single label, to reduce false positives when items share one of them. Returns the
|
|
15852
|
+
* matched target item (or undefined) rather than a boolean, so the caller can act on the
|
|
15853
|
+
* existing record — heal its missing fields — instead of only skipping it.
|
|
15451
15854
|
*
|
|
15452
15855
|
* The fields are supplied by the caller because the models do not agree on them. The
|
|
15453
15856
|
* previous version hard-coded `title`/`description`, which do not exist on Equipment at
|
|
@@ -15455,9 +15858,95 @@ class MatterportTagCopyService {
|
|
|
15455
15858
|
* and a target space holding a single equipment silently caused the whole equipment phase
|
|
15456
15859
|
* to copy nothing.
|
|
15457
15860
|
*/
|
|
15458
|
-
|
|
15861
|
+
findExisting(source, targetList, identity) {
|
|
15459
15862
|
const sourceKey = this.identityKey(identity(source));
|
|
15460
|
-
return targetList.
|
|
15863
|
+
return targetList.find(target => this.identityKey(identity(target)) === sourceKey);
|
|
15864
|
+
}
|
|
15865
|
+
/**
|
|
15866
|
+
* Builds a patch of whichever fields in `fields` the existing target record is missing
|
|
15867
|
+
* (`null`/`undefined`/empty array) but the source has, plus `domainID` when requested —
|
|
15868
|
+
* domains are organisation-scoped everywhere in this file, so that one is only ever
|
|
15869
|
+
* healed across the SAME organisation, exactly like at creation time.
|
|
15870
|
+
*
|
|
15871
|
+
* Never overwrites a field the target already carries a value for: a value entered by
|
|
15872
|
+
* hand in the target space since an earlier, incomplete copy must not be clobbered by
|
|
15873
|
+
* an older or different source value.
|
|
15874
|
+
*
|
|
15875
|
+
* Returns both the patch to send and the list of healed field names, so the caller can
|
|
15876
|
+
* both perform the update and describe it in the copy log.
|
|
15877
|
+
*/
|
|
15878
|
+
buildHealPatch(sourceObj, existingTarget, fields, includeDomain) {
|
|
15879
|
+
const patch = {};
|
|
15880
|
+
const healedFields = [];
|
|
15881
|
+
const fillIfMissing = (key, sourceValue) => {
|
|
15882
|
+
const currentValue = existingTarget[key];
|
|
15883
|
+
const isMissing = currentValue === null || currentValue === undefined
|
|
15884
|
+
|| (Array.isArray(currentValue) && currentValue.length === 0);
|
|
15885
|
+
if (isMissing && sourceValue !== null && sourceValue !== undefined) {
|
|
15886
|
+
patch[key] = sourceValue;
|
|
15887
|
+
healedFields.push(key);
|
|
15888
|
+
}
|
|
15889
|
+
};
|
|
15890
|
+
for (const field of fields) {
|
|
15891
|
+
fillIfMissing(field, sourceObj[field]);
|
|
15892
|
+
}
|
|
15893
|
+
if (includeDomain && (existingTarget.domainID === null || existingTarget.domainID === undefined)) {
|
|
15894
|
+
const resolvedDomainId = this.resolveDomainID(sourceObj.domainID);
|
|
15895
|
+
if (resolvedDomainId) {
|
|
15896
|
+
patch.domainID = resolvedDomainId;
|
|
15897
|
+
healedFields.push('domainID');
|
|
15898
|
+
}
|
|
15899
|
+
}
|
|
15900
|
+
return { patch, healedFields };
|
|
15901
|
+
}
|
|
15902
|
+
/**
|
|
15903
|
+
* Fills in whichever identity field a PRE-EXISTING target equipment is missing but the
|
|
15904
|
+
* source has — see the call site in the EQUIPMENTS phase for why re-running the copy
|
|
15905
|
+
* has to be able to repair a previously-copied equipment, not just skip it again.
|
|
15906
|
+
*
|
|
15907
|
+
* A failed update is left to bubble up to the caller's existing per-item try/catch, the
|
|
15908
|
+
* same as a failed creation — it gets the same visible error line, rather than a silent
|
|
15909
|
+
* console warning.
|
|
15910
|
+
*
|
|
15911
|
+
* Returns the list of fields that were actually healed (empty if nothing was missing).
|
|
15912
|
+
*/
|
|
15913
|
+
async healEquipmentIdentity(sourceObj, existingTarget) {
|
|
15914
|
+
const { patch, healedFields } = this.buildHealPatch(sourceObj, existingTarget, ['status', 'annotation', 'family', 'sku', 'manufacturer', 'installDate', 'warrantyEndDate', 'metadata', 'isCreatedByAI'], true);
|
|
15915
|
+
if (!existingTarget.photo && sourceObj.photo) {
|
|
15916
|
+
patch.photo = await this.copyS3KeyIfPresent(sourceObj.photo, `equipment photo "${sourceObj.name}"`);
|
|
15917
|
+
healedFields.push('photo');
|
|
15918
|
+
}
|
|
15919
|
+
if (healedFields.length === 0) {
|
|
15920
|
+
return healedFields;
|
|
15921
|
+
}
|
|
15922
|
+
patch.id = existingTarget.id;
|
|
15923
|
+
await this.equipmentService.updateEquipment(patch);
|
|
15924
|
+
return healedFields;
|
|
15925
|
+
}
|
|
15926
|
+
/**
|
|
15927
|
+
* Same idea as healEquipmentIdentity(), for a pre-existing ticket. `title`/`description`
|
|
15928
|
+
* are the pair findExisting() matched on to find this ticket in the first place, so they
|
|
15929
|
+
* are never part of the heal — by construction, they already agree with the source.
|
|
15930
|
+
*/
|
|
15931
|
+
async healTicketIdentity(sourceObj, existingTarget) {
|
|
15932
|
+
const { patch, healedFields } = this.buildHealPatch(sourceObj, existingTarget, ['friendlyLabel', 'status', 'type', 'priority', 'level', 'metadata', 'read', 'projectID', 'problemCode'], true);
|
|
15933
|
+
if (healedFields.length === 0) {
|
|
15934
|
+
return healedFields;
|
|
15935
|
+
}
|
|
15936
|
+
patch.id = existingTarget.id;
|
|
15937
|
+
await this.ticketService.updateTicket(patch);
|
|
15938
|
+
return healedFields;
|
|
15939
|
+
}
|
|
15940
|
+
/** Same idea, for a pre-existing feature. Keywords, media and POIs are not healed here
|
|
15941
|
+
* for the same reason notes/POIs are not healed on equipment — see healEquipmentIdentity(). */
|
|
15942
|
+
async healFeatureIdentity(sourceObj, existingTarget) {
|
|
15943
|
+
const { patch, healedFields } = this.buildHealPatch(sourceObj, existingTarget, ['shortDescription', 'level', 'type', 'index', 'stripeProductID', 'stripePriceID', 'bookingPriceMetadata', 'locale'], true);
|
|
15944
|
+
if (healedFields.length === 0) {
|
|
15945
|
+
return healedFields;
|
|
15946
|
+
}
|
|
15947
|
+
patch.id = existingTarget.id;
|
|
15948
|
+
await this.featureService.updateFeature(patch);
|
|
15949
|
+
return healedFields;
|
|
15461
15950
|
}
|
|
15462
15951
|
/** Serialises the identifying fields unambiguously, so that ["a", "b"] and ["ab", ""]
|
|
15463
15952
|
* can never produce the same key. */
|
|
@@ -15734,13 +16223,13 @@ class MatterportTagCopyService {
|
|
|
15734
16223
|
// for the CopySource header. Do NOT re-encode it here.
|
|
15735
16224
|
return extracted;
|
|
15736
16225
|
}
|
|
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:
|
|
16226
|
+
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: MissionService }, { token: OperationService }, { token: DomainService }, { token: i1.TranslateService }], target: i0.ɵɵFactoryTarget.Injectable });
|
|
15738
16227
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagCopyService, providedIn: 'root' });
|
|
15739
16228
|
}
|
|
15740
16229
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagCopyService, decorators: [{
|
|
15741
16230
|
type: Injectable,
|
|
15742
16231
|
args: [{ providedIn: 'root' }]
|
|
15743
|
-
}], ctorParameters: () => [{ type: NavigationService }, { type: BaseUserService }, { type: PoiService }, { type: TicketsService }, { type: EquipmentService }, { type: Object3DService }, { type: FeatureService }, { type: MeasurementService }, { type: CommentService }, { type: HashtagService }, { type:
|
|
16232
|
+
}], ctorParameters: () => [{ type: NavigationService }, { type: BaseUserService }, { type: PoiService }, { type: TicketsService }, { type: EquipmentService }, { type: Object3DService }, { type: FeatureService }, { type: MeasurementService }, { type: CommentService }, { type: HashtagService }, { type: MissionService }, { type: OperationService }, { type: DomainService }, { type: i1.TranslateService }] });
|
|
15744
16233
|
|
|
15745
16234
|
/* eslint-disable class-methods-use-this */
|
|
15746
16235
|
class OrganisationService {
|
|
@@ -15814,81 +16303,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
15814
16303
|
args: ['currentAPIService']
|
|
15815
16304
|
}] }] });
|
|
15816
16305
|
|
|
15817
|
-
class DomainService {
|
|
15818
|
-
API; //AWS
|
|
15819
|
-
constructor(apiInjected) {
|
|
15820
|
-
this.API = apiInjected;
|
|
15821
|
-
}
|
|
15822
|
-
async getGenericDomainsAndForOrganisation(organisationID) {
|
|
15823
|
-
return this.API.__proto__.ListDomains().then((domains) => {
|
|
15824
|
-
return domains.items.filter((dom) => {
|
|
15825
|
-
return (dom.organisationID === organisationID || !dom.organisationID);
|
|
15826
|
-
});
|
|
15827
|
-
});
|
|
15828
|
-
}
|
|
15829
|
-
async getGenericDomainsAndForOrgAndOrderedOrg(orgID, orderedOrgID = null) {
|
|
15830
|
-
// TODO: Change this (UUID can change depending on DB)
|
|
15831
|
-
if (orgID === "6e28201f-4679-4c29-9d2d-8a325f57cee9") {
|
|
15832
|
-
// I am SmarterPlan, i can see all
|
|
15833
|
-
return this.API.__proto__.ListDomains().then((response) => response.items);
|
|
15834
|
-
}
|
|
15835
|
-
const currentOrgDomains = await this.getGenericDomainsAndForOrganisation(orgID);
|
|
15836
|
-
if (!orderedOrgID || orgID === orderedOrgID) {
|
|
15837
|
-
return currentOrgDomains;
|
|
15838
|
-
}
|
|
15839
|
-
const orderedOrgDomains = await this.getGenericDomainsAndForOrganisation(orderedOrgID);
|
|
15840
|
-
const merged = [...currentOrgDomains, ...orderedOrgDomains];
|
|
15841
|
-
return filterUniqueArrayByID(merged);
|
|
15842
|
-
}
|
|
15843
|
-
async getDomainsForOrgExcludingTypes(types, organisationID) {
|
|
15844
|
-
return this.getGenericDomainsAndForOrganisation(organisationID).then((domains) => {
|
|
15845
|
-
return domains.filter((dom) => !types.includes(dom.type));
|
|
15846
|
-
});
|
|
15847
|
-
}
|
|
15848
|
-
async getDomain(id) {
|
|
15849
|
-
return this.API.__proto__.GetDomain(id);
|
|
15850
|
-
}
|
|
15851
|
-
async updateDomain(input) {
|
|
15852
|
-
return this.API.__proto__.UpdateDomain(input);
|
|
15853
|
-
}
|
|
15854
|
-
async deleteDomain(id) {
|
|
15855
|
-
return this.API.__proto__.DeleteDomain({ id });
|
|
15856
|
-
}
|
|
15857
|
-
async getRootDomains(orgID) {
|
|
15858
|
-
const allDomains = await this.getGenericDomainsAndForOrganisation(orgID);
|
|
15859
|
-
const root = allDomains.filter((dom) => !dom.parentID);
|
|
15860
|
-
return root.sort((a, b) => a.name.localeCompare(b.name));
|
|
15861
|
-
}
|
|
15862
|
-
async createDomain(input) {
|
|
15863
|
-
return this.API.__proto__.CreateDomain(input);
|
|
15864
|
-
}
|
|
15865
|
-
async getDomainsForOrgForType(type, organisationID, orderedOrg = null) {
|
|
15866
|
-
return this.getGenericDomainsAndForOrgAndOrderedOrg(organisationID, orderedOrg).then((domains) => {
|
|
15867
|
-
return domains.filter((dom) => dom.type === type);
|
|
15868
|
-
});
|
|
15869
|
-
}
|
|
15870
|
-
async getParentDomainsForOrgAndOrderedOrgForType(type, organisationID, orderedOrg = null) {
|
|
15871
|
-
return this.getGenericDomainsAndForOrgAndOrderedOrg(organisationID, orderedOrg).then((domains) => {
|
|
15872
|
-
return domains.filter((dom) => dom.type === type && !dom.parentID);
|
|
15873
|
-
});
|
|
15874
|
-
}
|
|
15875
|
-
async getDomainsForMissionsForOrg(orgID) {
|
|
15876
|
-
const domains = await this.getDomainsForOrgExcludingTypes([DomainType.BOOKING, DomainType.FEATURE, DomainType.MISSION], orgID);
|
|
15877
|
-
return domains.filter((dom) => !dom.parentID);
|
|
15878
|
-
}
|
|
15879
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DomainService, deps: [{ token: 'currentAPIService' }], target: i0.ɵɵFactoryTarget.Injectable });
|
|
15880
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DomainService, providedIn: "root" });
|
|
15881
|
-
}
|
|
15882
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DomainService, decorators: [{
|
|
15883
|
-
type: Injectable,
|
|
15884
|
-
args: [{
|
|
15885
|
-
providedIn: "root",
|
|
15886
|
-
}]
|
|
15887
|
-
}], ctorParameters: () => [{ type: undefined, decorators: [{
|
|
15888
|
-
type: Inject,
|
|
15889
|
-
args: ['currentAPIService']
|
|
15890
|
-
}] }] });
|
|
15891
|
-
|
|
15892
16306
|
class TemplateService {
|
|
15893
16307
|
API; //AWS
|
|
15894
16308
|
constructor(apiInjected) {
|