-
Notifications
You must be signed in to change notification settings - Fork 83
Expand file tree
/
Copy pathdev.js
More file actions
1062 lines (977 loc) · 42 KB
/
Copy pathdev.js
File metadata and controls
1062 lines (977 loc) · 42 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
// @flow
/**
* Development-related helper functions.
*
* Logging vs sync/async `DataStore.settings` (plugin JS vs HTML/React WebView):
* - In the main plugin context, `DataStore.settings` is typically a plain object, so reading `_logLevel` for
* `shouldOutputForLogLevel` is synchronous and immediate.
* - In HTML/React windows, `DataStore.settings` may be thenable (Promise-like). Synchronous code cannot read
* `_logLevel` until it resolves. This file uses an in-memory cache filled by a one-shot background `await`
* (`getPluginSettingsForLogging`, `primePluginSettingsCacheViaAwait`). Until the cache is populated,
* log gating uses the default threshold (DEBUG) so early lines are not silenced. After settings resolve,
* `_logLevel` from the real object applies. If the first logs look noisier than afterward or the level
* seems to "kick in" shortly after load, that is this bootstrap window - not necessarily a wrong user setting.
*
* Aside from logging helpers, most functions here intentionally avoid `DataStore.*`.
*/
import isEqual from 'lodash-es/isEqual'
import isObject from 'lodash-es/isObject'
import isArray from 'lodash-es/isArray'
import moment from 'moment/min/moment-with-locales'
import { awaitTopLevelApiProp } from './npBridgeResolve'
/**
* NotePlan API properties which should not be traversed when stringifying an object
*/
const PARAM_BLACKLIST = ['note', 'referencedBlocks', 'availableThemes', 'currentTheme', 'linkedNoteTitles', 'linkedItems'] // fields not to be traversed (e.g. circular references)
export const dt = (): string => {
const d = new Date()
const pad = (value: number): string => {
return value < 10 ? `0${value}` : value.toString()
}
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${d.toLocaleTimeString('en-GB')}`
}
/**
* Returns a local datetime timestamp with milliseconds.
* If a Date object is provided, it formats that date instead.
*
* @param {Date} [date] - Optional Date object to format.
* @returns {string} Formatted datetime string.
*/
export const dtl = (date?: Date): string => {
const momentDate = date ? moment(date) : moment()
return momentDate.format('YYYY-MM-DD HH:mm:ss.SSS')
}
/**
* JSON.stringify() with support for Prototype properties
* @author @dwertheimer
*
* @param {object} obj
* @param {string | number} space - A String or Number of spaces that's used to insert white space (including indentation, line break characters, etc.) into the output JSON string for readability purposes.
* @returns {string} stringified object
* @example console.log(JSP(obj, '\t')) // prints the full object with newlines and tabs for indentation
*/
export function JSP(obj: any, space: string | number = 2): string {
// CRITICAL: Check for null explicitly (typeof null === 'object' in JavaScript!)
if (obj === null || obj === undefined) {
return String(obj === null ? 'null' : 'undefined')
}
if (typeof obj !== 'object' || obj instanceof Date) {
return String(obj)
} else {
if (Array.isArray(obj)) {
const arrInfo = []
let isValues = false
obj.forEach((item, i) => {
// Check for null explicitly before processing as object
if (item === null || item === undefined) {
arrInfo.push(`[${i}] = ${item === null ? 'null' : 'undefined'}`)
} else if (typeof item === 'object') {
arrInfo.push(`[${i}] = ${JSP(item, space)}`)
} else {
isValues = true
arrInfo.push(`${item}`)
}
})
return `${isValues ? '[' : ''}${arrInfo.join(isValues ? ', ' : ',\n')}${isValues ? ']' : ''}`
}
const propNames = getFilteredProps(obj)
const fullObj = propNames.reduce((acc: Object, propName: string) => {
if (!/^__/.test(propName)) {
if (Array.isArray(obj[propName])) {
try {
if (PARAM_BLACKLIST.indexOf(propName) === -1) {
acc[propName] = obj[propName].map((x: any) => {
if (typeof x === 'object' && !(x instanceof Date)) {
return JSP(x, '')
} else {
return x
}
})
} else {
acc[propName] = obj[propName] //do not traverse any further
}
} catch (error) {
logDebug(
'helpers/dev',
`Caught error in JSP for propname=${propName} : ${error} typeof obj[propName]=${typeof obj[propName]} isArray=${String(Array.isArray(obj[propName]))} len=${
obj[propName]?.length
} \n VALUE: ${JSON.stringify(obj[propName])}`,
)
}
} else {
acc[propName] = obj[propName]
}
}
return acc
}, {})
// return cleanStringifiedResults(JSON.stringify(fullObj, null, space ?? null))
return typeof fullObj === 'object' && !(fullObj instanceof Date) ? JSON.stringify(fullObj, null, space ?? null) : 'date'
}
}
/**
* Returns whether an object is empty
* From https://stackoverflow.com/a/679937/3238281
* @param {Object} obj
* @returns
*/
export function isObjectEmpty(obj: Object): boolean {
return Object.keys(obj).length === 0
}
/**
* Remove quoted and escaped characters from a string
* @param {*} str
* @returns
*/
export function cleanStringifiedResults(str: string): string {
let retStr = str
retStr = retStr.replace(/","/gm, ',')
retStr = retStr.replace(/"\{"/gm, '{').replace(/"\}"/gm, '}')
retStr = str.replace(/\\n/gm, '\n')
// retStr = retStr.replace(/\\"/gm, '"')
retStr = retStr.replace(/\\"/gm, '"')
// retStr = str.replace(/\\n/gm, '\n')
return retStr
}
/**
* Console.logs all property names/values of an object to console with text preamble
* @author @dwertheimer
*
* @param {object} obj - array or object
* @param {string} preamble - (optional) text to prepend to the output
* @param {string | number} space - A String or Number of spaces that's used to insert white space (including indentation, line break characters, etc.) into the output JSON string for readability purposes.
* @example clo(obj, 'myObj:')
*/
export function clo(obj: any, preamble: string = '', space: string | number = 2): void {
if (!obj) {
logDebug(preamble, `null`)
return
}
if (typeof obj !== 'object') {
logDebug(preamble, `${obj}`)
} else {
logDebug(preamble, JSP(obj, space))
}
}
/**
* Console.logs variable and its type
* @author @jgclark
*
* @param {object} obj - array or object
* @param {string} preamble - (optional) text to prepend to the output
* @example clvt(obj, 'myObj:')
*/
export function clvt(obj: any, preamble: string = ''): void {
if (obj == null) {
console.log(`${preamble} null`)
return
}
if (typeof obj !== 'object') {
console.log(`${preamble} ${typeof obj}: ${obj}`)
} else {
console.log(`${preamble} ${typeof obj}: ${JSP(obj)}`)
}
}
type DiffValue = { before: any, after: any } | DiffObject | DiffArray
type DiffObject = { [key: string]: DiffValue }
type DiffArray = Array<DiffValue | null>
/**
* Compare two objects or arrays and return an object containing only the NEW properties that have changed.
* Note: dbw created a version below called getDiff that gives before and after values.
* Fields listed in fieldsToIgnore are ignored when comparing objects (does not apply to arrays).
*
* @param {Object|Array} oldObj - The original object or array to compare against.
* @param {Object|Array} newObj - The new object or array with potential changes.
* @param {Array<string | RegExp>} fieldsToIgnore - An array of field names to ignore when comparing objects.
* @param {boolean} logDiffDetails - If true, will log details of the differences.
* @returns {Object|Array|null} - An object or array containing only the properties that have changed, or null if no changes.
*/
export function compareObjects(oldObj: any, newObj: any, fieldsToIgnore: Array<string | RegExp> = [], logDiffDetails: boolean = false): any | null {
if (oldObj === newObj) {
return null // No changes
}
if (typeof oldObj !== typeof newObj) {
// logDebug('compareObjects', 'Objects are of different types.')
return newObj // Type has changed, consider as changed
}
if (Array.isArray(newObj)) {
if (!Array.isArray(oldObj)) {
logDebug('compareObjects', 'Changed from non-array to array.')
return newObj // Changed from non-array to array
}
const differences = []
const maxLength = Math.max(oldObj.length, newObj.length)
for (let i = 0; i < maxLength; i++) {
const oldVal = oldObj[i]
const newVal = newObj[i]
const diff = compareObjects(oldVal, newVal, fieldsToIgnore)
if (diff !== null) {
logDiffDetails && logDebug('compareObjects', `Array difference at index ${i}: ${JSON.stringify(diff)}`)
differences[i] = diff
}
}
return differences.length > 0 ? differences : null
// $FlowFixMe[invalid-compare]
} else if (typeof newObj === 'object' && newObj !== null) {
if (typeof oldObj !== 'object' || oldObj === null) {
logDiffDetails && logDebug('compareObjects', 'Changed from non-object to object.')
return newObj // Changed from non-object to object
}
const differences: { [string]: any } = {}
const keys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)])
for (const key of keys) {
// Check if the key should be ignored
const shouldIgnore = fieldsToIgnore.some((ignore) => {
if (typeof ignore === 'string') {
return key === ignore
} else if (ignore instanceof RegExp) {
return ignore.test(key)
}
return false
})
if (shouldIgnore) {
continue // Ignore fields listed in fieldsToIgnore
}
const oldVal = oldObj[key]
const newVal = newObj[key]
const diff = compareObjects(oldVal, newVal, fieldsToIgnore)
if (diff !== null) {
logDiffDetails && logDebug('compareObjects', `Object difference: value[${key}]= "${oldVal}" !== "${newVal}"`)
differences[key] = diff
}
}
return Object.keys(differences).length > 0 ? differences : null
} else {
// Primitives
const result = oldObj !== newObj ? newObj : null
if (result !== null) {
logDiffDetails && logDebug('compareObjects', `Primitive difference: oldVal=${oldObj}, newVal=${newObj}`)
}
return result
}
}
/**
* Deeply compares values, potentially recursively if they are objects.
* Logs differences with a path to the differing property.
* Note: suggested by ChatGPT.
* @param {any} value1 The first value to compare.
* @param {any} value2 The second value to compare.
* @param {string} path The base path to the property being compared.
*/
export function deepCompare(value1: any, value2: any, path: string): void {
// $FlowFixMe[constant-condition]
if (isObject(value1) && isObject(value2)) {
const keys1 = Object.keys(value1)
const keys2 = Object.keys(value2)
const allKeys = new Set([...keys1, ...keys2])
allKeys.forEach((key) => {
if (!(key in value1)) {
logDebug('deepCompare', `Property ${path}.${key} is missing in the first object value`)
} else if (!(key in value2)) {
logDebug('deepCompare', `Property ${path}.${key} is missing in the second object value`)
} else {
deepCompare(value1[key], value2[key], `${path}.${key}`)
}
})
} else if (value1 !== value2) {
logDebug(`Value difference at ${path}: ${value1} vs ${value2}`)
}
}
/**
* Compares two objects and returns the differences.
* @param {Object} obj1 - The original object.
* @param {Object} obj2 - The modified object.
* @returns {Object|null} - An object representing the differences or null if no differences.
*/
function getObjectDiff(obj1: any, obj2: any): DiffObject | null {
const diff: { [string]: any } = {}
const keys = new Set([...Object.keys(obj1), ...Object.keys(obj2)])
keys.forEach((key) => {
const val1 = obj1[key]
const val2 = obj2[key]
if (!isEqual(val1, val2)) {
// $FlowFixMe[constant-condition]
if (isObject(val1) && isObject(val2) && !isArray(val1) && !isArray(val2)) {
// Recursively find differences in nested objects
const nestedDiff = getObjectDiff(val1, val2)
if (nestedDiff !== null) {
diff[key] = nestedDiff
}
// $FlowFixMe[constant-condition]
} else if (isArray(val1) && isArray(val2)) {
// Handle arrays
const arrayDiff = getArrayDiff(val1, val2)
if (arrayDiff !== null) {
diff[key] = arrayDiff
}
} else {
// Primitive value or different types
diff[key] = {
before: val1,
after: val2,
}
}
}
})
return Object.keys(diff).length > 0 ? diff : null
}
/**
* Compares two arrays and returns the differences.
* @param {Array} arr1 - The original array.
* @param {Array} arr2 - The modified array.
* @returns {Array|null} - An array representing the differences or null if no differences.
*/
function getArrayDiff(arr1: Array<any>, arr2: Array<any>): DiffArray | null {
const diff: DiffArray = []
const maxLength = Math.max(arr1.length, arr2.length)
for (let i = 0; i < maxLength; i++) {
const item1 = arr1[i]
const item2 = arr2[i]
if (!isEqual(item1, item2)) {
// $FlowFixMe[constant-condition]
if (isObject(item1) && isObject(item2)) {
const nestedDiff = getObjectDiff(item1, item2)
if (nestedDiff !== null) {
diff[i] = nestedDiff
}
} else {
diff[i] = {
before: item1,
after: item2,
}
}
}
}
return diff.length > 0 ? diff : null
}
/**
* Wrapper function that determines whether to perform an object or array diff.
* Deals with the case where the two items are not the same type, e.g. an array and an object.
* Deals with
* Returns null if there are no differences.
* @param {*} data1 - The original data (object or array).
* @param {*} data2 - The modified data (object or array).
* @returns {*} - The differences or null if no differences.
* @usage const differences = getDiff(obj1, obj2);
*/
export function getDiff(data1: any, data2: any): ?(DiffObject | DiffArray | { before: any, after: any }) {
// $FlowFixMe[constant-condition]
if (isArray(data1) && isArray(data2)) {
return getArrayDiff(data1, data2)
// $FlowFixMe[constant-condition]
} else if (isObject(data1) && isObject(data2)) {
return getObjectDiff(data1, data2)
} else {
// If data types are different or not objects/arrays, perform a direct comparison
if (!isEqual(data1, data2)) {
return {
before: data1,
after: data2,
}
}
return null
}
}
/**
* CLO + field-limited - Loop through and Console.log only certain names/values of an object to console with text preamble
* Like CLO but more concise, only showing certain fields. Useful for large objects with many fields.
* Prunes object properties that are not in the list, but continues to look deeper as long as properties match the list.
* @param {object} obj - array or object
* @param {string} preamble - (optional) text to prepend to the output
* @param {Array<string>|string} fields - the field property names to display (default: null - display all fields)
* @param {boolean} compactMode - [default: false] if true, will display the fields in a more compact format (less vertical space)
* @author @dwertheimer
* @example clof(note.paragraphs, 'paragraphs',['content'],true)
* @example clof({ foo: { bar: [{ willPrint: 1, ignored:2 }] } }, 'Goes deep as long as it finds a matching field', ['foo', 'bar', 'willPrint'], false)
*/
export function clof(obj: any, preamble: string = '', fields: ?Array<string> | string = null, compactMode: ?boolean = false): void {
const fieldList: ?Array<string> = fields == null ? null : typeof fields === 'string' ? [fields] : fields
const topLevelIsArray = Array.isArray(obj)
const copy = deepCopy(obj, fieldList?.length ? fieldList : null, true)
const topLevel = topLevelIsArray ? Object.keys(copy).map((k) => copy[k]) : copy
if (Array.isArray(topLevel)) {
if (topLevel.length === 0) {
logDebug(`${preamble}: [] (no data)`)
return
}
logDebug(`${preamble}: vvv`)
topLevel.forEach((item, i) => {
// $FlowFixMe[invalid-compare]
logDebug(`${preamble}: [${i}]: ${typeof item === 'object' && item !== null ? JSON.stringify(item, null, compactMode ? undefined : 2) : String(item)}`)
})
logDebug(`${preamble}: ^^^`)
} else {
if (Object.keys(topLevel).length === 0) {
const keycheck = fieldList?.length ? ` for fields: [${fieldList.join(', ')}] - all other properties are pruned` : ''
logDebug(`${preamble}: {} (no data${keycheck})`)
} else {
logDebug(`${preamble}:\n`, compactMode ? JSON.stringify(topLevel) : JSON.stringify(topLevel, null, 2))
}
}
}
export function dump(pluginInfo: any, obj: { [string]: mixed }, preamble: string = '', space: string | number = 2): void {
log(pluginInfo, '-------------------------------------------')
clo(obj, preamble, space)
log(pluginInfo, '-------------------------------------------')
}
/**
* Create a list of the properties of an object, including inherited properties (which are not typically visible in JSON.stringify)
* Often includes a bunch of properties that are not useful for the user, e.g. constructor, __proto__
* See getFilteredProps for a cleaner version
* @author @dwertheimer (via StackOverflow)
*
* @param {object} inObj
* @returns {Array<string>}
* @reference https://stackoverflow.com/questions/59228638/console-log-an-object-does-not-log-the-method-added-via-prototype-in-node-js-c
*/
// Note: the indexer is covariant (`+`) because this only ever *reads* property names. An
// invariant indexer would reject every concretely-typed object (e.g. a backlink), since
// `string` is not interchangeable with `mixed` for a writable property.
export function getAllPropertyNames(inObj: interface { +[string]: mixed }): Array<string> {
// CRITICAL: Check for null/undefined before processing (typeof null === 'object' in JavaScript!)
// $FlowFixMe[invalid-compare]
if (inObj === null || inObj === undefined) {
return []
}
let obj = inObj
const props = []
do {
// Additional null check in the loop (Object.getPrototypeOf(null) can cause issues)
// $FlowFixMe[invalid-compare]
if (obj === null || obj === undefined) {
break
}
Object.getOwnPropertyNames(obj).forEach(function (prop) {
if (props.indexOf(prop) === -1) {
props.push(prop)
}
})
// $FlowFixMe[invalid-compare]
} while ((obj = Object.getPrototypeOf(obj)) && obj !== null)
return props
}
/**
* Get the properties of interest (i.e. excluding all the ones added automatically)
* @author @dwertheimer
* @param {object} object
* @returns {Array<string>} - an array of the interesting properties of the object
*/
export const getFilteredProps = (object: any): Array<string> => {
const ignore = ['toString', 'toLocaleString', 'valueOf', 'hasOwnProperty', 'propertyIsEnumerable', 'isPrototypeOf']
// CRITICAL: Check for null explicitly (typeof null === 'object' in JavaScript!)
if (object === null || object === undefined || typeof object !== 'object' || Array.isArray(object)) {
// console.log(`getFilteredProps improper type: ${typeof object}`)
return []
}
return getAllPropertyNames(object).filter((prop) => !/(^__)|(constructor)/.test(prop) && !ignore.includes(prop))
}
/**
* Copy the first level of an object and its prototypes as well, return as a normal object
* with no prototypes. This is useful for copying objects that have
* prototypes that are not normally visible in JSON.stringify
* (e.g. most objects that come from the NotePlan API)
* @author @dwertheimer
* @param {any} obj
*/
export function copyObject(obj: any): any {
const props = getFilteredProps(obj)
return props.reduce((acc, p: any) => {
acc[p] = obj[p]
return acc
}, {})
}
/**
* Deeply copies an object including its prototype properties. Optionally filters properties by a given list.
* For arrays, can optionally modify their representation in the stringified output to include indices.
* Handles objects, arrays, Dates, and primitive types.
* Result is JSON-safe, free of recursion, and can be stringified.
* Use function clof to display objects with certain properties
* NOTE: Does not actually copy prototype (does not work for NP), only the properties.
*
* @template T The type of the value being copied.
* @param {T} value The value to copy.
* @param {?Array<string>|string} [propsToInclude=null] Optional single field name or array of property names to include in the copy. As objects are traversed, only these properties will be included in the copy. If null, all properties will be included.
* @param {boolean} [showIndices=false] Optional parameter to include indices in array representation during stringification.
* @return {T|{ [key: string]: any }} The deep copy of the value.
*/
export function deepCopy<T>(value: T, _propsToInclude: ?Array<string> | string = null, showIndices: boolean = false): T | { [key: string]: any } {
// $FlowFixMe[invalid-compare]
const propsToInclude = _propsToInclude === [] ? null : typeof _propsToInclude === 'string' ? [_propsToInclude] : _propsToInclude
// Handle null, undefined, and primitive types
// $FlowFixMe[invalid-compare]
if (value === null || typeof value !== 'object') {
return value
}
// Handle Date
if (value instanceof Date) {
// Cast: the copy is the same type as the input, but Flow cannot see that through T.
return ((new Date(value.getTime()): any): T)
}
// Handle Array
if (Array.isArray(value)) {
const arrayCopy = value.map((item: any) => deepCopy(item, propsToInclude, showIndices))
if (showIndices) {
// Convert array to object with index keys for stringification
const objectWithIndices: { [string]: any } = {}
arrayCopy.forEach((item: any, index: number) => {
objectWithIndices[`[${index}]`] = item
})
return objectWithIndices
} else {
return arrayCopy
}
}
// Handle Object (including objects with prototype properties)
const copy: { [string]: any } = {}
const propNames = propsToInclude || Object.keys(value)
for (const key of propNames) {
if (propsToInclude ? propsToInclude.includes(key) : true) {
const isBlacklisted = PARAM_BLACKLIST.indexOf(key) !== -1
const isPrivateVar = /^__/.test(key)
const isFunction = typeof value[key] === 'function'
if (!isBlacklisted && !isPrivateVar && !isFunction) {
copy[key] = deepCopy(value[key], propsToInclude, showIndices)
}
}
}
return copy
}
/**
* Print to the console log, the properties of an object (including its prototype/private methods). This is useful if you want to know which properties are on the object vs the prototype because it will display in two lines, but it's more succinct to use getAllPropertyNames()
* @author @dwertheimer
* @param {object} obj
* @returns {void}
*/
// This works and is good if you want to know which properties are on the object vs the prototype
// because it will display in two lines
export function logAllPropertyNames(obj?: mixed): void {
if (typeof obj !== 'object' || obj == null) return // recursive approach
logDebug(
'helpers/dev',
Object.getOwnPropertyNames(obj).filter((x) => /^__/.test(x) === false),
)
logAllPropertyNames(obj.__proto__)
}
/**
* Converts any to message string
* @author @codedungeon
* @param {any} message
* @returns {string}
*/
const _message = (message: any): string => {
let logMessage = ''
switch (typeof message) {
case 'string':
logMessage = message
break
case 'object':
if (Array.isArray(message)) {
logMessage = message.toString()
} else {
logMessage = message instanceof Date ? message.toString() : JSON.stringify(message)
}
break
default:
logMessage = message.toString()
break
}
return logMessage
}
/**
* Fold any trailing log arguments into the message string.
* The log* functions used to accept only (pluginInfo, message), so a call like
* `logDebug('Foo', 'bar:', someObject)` silently dropped `someObject`. They now take a rest
* param and append it here, matching what helpers/react/reactDev.js has always done.
* @author @dwertheimer
* @param {any} message the original second argument
* @param {Array<any>} args any further arguments the caller supplied
* @returns {any} the message unchanged when there are no extra args, else a combined string
*/
const _withExtraArgs = (message: any, args: Array<any>): any => {
if (!args || args.length === 0) return message
return [_message(message), ...args.map(_message)].filter((part) => part !== '').join(' ')
}
const LOG_LEVELS = ['DEBUG', 'INFO', 'WARN', 'ERROR', 'none']
export const LOG_LEVEL_STRINGS = ['| DEBUG |', '| INFO |', '🥺 WARN 🥺', '❗️ ERROR ❗️', 'none']
/** Padding used when log contains "LBB" to force NotePlan's log buffer to flush before/after the line (log buffer buster).
* Emitted as separate log lines (before + msg + after) so the dots appear. */
const LOG_BUFFER_BUSTER_PADDING = `${'.'.repeat(10000)}/`
/** Resolved settings for log-level checks (from sync object or after await DataStore.settings). */
let cachedPluginSettingsForLog: any = null
/** True once background await has been scheduled (avoid duplicate work). */
let pluginSettingsAwaitPrimeStartedForLog: boolean = false
/** Trace settings resolution via console.log. Enable with env NP_INSTRUMENT_PLUGIN_SETTINGS=1 (Node/Jest only). */
const ENABLE_GET_PLUGIN_SETTINGS_INSTRUMENTATION: boolean = typeof process !== 'undefined' && process.env && process.env.NP_INSTRUMENT_PLUGIN_SETTINGS === '1'
let getPluginSettingsInstrumentationResolutionLogged: boolean = false
/**
* Emit diagnostics for plugin settings loading. Uses console.log so CLO/logDebug gating does not hide it.
* @param {string} phase
* @param {any} detail
* @returns {void}
*/
function logGetPluginSettingsInstrumentation(phase: string, detail?: any): void {
if (!ENABLE_GET_PLUGIN_SETTINGS_INSTRUMENTATION) {
return
}
try {
if (typeof console !== 'undefined' && typeof console.log === 'function') {
console.log(`[getPluginSettingsForLogging] ${phase}`, detail !== undefined ? detail : '')
}
} catch (_e) {
// ignore
}
}
/**
* Fire-and-forget: `await DataStore.settings` and cache. Callers use sync getPluginSettingsForLogging / shouldOutputForLogLevel.
* @returns {void}
*/
function primePluginSettingsCacheViaAwait(): void {
if (pluginSettingsAwaitPrimeStartedForLog) {
return
}
pluginSettingsAwaitPrimeStartedForLog = true
void (async (): Promise<void> => {
try {
if (typeof DataStore === 'undefined') {
return
}
const resolved = await awaitTopLevelApiProp(DataStore, 'settings')
if (resolved != null && typeof resolved === 'object') {
cachedPluginSettingsForLog = resolved
}
if (ENABLE_GET_PLUGIN_SETTINGS_INSTRUMENTATION && !getPluginSettingsInstrumentationResolutionLogged) {
getPluginSettingsInstrumentationResolutionLogged = true
logGetPluginSettingsInstrumentation('await DataStore.settings resolved', {
typeofResolved: typeof resolved,
hasLogLevel: resolved != null && typeof resolved === 'object' && '_logLevel' in resolved,
_logLevel: resolved != null && typeof resolved === 'object' ? resolved._logLevel : '(n/a)',
})
}
} catch (err) {
pluginSettingsAwaitPrimeStartedForLog = false
logGetPluginSettingsInstrumentation('await DataStore.settings threw/rejected', err)
}
})()
}
/**
* Return plugin settings for log-level checks.
* - If `DataStore.settings` is already a plain object (not a thenable), use it synchronously.
* - Otherwise schedule one background await and return null until cache fills (shouldOutputForLogLevel then defaults to DEBUG until settings apply).
*
* @returns {?Object}
*/
function getPluginSettingsForLogging(): any {
if (typeof DataStore === 'undefined') {
logGetPluginSettingsInstrumentation('DataStore undefined', { returning: null })
return null
}
if (cachedPluginSettingsForLog != null) {
return cachedPluginSettingsForLog
}
const raw = DataStore.settings
if (raw == null) {
logGetPluginSettingsInstrumentation('DataStore.settings is null/undefined', { raw, returning: null })
return null
}
// Plugin / legacy: plain settings object (not Promise / thenable). Exclude null (typeof null === 'object').
if (typeof raw === 'object' && raw !== null && typeof raw.then !== 'function') {
cachedPluginSettingsForLog = raw
return raw
}
// WebView / async: await DataStore.settings once in the background.
primePluginSettingsCacheViaAwait()
return null
}
/**
* Test _logLevel against logType to decide whether to output
* @param {string} logType
* @returns {boolean}
*/
export const shouldOutputForLogLevel = (logType: string): boolean => {
// Default DEBUG so early logs are not dropped while DataStore.settings is still unresolved or _logLevel is unset.
// Note: `number | string` because _logLevel can be either ('DEBUG' or an index) — the branch
// below already handles both.
let userLogLevel: number | string = 0
const thisMessageLevel = LOG_LEVELS.indexOf(logType.toUpperCase())
const pluginSettings = getPluginSettingsForLogging()
// Note: Performing a null change against a value that is `undefined` will be true
// Sure wish NotePlan would not return `undefined` but instead null, then the previous implementataion would not have failed
// se _logLevel to decide whether to output
if (pluginSettings && pluginSettings.hasOwnProperty('_logLevel')) {
userLogLevel = pluginSettings['_logLevel']
}
// Handle both string and numeric log levels
let userLogLevelIndex
if (typeof userLogLevel === 'string') {
userLogLevelIndex = LOG_LEVELS.indexOf(userLogLevel)
} else {
userLogLevelIndex = userLogLevel
}
// If 'none' is set, don't output anything
if (userLogLevel === 'none' || userLogLevelIndex === 4) {
return false
}
return thisMessageLevel >= userLogLevelIndex
}
/**
* Test if _logFunctionRE is set and matches the current log details.
* Note: only works if DataStore is available.
* @param {any} pluginInfo
* @returns
*/
export const shouldOutputForFunctionName = (pluginInfo: any): boolean => {
const pluginSettings = getPluginSettingsForLogging()
if (pluginSettings && pluginSettings.hasOwnProperty('_logFunctionRE')) {
const logFunctionRE = pluginSettings['_logFunctionRE']
if (logFunctionRE) {
// Check if the regular expression is not empty
const functionRE = new RegExp(logFunctionRE, 'i')
const infoStr: string = pluginInfo === 'object' ? pluginInfo['plugin.id'] : String(pluginInfo)
return functionRE.test(infoStr)
}
}
return false
}
export function getLogDateAndTypeString(type: string): string {
const thisMessageLevel = LOG_LEVELS.indexOf(type.toUpperCase())
const thisIndicator = LOG_LEVEL_STRINGS[thisMessageLevel]
return `${dt().padEnd(19)} ${thisIndicator}`
}
/**
* Formats log output to include timestamp pluginId, pluginVersion, and pluginReleaseStatus (if populated)
* If the formatted message contains "LBB" (log buffer buster), flushes NotePlan's log buffer before and after
* so this line is captured when the buffer is otherwise truncated.
* @author @codedungeon extended by @jgclark
* @param {any} pluginInfo
* @param {any} message
* @param {string} type
* @returns {string}
*/
export function log(pluginInfo: any, message: any = '', type: string = 'INFO'): string {
let msg = ''
if (shouldOutputForLogLevel(type) || shouldOutputForFunctionName(pluginInfo)) {
let pluginId = ''
let pluginVersion = ''
const isPluginJson = typeof pluginInfo === 'object' && pluginInfo.hasOwnProperty('plugin.id')
const ldts = getLogDateAndTypeString(type)
if (isPluginJson) {
pluginId = pluginInfo.hasOwnProperty('plugin.id') ? pluginInfo['plugin.id'] : 'INVALID_PLUGIN_ID'
pluginVersion = pluginInfo.hasOwnProperty('plugin.version') ? pluginInfo['plugin.version'] : 'INVALID_PLUGIN_VERSION'
const pluginReleaseStatus = pluginInfo.hasOwnProperty('plugin.releaseStatus') && pluginInfo['plugin.releaseStatus'] ? `-${pluginInfo['plugin.releaseStatus']}` : ''
msg = `${ldts} ${pluginId} v${pluginVersion}${pluginReleaseStatus} :: ${_message(message)}`
} else {
if (message.length > 0) {
// msg = `${dt().padEnd(19)} | ${thisIndicator.padEnd(7)} | ${pluginInfo} :: ${_message(message)}`
msg = `${ldts} ${pluginInfo} :: ${_message(message)}`
} else {
// msg = `${dt().padEnd(19)} | ${thisIndicator.padEnd(7)} | ${_message(pluginInfo)}`
msg = `${ldts} ${_message(pluginInfo)}`
}
}
// If message contains "LBB" (log buffer buster), emit padding as separate lines so dots show and buffer flushes
if (typeof msg === 'string' && msg.indexOf('LBB') !== -1) {
console.log(`before ${msg.substring(0, 25)}...: ${LOG_BUFFER_BUSTER_PADDING}`)
console.log(msg)
console.log(`after ${msg.substring(0, 25)}...: ${LOG_BUFFER_BUSTER_PADDING}`)
} else {
console.log(msg)
}
}
return msg
}
/**
* Formats log output as ERROR to include timestamp pluginId, pluginVersion
* @author @codedungeon
* @param {any} pluginInfo
* @param {any} message
* @returns {string}
*/
export function logError(pluginInfo: any, error?: any, ...args: Array<any>): string {
if (typeof error === 'object' && error != null) {
const msg = `${error.filename ?? '<unknown file>'} ${error.lineNumber ?? '<unkonwn line>'}: ${error.message}`
return log(pluginInfo, _withExtraArgs(msg, args), 'ERROR')
}
return log(pluginInfo, _withExtraArgs(error, args), 'ERROR')
}
/**
* Formats log output as WARN to include timestamp pluginId, pluginVersion
* @author @codedungeon
* @param {any} pluginInfo
* @param {any} message
* @returns {string}
*/
export function logWarn(pluginInfo: any, message: any = '', ...args: Array<any>): string {
return log(pluginInfo, _withExtraArgs(message, args), 'WARN')
}
/**
* Formats log output as INFO to include timestamp pluginId, pluginVersion
* @author @codedungeon
* @param {any} pluginInfo
* @param {any} message
* @returns {string}
*/
export function logInfo(pluginInfo: any, message: any = '', ...args: Array<any>): string {
return log(pluginInfo, _withExtraArgs(message, args), 'INFO')
}
/**
* Formats log output as DEBUG to include timestamp pluginId, pluginVersion
* Include "LBB" in the message to force log-buffer flush before and after (helps when buffer truncates).
* @author @dwertheimer
* @param {any} pluginInfo
* @param {any} message
* @returns {string}
*/
export function logDebug(pluginInfo: any, message: any = '', ...args: Array<any>): string {
return log(pluginInfo, _withExtraArgs(message, args), 'DEBUG')
}
/**
* Time a function
* @param {Date} startTime - the date object from when timer started (using Date.now())
* @returns {string} - the formatted elapsed time
* @author @dwertheimer
* @example
* const startTime = Date.now()
* ...some long-running stuff here...
* const elapsedTime = timer(startTime)
*/
export function timer(startTime: Date): string {
const timeStart = startTime ?? new Date()
const timeEnd = new Date()
// `Number()` is exactly what the `-` operator does to each operand, so this is identical at runtime to `timeEnd - timeStart`
// (including for callers that pass a `Date.now()` number rather than a Date), but Flow can type it.
const difference = Number(timeEnd) - Number(timeStart)
const diffText = `${difference.toLocaleString()}ms`
return diffText
}
/**
* A special logger that logs the time it takes to execute a function, or a certain stage of a function, that this is called from.
* It can be turned on/off independently from _logLevel. And for it to always trigger if a threshold is passed.
* Assumes that `const startTime = new Date()` is included earlier in the function.
* If separate plugin-level _logTimer setting is true, then it will log, irrespective of the main _logLevel setting.
* But if warningTrigger (in milliseconds)is exceeded, then this will log with a warning, irrespective of _logTimer or _logLevel settings.
* @author @jgclark
* @param {string} functionName - to display after time in log line
* @param {Date} startTime - the date object from when timer started (using new Date())
* @param {string} explanation - optional text to display after the duration in log line
* @param {number} warningThreshold - optional duration in milliseconds: if the timer is more than this it will log with added warning symbol.
*/
export function logTimer(functionName: string, startTime: Date, explanation: string = '', warningThreshold?: number): void {
// `Number()` is exactly what the `-` operator does to each operand: identical at runtime to `new Date() - startTime`.
const difference = Number(new Date()) - Number(startTime)
const diffTimeText = `${difference.toLocaleString()}ms`
const output = `${diffTimeText} ${explanation}`
if (warningThreshold && difference > warningThreshold) {
// const msg = `${dt().padEnd(19)} | ⏱️ ⚠️ ${functionName} | ${output}`
const msg = `⏱️ ⚠️ ${output}`
// console.log(msg)
log(functionName, msg, 'DEBUG')
} else {
const pluginSettings = getPluginSettingsForLogging()
// const timerSetting = pluginSettings['_logTimer'] ?? false
if (pluginSettings && pluginSettings.hasOwnProperty('_logTimer') && pluginSettings['_logTimer'] === true) {
// const msg = `${dt().padEnd(19)} | ⏱️ ${functionName} | ${output}`
const msg = `⏱️ ${output}`
// console.log(msg)
log(functionName, msg, 'DEBUG')
}
}
}
/**
* Add or override parameters from args to the supplied config object.
* This is the **simple version** that treats all the passed arguments as strings, leaving some of the typing to the developer.
* Tested with strings, ints, floats, boolean and simple array of strings.
* Note: Different parameters are separated by ';' (not the more usual ',' to allow for comma-separated arrays)
* Note: use the advanced version to pass more advanced quoted arrays, and items containing commas or semicolons.
* Note: This can't tell the difference between single-element arrays and strings, so that processing needs to be done by the calling function.
* @author @jgclark and @dwertheimer
* @param {any} config object
* @param {string} argsAsString e.g. 'field1=Bob Skinner;field2=false;field3=simple,little,array'
* @returns {any} configOut
*/
export function overrideSettingsWithStringArgs(config: any, argsAsString: string): any {
try {
// Parse argsAsJSON (if any) into argObj using JSON
if (argsAsString) {
const argObj: { [string]: string } = {}
argsAsString.split(';').forEach((arg) => {
if (arg.split('=').length === 2) {
let key = arg.split('=')[0].trim()
if (key.startsWith('await ')) key = key.slice(6) // deal with a special case where templating is adding await to our params
const value = arg.split('=')[1].trim()
argObj[key] = value
}
})
// use the built-in way to add (or override) from argObj into config
const configOut = Object.assign(config)
// Attempt to change arg values that are numerics or booleans to the right types, otherwise they will stay as strings
for (const key in argObj) {
// Deliberately re-typed below: string args are coerced to number/boolean/array.
let value: any = argObj[key].trim()
logDebug(`dev.js`, `overrideSettingsWithStringArgs key:${key} value:${argObj[key]} typeof:${typeof argObj[key]} !isNaN(${value}):${String(!isNaN(argObj[key]))}`)
if (!isNaN(value) && value !== '') {
// Change to number type
value = Number(value)
} else if (value === 'false') {
// Change to boolean type
value = false
} else if (value === 'true') {
// Change to boolean type