-
-
Notifications
You must be signed in to change notification settings - Fork 187
Expand file tree
/
Copy pathutils.js
More file actions
1462 lines (1334 loc) · 50.6 KB
/
utils.js
File metadata and controls
1462 lines (1334 loc) · 50.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import {sfConn, apiVersion} from "./inspector.js";
// Browser polyfill for cross-browser compatibility
if (typeof browser === "undefined") {
// eslint-disable-next-line no-var
var browser = chrome;
}
export class Constants {
static PromptTemplateSOQL = "GenerateSOQL";
static PromptTemplateFlow = "DescribeFlow";
static PromptTemplateDebugLog = "AnalyzeDebugLog";
static PromptTemplateFormula = "FormulaHelper";
// Consumer Key of default connected app
static DEFAULT_CLIENT_ID = "3MVG9HB6vm3GZZR9qrol39RJW_sZZjYV5CZXSWbkdi6dd74gTIUaEcanh7arx9BHhl35WhHW4AlNUY8HtG2hs";
static ACCESS_TOKEN = "_access_token";
static CODE_VERIFIER = "_code_verifier";
static CLIENT_ID = "_clientId";
// API Statistics
static API_DEBUG_STATISTICS_MODE = "apiDebugStatisticsMode";
static API_DEBUG_STATISTICS = "apiDebugStatistics";
// Cache Keys
static CACHE_SOBJECTS_LIST = "sobjectsList";
// CustomEvent: dispatched when sobjects list is refreshed in background
static SOBJECTS_LIST_REFRESHED_EVENT = "sobjectsListRefreshed";
// Options
static PRELOAD_SOBJECTS_BEFORE_POPUP = "preloadSobjectsBeforePopup";
static ENABLE_SOBJECTS_LIST_CACHE = "enableSobjectsListCache";
static ENABLE_RECENTLY_VIEWED_RECORDS = "enableRecentlyViewedRecords";
static USER_SEARCH_EXCLUSIONS_KEY = "_userSearchExclusions";
/** Shared definition for "Exclude users from search (org specific)" */
static USER_SEARCH_EXCLUSIONS_CHECKBOXES = [
{label: " Exclude Portal users", name: "portal", stateKey: "excludePortalUsersFromSearch"},
{label: " Exclude Inactive users", name: "inactive", stateKey: "excludeInactiveUsersFromSearch"},
];
}
/**
* Unified storage-backed history/saved list used by data-export, rest-explore, and event-monitor.
* @param {string} storageKey - localStorage key
* @param {number} max - max entries to keep
* @param {Object} options - configuration
* @param {function(Object): boolean} [options.isValidEntry] - filter valid entries (default: objects only)
* @param {function(Object, Object): boolean} [options.matchAdd] - find existing for dedupe on add
* @param {function(Object, Object): boolean} [options.matchRemove] - find entry for remove (default: matchAdd or by key)
* @param {function(Object, Object): number} [options.sortComparator] - sort comparator (for saved lists)
* @param {boolean} [options.addToFront=true] - add new entries to front (history) or end (saved)
*/
export class StorageHistory {
constructor(storageKey, max, options = {}) {
this.storageKey = storageKey;
this.max = max;
this.options = {
isValidEntry: (e) => typeof e === "object",
matchAdd: null,
matchRemove: null,
sortComparator: null,
addToFront: true,
...options
};
this.list = this._get();
}
_get() {
let list;
try {
const stored = localStorage.getItem(this.storageKey);
list = stored ? JSON.parse(stored) : [];
} catch {
list = [];
}
if (!Array.isArray(list)) {
list = [];
}
list = list.filter(this.options.isValidEntry);
if (this.options.sortComparator) {
list.sort(this.options.sortComparator);
}
this.list = list;
return list;
}
add(entry) {
let list = this._get();
const match = this.options.matchAdd || ((e, ent) => e.key === ent.key);
const idx = list.findIndex((e) => match(e, entry));
if (idx > -1) {
list.splice(idx, 1);
}
if (this.options.addToFront) {
list.splice(0, 0, entry);
} else {
list.push(entry);
}
if (this.options.sortComparator) {
list.sort(this.options.sortComparator);
}
if (list.length > this.max) {
list.pop();
}
localStorage.setItem(this.storageKey, JSON.stringify(list));
this.list = list;
}
remove(entry) {
let list = this._get();
const match = this.options.matchRemove || this.options.matchAdd || ((e, ent) => e.key === ent.key);
const idx = list.findIndex((e) => match(e, entry));
if (idx > -1) {
list.splice(idx, 1);
}
if (this.options.sortComparator) {
list.sort(this.options.sortComparator);
}
localStorage.setItem(this.storageKey, JSON.stringify(list));
this.list = list;
}
clear() {
localStorage.removeItem(this.storageKey);
this.list = [];
}
}
/**
* Mapping of standard Salesforce objects to their name fields.
* Objects with "Name" field are not included (assumed default).
* Objects with null have no nameField property (e.g., Event, Task use Subject).
* Objects with a string value have a different nameField than "Name".
*
* This list helps optimize queries by avoiding unnecessary describe API calls.
*/
export const STANDARD_OBJECT_NAME_FIELDS = {
// Objects with non-standard name fields or no name field
"AccountContactRelation": null,
"AccountContactRole": null,
"AccountPartner": null,
"CampaignMember": null,
"CampaignMemberStatus": null,
"Case": "CaseNumber",
"CaseContactRole": null,
"CaseMilestone": null,
"CaseSolution": null,
"CaseStatus": "ApiName",
"ChangeRequest": "ChangeRequestNumber",
"ContentAsset": "DeveloperName",
"ContentBody": null,
"ContentDistributionView": null,
"ContentDocument": "Title",
"ContentDocumentLink": null,
"ContentDocumentSubscription": null,
"ContentFolderItem": null,
"ContentFolderLink": null,
"ContentFolderMember": null,
"ContentNote": "Title",
"ContentNotification": null,
"ContentTagSubscription": null,
"ContentTaxonomyRelatedTerm": null,
"ContentTaxonomyTermRelatedTerm": null,
"ContentUserSubscription": null,
"ContentVersion": "Title",
"ContentVersionComment": null,
"ContentVersionRating": null,
"ContentWorkspaceDoc": null,
"ContentWorkspaceMember": null,
"ContentWorkspaceSubscription": null,
"Contract": "ContractNumber",
"ContractContactRole": null,
"ContractGroupPlanAttribute": "AttributeName",
"ContractGrpPlanGrpClsAttr": "AttributeName",
"ContractLineItem": "LineItemNumber",
"ContractStatus": "ApiName",
"ContractType": "DeveloperName",
"ContractTypeConfig": null,
"Event": "Subject",
"Expense": "ExpenseNumber",
"ExpenseReport": "ExpenseReportNumber",
"ExpenseReportEntry": "ExpenseReportEntryNumber",
"GroupMember": null,
"LeadStatus": "ApiName",
"Note": "Title",
"OpportunityCompetitor": null,
"OpportunityContactRole": null,
"OpportunityHistory": null,
"OpportunityLineItemSchedule": null,
"OpportunityPartner": null,
"OpportunityRelatedDeleteLog": "DeleteLog",
"OpportunityStage": "ApiName",
"Order": "OrderNumber",
"OrderItem": "OrderItemNumber",
"OrderStatus": "ApiName",
"Partner": null,
"PartnerRole": "ApiName",
"ProductEntitlementTemplate": null,
"ProductItem": "ProductItemNumber",
"ProductItemTransaction": "ProductItemTransactionNumber",
"ProductRequest": "ProductRequestNumber",
"ProductRequestLineItem": "ProductRequestLineItemNumber",
"ProductRequired": "ProductRequiredNumber",
"ProductServiceCampaign": "ProductServiceCampaignName",
"ProductServiceCampaignItem": "ProductServiceCampaignItemNumber",
"ProductServiceCampaignItemStatus": "ApiName",
"ProductServiceCampaignStatus": "ApiName",
"ProductTransfer": "ProductTransferNumber",
"ProductWarrantyTerm": "ProductWarrantyTermNumber",
"QuoteLineItem": "LineNumber",
"ReturnOrder": "ReturnOrderNumber",
"ReturnOrderLineItem": "ReturnOrderLineItemNumber",
"ServiceAppointment": "AppointmentNumber",
"ServiceAppointmentCapacityUsage": "ServiceAppointmentCapacityUsageAutonumber",
"ServiceAppointmentStatus": "ApiName",
"ServiceCrewMember": "ServiceCrewMemberNumber",
"ServiceReport": "ServiceReportNumber",
"ServiceReportLayout": "MasterLabel",
"ServiceResourceCapacity": "CapacityNumber",
"ServiceResourceSkill": "SkillNumber",
"ServiceTerritoryLocation": "ServiceTerritoryLocationNumber",
"ServiceTerritoryMember": "MemberNumber",
"Shift": "ShiftNumber",
"ShiftStatus": "ApiName",
"Shipment": "ShipmentNumber",
"ShipmentItem": "ShipmentItemNumber",
"Skill": "MasterLabel",
"SkillRequirement": "SkillNumber",
"SkillType": "MasterLabel",
"Solution": "SolutionName",
"SolutionStatus": "ApiName",
"Task": "Subject",
"TaskPriority": "ApiName",
"TaskRelation": null,
"TaskStatus": "ApiName",
"TaskWhoRelation": null,
"TimeSheet": "TimeSheetNumber",
"TimeSheetEntry": "TimeSheetEntryNumber",
"TimeSlot": "TimeSlotNumber",
"TimelineObjectDefinition": "DeveloperName",
"Vote": null,
"WarrantyTerm": "WarrantyTermName",
"WorkAccess": null,
"WorkBadge": null,
"WorkCapacityAvailability": "WorkCapacityAvailNumber",
"WorkCapacityLimit": "WorkCapacityLimitNumber",
"WorkCapacityUsage": "WorkCapacityUsageNumber",
"WorkOrder": "WorkOrderNumber",
"WorkOrderLineItem": "LineItemNumber",
"WorkOrderLineItemStatus": "ApiName",
"WorkOrderStatus": "ApiName",
"WorkPlanSelectionRule": "WorkPlanSelectionRuleNumber",
"WorkPlanTemplateEntry": "WorkPlanTemplateEntryNumber",
"WorkStepStatus": "ApiName",
"WorkThanks": null,
// Custom metadata types ending with __mdt use "DeveloperName"
"CustomMetadataType": "DeveloperName", // For __mdt objects
};
/**
* Determines if the org should be treated as production (for styling/warnings).
* Returns false for sandbox, trial orgs, and Developer Edition orgs.
* @param {string} sfHost - Salesforce host (e.g. "myorg.lightning.force.com")
* @returns {boolean} True if production org, false otherwise
*/
export function isProductionOrg(sfHost) {
const isSandbox = localStorage.getItem(sfHost + "_isSandbox") === "true";
const trialExpDate = localStorage.getItem(sfHost + "_trialExpirationDate");
if (isSandbox || (trialExpDate && trialExpDate !== "null")) {
return false;
}
const orgInfo = JSON.parse(sessionStorage.getItem(sfHost + "_orgInfo") || "null");
if (orgInfo?.OrganizationType === "Developer Edition") {
return false;
}
return true;
}
/**
* Applies production styling (sfir-prod class) to document.body when the org is production.
* Developer Edition orgs are not considered production.
* @param {string} sfHost - Salesforce host (e.g. "myorg.lightning.force.com")
* @returns {boolean} True if production styling was applied, false otherwise
*/
export function applyProductionStyling(sfHost) {
if (isProductionOrg(sfHost)) {
document.body.classList.add("sfir-prod");
return true;
}
return false;
}
export function getLinkTarget(e = {}) {
if (localStorage.getItem("openLinksInNewTab") == "true" || (e.ctrlKey || e.metaKey)) {
return "_blank";
} else {
return "_top";
}
}
export function nullToEmptyString(value) {
// For react input fields, the value may not be null or undefined, so this will clean the value
return (value == null) ? "" : value;
}
export function isOptionEnabled(optionName, optionsArray){
const option = optionsArray?.find((element) => element.name == optionName);
if (option){
return option.checked;
}
//if no option was found, enable by default
return true;
}
export function isSettingEnabled(settingName, defaultValue = false){
const value = localStorage.getItem(settingName);
if (value === null) {
return defaultValue;
}
return value === "true";
}
export async function getLatestApiVersionFromOrg(sfHost) {
let latestApiVersionFromOrg = sessionStorage.getItem(sfHost + "_latestApiVersionFromOrg");
if (latestApiVersionFromOrg != null) {
return latestApiVersionFromOrg;
} else {
const res = await sfConn.rest("services/data/");
latestApiVersionFromOrg = res[res.length - 1].version; //Extract the value of the last version
sessionStorage.setItem(sfHost + "_latestApiVersionFromOrg", latestApiVersionFromOrg);
return latestApiVersionFromOrg;
}
}
export async function setOrgInfo(sfHost) {
let orgInfo = JSON.parse(sessionStorage.getItem(sfHost + "_orgInfo"));
if (orgInfo == null) {
const res = await sfConn.rest("/services/data/v" + apiVersion + "/query/?q=SELECT+Id,InstanceName,OrganizationType+FROM+Organization");
orgInfo = res.records[0];
sessionStorage.setItem(sfHost + "_orgInfo", JSON.stringify(orgInfo));
}
return orgInfo;
}
export async function getUserInfo() {
try {
const res = await sfConn.soap(sfConn.wsdl(apiVersion, "Partner"), "getUserInfo", {});
return {
success: true,
userInfo: res.userFullName + " / " + res.userName + " / " + res.organizationName,
userFullName: res.userFullName,
userInitials: res.userFullName.split(" ").map(n => n[0]).join(""),
userName: res.userName,
userError: null,
userErrorDescription: null
};
} catch (error) {
console.error("Error fetching user info:", error);
return {
success: false,
userInfo: "Error loading user info",
userFullName: "Unknown User",
userInitials: "?",
userName: "Unknown",
userError: "Error fetching user info",
userErrorDescription: "Session is probably expired or invalid"
};
}
}
/**
* UserInfoModel - Centralized user information management
* This class handles fetching and storing user information for any page.
*
* Usage:
* ```
* class Model {
* constructor(sfHost) {
* this.userInfoModel = new UserInfoModel(this.spinFor.bind(this));
* }
* }
*
* // In render:
* h(PageHeader, {
* ...this.userInfoModel.getProps(),
* // other props
* })
* ```
*/
export class UserInfoModel {
constructor(spinForCallback) {
// Initialize with loading state
this.userInfo = "...";
this.userFullName = "";
this.userInitials = "";
this.userName = "";
this.userError = null;
this.userErrorDescription = null;
// Fetch user info
if (spinForCallback) {
spinForCallback(this.fetchUserInfo());
} else {
this.fetchUserInfo();
}
}
async fetchUserInfo() {
const result = await getUserInfo();
// Update all properties from result
this.userInfo = result.userInfo;
this.userFullName = result.userFullName;
this.userInitials = result.userInitials;
this.userName = result.userName;
this.userError = result.userError;
this.userErrorDescription = result.userErrorDescription;
}
/**
* Get props object for PageHeader component
* @returns {Object} Props containing userInitials, userFullName, userName, userError, userErrorDescription
*/
getProps() {
return {
userInitials: this.userInitials,
userFullName: this.userFullName,
userName: this.userName,
userError: this.userError,
userErrorDescription: this.userErrorDescription
};
}
}
export class PromptTemplate {
constructor(promptName) {
this.promptName = promptName;
}
async generate(params = {}) {
const jsonBody = {
isPreview: false,
inputParams: {
valueMap: Object.entries(params).reduce((acc, [key, value]) => {
acc[`Input:${key}`] = {value};
return acc;
}, {})
},
additionalConfig: {
applicationName: "PromptTemplateGenerationsInvocable"
}
};
try {
const response = await sfConn.rest(
`/services/data/v${apiVersion}/einstein/prompt-templates/${this.promptName}/generations`,
{
method: "POST",
body: jsonBody
}
);
if (response && response.generations && response.generations.length > 0) {
return {
success: true,
result: response.generations[0].text,
requestId: response.requestId,
metadata: {
promptTemplateDevName: response.promptTemplateDevName,
parameters: response.parameters,
isSummarized: response.isSummarized
}
};
}
return {
success: false,
error: "No result generated"
};
} catch (error) {
return {
success: false,
error: error.message || "Failed to generate result"
};
}
}
}
/**
* Creates a spinFor method for a model context
* This method shows a spinner while waiting for a promise.
* @param {Object} context - The model context (must have spinnerCount and didUpdate properties)
* @returns {Function} A bound spinFor method
*/
export function createSpinForMethod(context) {
return function(promise) {
context.spinnerCount++;
promise
.catch(err => {
console.error("spinFor", err);
})
.then(() => {
context.spinnerCount--;
context.didUpdate();
})
.catch(err => console.log("error handling failed", err));
};
}
// OAuth utilities
export function getBrowserType() {
return navigator.userAgent?.includes("Chrome") ? "chrome" : "moz";
}
export function getExtensionId() {
return chrome.i18n.getMessage("@@extension_id");
}
export function getClientId(sfHost) {
const storedClientId = localStorage.getItem(sfHost + Constants.CLIENT_ID);
return storedClientId || Constants.DEFAULT_CLIENT_ID;
}
export function getRedirectUri(page = "data-export.html") {
const browser = getBrowserType();
const extensionId = getExtensionId();
return `${browser}-extension://${extensionId}/${page}`;
}
// PKCE (Proof Key for Code Exchange) utilities
export async function getPKCEParameters(sfHost) {
try {
const response = await fetch(`https://${sfHost}/services/oauth2/pkce/generator`);
if (!response.ok) {
throw new Error(`Failed to fetch PKCE parameters: ${response.status}`);
}
const data = await response.json();
return {
// eslint-disable-next-line camelcase
code_verifier: data.code_verifier,
// eslint-disable-next-line camelcase
code_challenge: data.code_challenge
};
} catch (error) {
console.error("Error fetching PKCE parameters:", error);
throw error;
}
}
// Copy text to the clipboard, without rendering it, since rendering is slow.
export function copyToClipboard(value) {
// Use execCommand to trigger an oncopy event and use an event handler to copy the text to the clipboard.
// The oncopy event only works on editable elements, e.g. an input field.
let temp = document.createElement("input");
// The oncopy event only works if there is something selected in the editable element.
temp.value = "temp";
temp.addEventListener("copy", e => {
e.clipboardData.setData("text/plain", value);
e.preventDefault();
});
document.body.appendChild(temp);
try {
// The oncopy event only works if there is something selected in the editable element.
temp.select();
// Trigger the oncopy event
let success = document.execCommand("copy");
if (!success) {
alert("Copy failed");
}
} finally {
document.body.removeChild(temp);
}
}
/**
* Generates a URL for the Flow Compare page in Salesforce Flow Builder.
* @param {string} sfHost - The Salesforce host URL (e.g., "myorg.lightning.force.com").
* @param {string} recordId - The flow version record ID (18-character Salesforce ID).
* @returns {string} The complete URL for the Flow Compare page.
*/
export function getFlowCompareUrl(sfHost, recordId) {
return `https://${sfHost}/builder_platform_interaction/flowBuilder.app?flowId=${recordId}&compareTargetFlowId=${recordId}`;
}
/**
* Downloads a CSV file with optional UTF-8 BOM for Excel compatibility
* @param {string} csvContent - The CSV content to download
* @param {string} filename - The filename for the downloaded file
*/
export function downloadCsvFile(csvContent, filename) {
// Add UTF-8 BOM for Excel compatibility with Hebrew and other non-Latin characters
const BOM = localStorage.getItem("useBomForCsvExport") === "true" ? "\uFEFF" : "";
const blob = new Blob([BOM + csvContent], {type: "text/csv;charset=utf-8;"});
const downloadAnchor = document.createElement("a");
downloadAnchor.download = filename;
downloadAnchor.href = window.URL.createObjectURL(blob);
downloadAnchor.click();
}
/**
* Get the name field for a Salesforce object.
* Checks the standard objects mapping first, then returns null to indicate
* that the describe API should be used to determine the name field.
*
* @param {string} sobjectName - The API name of the Salesforce object
* @returns {string|null|undefined} The name field API name, null if no name field exists, or undefined if not in the mapping
*/
export function getStandardObjectNameField(sobjectName) {
// Check direct mapping first
if (sobjectName in STANDARD_OBJECT_NAME_FIELDS) {
return STANDARD_OBJECT_NAME_FIELDS[sobjectName];
}
// Check for custom metadata types (end with __mdt)
if (sobjectName.endsWith("__mdt")) {
return "DeveloperName";
}
// Not in the mapping - return N/A to indicate describe API should be used
return "N/A";
}
/**
* DataCache - Generic caching utility for any JSON-serializable data
* Stores data with timestamps and provides expiration checking based on user-configured days.
*/
export class DataCache {
/**
* Get cache duration for a specific cache key (in hours) from localStorage setting
* Falls back to default (168 hours = 7 days) if cache-specific duration not set
* This is used when creating new cache entries and for UI display.
* Note: Cache validation uses the durationHours stored in the cache entry itself.
* @param {string} cacheKey - Cache key to get duration for
* @returns {number} Cache duration in hours
*/
static getCacheDurationHours(cacheKey) {
const cacheDurationHours = localStorage.getItem(`cacheDuration_${cacheKey}`);
if (cacheDurationHours !== null && cacheDurationHours !== undefined) {
const hours = parseInt(cacheDurationHours, 10);
if (!isNaN(hours) && hours >= 0) {
return hours;
}
}
// Fallback to default: 168 hours (7 days)
return 168;
}
/**
* Check if a cache entry is still valid
* @param {Object} cacheEntry - Cache entry with data, timestamp, and optionally durationHours
* @param {string} cacheKey - Cache key for per-cache expiration checking (used for fallback)
* @returns {boolean} True if cache is valid, false if expired
*/
static isCacheValid(cacheEntry, cacheKey) {
if (!cacheEntry || !cacheEntry.timestamp) {
return false;
}
// Use durationHours from cache entry if available, otherwise fallback to current setting
const cacheDurationHours = cacheEntry.durationHours !== undefined
? cacheEntry.durationHours
: this.getCacheDurationHours(cacheKey);
const now = Date.now();
const cacheAge = now - cacheEntry.timestamp;
const maxAge = cacheDurationHours * 60 * 60 * 1000; // Convert hours to milliseconds
return cacheAge < maxAge;
}
/**
* Get cached data if valid, null if expired or missing
* @param {string} cacheKey - Unique key for the cached data
* @param {string} sfHost - Salesforce host (for scoping cache per org)
* @param {boolean} isLarge - If true, use browser.storage.local (async), otherwise localStorage (sync)
* @param {boolean} useSfHostPrefix - If true, prefix storage key with sfHost (default: true)
* @returns {Promise<Object|null>|Object|null} Cached data if valid, null otherwise. Promise if isLarge=true
*/
static async getCachedData(cacheKey, sfHost, isLarge = false, useSfHostPrefix = true) {
const storageKey = useSfHostPrefix
? `${sfHost}_cache_${cacheKey}`
: `cache_${cacheKey}`;
if (isLarge) {
// Use browser.storage.local for large data
return this._getCachedDataLarge(storageKey, cacheKey, sfHost);
} else {
// Use localStorage for small data (synchronous)
return this._getCachedDataSmall(storageKey, cacheKey, sfHost);
}
}
/**
* Internal method to get cached data from localStorage (synchronous)
* @private
*/
static async _getCachedDataSmall(storageKey, cacheKey, expectedSfHost) {
const cached = localStorage.getItem(storageKey);
if (!cached) {
return null;
}
try {
const cacheEntry = JSON.parse(cached);
if (cacheEntry.compressed) {
cacheEntry.data = await this._decompressGzip(cacheEntry.data);
}
// Check if sfHost matches (for sobjectsList cache)
if (cacheEntry.sfHost && cacheEntry.sfHost !== expectedSfHost) {
// Different org cached, return null to trigger fresh fetch
// Clear old cache asynchronously (don't block)
setTimeout(() => {
localStorage.removeItem(storageKey);
}, 0);
return null;
}
if (this.isCacheValid(cacheEntry, cacheKey)) {
return cacheEntry.data;
} else {
// Cache expired, remove it
localStorage.removeItem(storageKey);
return null;
}
} catch (e) {
console.error(`Error parsing cache entry for ${cacheKey}:`, e);
localStorage.removeItem(storageKey);
return null;
}
}
/**
* Internal method to get cached data from browser.storage.local (asynchronous)
* @private
*/
static async _getCachedDataLarge(storageKey, cacheKey) {
if (typeof browser === "undefined" || !browser.storage || !browser.storage.local) {
console.warn("browser.storage.local not available");
return null;
}
try {
const result = await browser.storage.local.get(storageKey);
const cached = result[storageKey];
if (!cached) {
return null;
}
//check it the cache is valid
if (!this.isCacheValid(cached, cacheKey)) {
await browser.storage.local.remove(storageKey);
return null;
}
//uncompress the data if compressed
let data = cached.data;
if (cached.compressed) {
data = await this._decompressGzip(cached.data);
}
return {data, lastFetch: cached.lastFetch};
} catch (e) {
console.error(`Error reading large data cache for ${cacheKey}:`, e);
return null;
}
}
/**
* Store data in cache with current timestamp
* @param {string} cacheKey - Unique key for the cached data
* @param {string} sfHost - Salesforce host (for scoping cache per org)
* @param {*} data - Any JSON-serializable data to cache
* @param {boolean} isLarge - If true, use browser.storage.local (async), otherwise localStorage (sync)
* @param {boolean} useSfHostPrefix - If true, prefix storage key with sfHost (default: true)
* @param {number} lastFetch - Timestamp of the last fetch
* @returns {Promise<boolean>|void} Promise with success boolean if isLarge=true, void otherwise
*/
static async setCachedData(cacheKey, sfHost, data, isLarge = false, useSfHostPrefix = true, lastFetch = null, compression = false) {
// Get current duration setting
const durationHours = this.getCacheDurationHours(cacheKey);
const storageKey = useSfHostPrefix
? `${sfHost}_cache_${cacheKey}`
: `cache_${cacheKey}`;
//set the cache entry and compress the data if needed
const cacheEntry = {
data: compression ? await this._compressGzip(data) : data,
timestamp: Date.now(),
sfHost, // Store sfHost in cache entry for validation
durationHours, // Store duration in cache entry
lastFetch,
compressed: compression
};
if (isLarge) {
// Use browser.storage.local for large data
// Await cleanup before storing (avoids race where set runs before clear completes)
return this._clearOldOrgCache(storageKey, sfHost, cacheEntry)
.then(() => this._setCachedDataLarge(storageKey, cacheKey, cacheEntry));
} else {
// Use localStorage for small data (synchronous)
this._setCachedDataSmall(storageKey, cacheKey, cacheEntry);
return undefined;
}
}
/**
* Clear cache entries to stay under storage quota before storing new data.
* Chrome storage.local ~5MB (10MB in Chrome 114+), Firefox ~10MB.
* Removes other-org caches first, then oldest entries if still over quota.
* @private
*/
static async _clearOldOrgCache(storageKey, currentSfHost, cacheEntry = undefined) {
if (typeof browser === "undefined" || !browser.storage || !browser.storage.local) {
return;
}
const maxStorageUsage = 10 * 1024 * 1024;
try {
const getBytesInUse = browser.storage.local.getBytesInUse?.bind(browser.storage.local);
if (!getBytesInUse) {
return;
}
//retrieve the current storage usage and estimate the expected storage usage after the update
const currentStorageUsage = await getBytesInUse(null);
const cacheEntrySize = (cacheEntry?.data?.length || 0) + 100;
const keyStorageUsage = await getBytesInUse(storageKey);
let expectedStorageUsage = currentStorageUsage + cacheEntrySize - keyStorageUsage;
//we have enough space, so we don't need to remove any entries
if (expectedStorageUsage <= maxStorageUsage) {
return;
}
const allData = await browser.storage.local.get(null);
//get all the entries that are not expired and sort them by last fetch timestamp (older first)
const entries = Object.entries(allData || {})
.filter(([, v]) => v && (v.timestamp != null || v.lastFetch != null))
.sort((a, b) => (a[1].lastFetch ?? a[1].timestamp ?? 0) - (b[1].lastFetch ?? b[1].timestamp ?? 0));
const keysToRemove = [];
// Calculate which entry we will remove based on last fetch timestamp
for (const [key, value] of entries) {
if (value?.sfHost && value.sfHost !== currentSfHost) {
//if the entry is from another org, we add it to the list of keys to remove
keysToRemove.push(key);
//then we calculate if we we have enough space to store the new entry
const size = await getBytesInUse(key);
expectedStorageUsage -= size;
if (expectedStorageUsage < maxStorageUsage) {
break;
}
}
}
if (keysToRemove.length > 0) {
await browser.storage.local.remove(keysToRemove);
}
} catch (e) {
console.error(`Error clearing cache for ${storageKey}:`, e);
}
}
/**
* Internal method to store cached data in localStorage (synchronous)
* @private
*/
static _setCachedDataSmall(storageKey, cacheKey, cacheEntry) {
try {
localStorage.setItem(storageKey, JSON.stringify(cacheEntry));
} catch (e) {
console.error(`Error storing cache entry for ${cacheKey}:`, e);
}
}
/**
* Compress data with gzip and return as base64 string.
* @param {*} data - JSON-serializable data to compress
* @returns {Promise<string>} Base64-encoded gzip compressed string
* @private
*/
static async _compressGzip(data) {
const json = JSON.stringify(data);
const blob = new Blob([json], {type: "application/json"});
const stream = blob.stream().pipeThrough(new CompressionStream("gzip"));
const compressedBlob = await new Response(stream).blob();
const buffer = await compressedBlob.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = "";
const chunkSize = 8192;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize));
}
return btoa(binary);
}
/**
* Decompress base64 gzip string back to original data.
* @param {string} base64Compressed - Base64-encoded gzip compressed string
* @returns {Promise<*>} Original decompressed data
* @private
*/
static async _decompressGzip(base64Compressed) {
const binary = atob(base64Compressed);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
const decompressedBlob = await new Response(stream).blob();
const text = await decompressedBlob.text();
return JSON.parse(text);
}
/**
* Internal method to store cached data in browser.storage.local (asynchronous)
* On quota error, clears the target key (we're overwriting) and retries once.
* Compresses cacheEntry.data with gzip before storing to reduce storage usage.
* @private
*/
static async _setCachedDataLarge(storageKey, cacheKey, cacheEntry) {
if (typeof browser === "undefined" || !browser.storage || !browser.storage.local) {
console.warn("browser.storage.local not available");
return false;
}
try {
await browser.storage.local.set({[storageKey]: cacheEntry});
return true;
} catch (e) {
console.error(`Error storing large data cache for ${cacheKey}:`, e);
console.error(`Error name: ${e.name}, Error message: ${e.message}`);
return false;
}
}
/**
* Clear a specific cache entry
* @param {string} cacheKey - Unique key for the cached data
* @param {string} sfHost - Salesforce host (for scoping cache per org, not used if useSfHostPrefix is false)
* @param {boolean} isLarge - If true, clear from browser.storage.local (async), otherwise localStorage (sync)
* @param {boolean} useSfHostPrefix - If true, prefix storage key with sfHost (default: true)
* @returns {Promise<void>|void} Promise if isLarge=true, void otherwise
*/
static clearCache(cacheKey, sfHost, isLarge = false, useSfHostPrefix = true) {
const storageKey = useSfHostPrefix
? `${sfHost}_cache_${cacheKey}`
: `cache_${cacheKey}`;
if (isLarge) {
// Clear from browser.storage.local
return this._clearCacheLarge(storageKey);
} else {
// Clear from localStorage
if (useSfHostPrefix) {
// Direct removal for sfHost-prefixed keys
localStorage.removeItem(storageKey);
} else {
// Iterate through all localStorage keys to find and remove matching cache entries
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.endsWith(`_cache_${cacheKey}`)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(key => localStorage.removeItem(key));
}
return undefined;
}
}
/**
* Internal method to clear cached data from browser.storage.local (asynchronous)
* @private
* @param {string} storageKey - The exact storage key to remove
*/
static async _clearCacheLarge(storageKey) {
if (typeof browser !== "undefined" && browser.storage && browser.storage.local) {
// Direct removal using exact storage key (works for both prefixed and non-prefixed keys)
await browser.storage.local.remove(storageKey);
}
}
/**
* Clear ALL extension cache entries from both localStorage and browser.storage.local
* Clears all cache entries regardless of host or cache key
* @returns {Promise<void>}
*/
static async clearAllExtensionCache() {
const keysToRemove = [];
// Collect all cache-related keys from localStorage
// Patterns: *_cache_* or cache_* or cacheDuration_*
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.includes("_cache_") || key.startsWith("cache_") || key.startsWith("cacheDuration_"))) {
keysToRemove.push(key);
}
}