-
-
Notifications
You must be signed in to change notification settings - Fork 43
/
bookmarks.ts
889 lines (760 loc) · 30 KB
/
bookmarks.ts
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
import {computed, reactive, ref, type Ref} from "vue";
import type {Bookmarks} from "webextension-polyfill";
import browser from "webextension-polyfill";
import type {OpenableURL} from "../util";
import {
backingOff,
expect,
filterMap,
shortPoll,
tryAgain,
urlToOpen,
} from "../util";
import {logErrorsFrom} from "../util/oops";
import {EventWiring} from "../util/wiring";
import {pathTo, type Position, type Tree} from "./tree";
/** A node in the bookmark tree. */
export type Node = Bookmark | Separator | Folder;
export type Bookmark = NodeBase & {url: string};
export type Separator = NodeBase & {type: "separator"};
export type Folder = NodeBase & {
children: NodeID[];
$stats: FolderStats;
$recursiveStats: FolderStats;
};
type NodeBase = {
parentId: NodeID;
id: NodeID;
dateAdded?: number;
title: string;
$selected: boolean;
};
export type NodeID = string & {readonly __node_id: unique symbol};
export type NodePosition = {parent: Folder; index: number};
export type FolderStats = {
bookmarkCount: number;
folderCount: number;
selectedCount: number;
};
export function isBookmark(node: Node): node is Bookmark {
return "url" in node;
}
export function isFolder(node: Node): node is Folder {
return "children" in node;
}
export function isSeparator(node: Node): node is Separator {
return "type" in node && node.type === "separator";
}
/** The name of the stash root folder. This name must match exactly (including
* in capitalization). */
const STASH_ROOT = "Tab Stash";
const ROOT_FOLDER_HELP =
"https://github.com/josh-berry/tab-stash/wiki/Problems-Locating-the-Tab-Stash-Bookmark-Folder";
/** A Vue model for the state of the browser bookmark tree.
*
* Similar to `tabs.ts`, this model follows the WebExtension conventions, with
* some slight changes to handle hierarchy in the same manner as `tabs.ts`, and
* ensure the state is JSON-serializable.
*/
export class Model implements Tree<Folder, Bookmark | Separator> {
private readonly by_id = new Map<NodeID, Node>();
private readonly by_url = new Map<OpenableURL, Set<Bookmark>>();
/** The ID of the root node (set only once the model is loaded). */
root_id: NodeID | undefined;
/** The title to look for to locate the stash root. */
readonly stash_root_name: string;
/** A Vue ref to the root folder for Tab Stash's saved tabs. */
readonly stash_root: Ref<Folder | undefined> = ref();
/** If set, there is more than one candidate stash root, and it's not clear
* which one to use. The contents of the warning are an error to show the
* user and a function to direct them to more information. */
readonly stash_root_warning: Ref<
{text: string; help: () => void} | undefined
> = ref();
/** The number of selected bookmarks. This is a ref() rather than a
* computed() because it's very expensive to compute, so we always update it
* incrementally.
*
* The invariant is: this should only be updated by assigning to a node's
* `$selected` field. That will trigger a watch which will adjust the count
* up or down by one. */
readonly selectedCount = computed(
() => this.stash_root.value?.$recursiveStats.selectedCount || 0,
);
/** Tracks folders which are candidates to be the stash root, and their
* parents (up to the root). Any changes to these folders should recompute
* the stash root. */
private _stash_root_watch = new Set<Folder>();
/** Did we receive an event since the last (re)load of the model? */
private _event_since_load: boolean = false;
//
// Loading data and wiring up events
//
/** Construct a model by loading bookmarks from the browser bookmark store.
* It will listen for bookmark events to keep itself updated. */
static async from_browser(
stash_root_name_test_only?: string,
): Promise<Model> {
// istanbul ignore if
if (!stash_root_name_test_only) stash_root_name_test_only = STASH_ROOT;
const model = new Model(stash_root_name_test_only);
await model.reload();
return model;
}
private constructor(stash_root_name: string) {
this.stash_root_name = stash_root_name;
const wiring = new EventWiring(this, {
onFired: () => {
this._event_since_load = true;
},
// istanbul ignore next -- safety net; reload the model in the event
// of an unexpected exception.
onError: () => {
logErrorsFrom(() => this.reload());
},
});
wiring.listen(browser.bookmarks.onCreated, this.whenBookmarkCreated);
wiring.listen(browser.bookmarks.onChanged, this.whenBookmarkChanged);
wiring.listen(browser.bookmarks.onMoved, this.whenBookmarkMoved);
wiring.listen(browser.bookmarks.onRemoved, this.whenBookmarkRemoved);
}
dumpState(): any {
return {
root: this.root_id,
stash_root: this.stash_root.value?.id,
bookmarks: JSON.parse(JSON.stringify(Object.fromEntries(this.by_id))),
};
}
/** Fetch bookmarks from the browser again and update the model's
* understanding of the world with the browser's data. Use this if it looks
* like the model has gotten out of sync with the browser (e.g. for crash
* recovery). */
readonly reload = backingOff(async () => {
function mark(marked: Set<string>, root: Bookmarks.BookmarkTreeNode) {
marked.add(root.id);
if (root.children) for (const c of root.children) mark(marked, c);
}
// We loop until we can complete a reload without receiving any
// concurrent events from the browser--if we get a concurrent event, we
// need to try loading again, since we don't know how the event was
// ordered with respect to the getTree().
this._event_since_load = true;
while (this._event_since_load) {
this._event_since_load = false;
const tree = await browser.bookmarks.getTree();
const root = tree[0]!;
this.root_id = root.id as NodeID;
this.whenBookmarkCreated(root.id, root);
// Clean up bookmarks that don't exist anymore
const marked = new Set<string>();
mark(marked, root);
for (const id of this.by_id.keys()) {
if (!marked.has(id)) this.whenBookmarkRemoved(id);
}
}
});
//
// Accessors
//
/** Retrieves the node with the specified ID (if it exists). */
node(id: string): Node | undefined {
return this.by_id.get(id as NodeID);
}
/** Retrieves the bookmark with the specified ID. Returns `undefined` if it
* does not exist or is not a bookmark. */
bookmark(id: string): Bookmark | undefined {
const node = this.node(id);
if (node && "url" in node) return node;
return undefined;
}
/** Retrieves the folder with the specified ID. Returns `undefined` if it does not exist or is not a folder. */
folder(id: string): Folder | undefined {
const node = this.node(id);
if (node && "children" in node) return node;
return undefined;
}
/** Returns a (reactive) set of bookmarks with the specified URL. */
bookmarksWithURL(url: string): Set<Bookmark> {
let index = this.by_url.get(urlToOpen(url));
if (!index) {
index = reactive(new Set<Bookmark>());
this.by_url.set(urlToOpen(url), index);
}
return index;
}
isParent(node: Node): node is Folder {
return isFolder(node);
}
/** Given a child node, return its parent and the index of the child in the
* parent's children. Returns `undefined` if the child has no parent (i.e.
* its `parentId === undefined`), if the parent itself cannot be located, or
* if the child cannot be located inside the parent. */
positionOf(node: Node): Position<Folder> | undefined {
const parent = this.folder(node.parentId);
if (!parent) return undefined;
const index = parent.children.findIndex(id => id === node.id);
// istanbul ignore if -- internal sanity
if (index === -1) return undefined;
return {parent, index};
}
/** Given a parent folder, return all the child nodes in the parent. */
childrenOf(folder: Folder): Node[] {
return filterMap(folder.children, cid => this.node(cid));
}
/** Check if `node` is contained, directly or indirectly, by the folder with
* the specified ID. */
isNodeInFolder(node: Node, folder_id: NodeID): boolean {
let item: Node | undefined = node;
while (item) {
if (item.id === folder_id) return true;
if (!item.parentId) break;
item = this.by_id.get(item.parentId);
}
return false;
}
/** Check if `node` is contained, directly or indirectly, by the stash root.
* If there is no stash root, always returns `false`. */
isNodeInStashRoot(node: Node): boolean {
// istanbul ignore if -- we always have a root in tests
if (!this.stash_root.value) return false;
return this.isNodeInFolder(node, this.stash_root.value.id);
}
/** Given a bookmark node, return the path from the root to the node as an
* array of NodePositions. If the node is not present in the tree, throws
* an exception. */
pathTo(node: Node): Position<Folder>[] {
return pathTo(this, node);
}
/** Checks if a particular bookmark is a child of a stash folder inside the
* stash root (i.e. it is visible in the UI). If so, returns the parent
* folder of the bookmark (i.e. the stash group). */
stashGroupOf(node: Node): Folder | undefined {
// istanbul ignore if -- uncommon and hard to test
if (!this.stash_root.value) return undefined;
const group = this.folder(node.parentId);
if (!group) return undefined;
if (!this.isNodeInStashRoot(group)) return undefined;
return group;
}
/** Returns true if a particular URL is present in the stash. */
isURLStashed(url: string): boolean {
const stash_root = this.stash_root.value;
// istanbul ignore if -- uncommon and hard to test
if (!stash_root) return false;
for (const bm of this.bookmarksWithURL(url)) {
if (this.isNodeInStashRoot(bm)) return true;
}
return false;
}
/** Return all the URLs present in the stash root. */
urlsInStash(): Set<string> {
const urls = new Set<string>();
const urlsInChildren = (folder: Folder) => {
for (const c of folder.children) {
const node = this.node(c);
if (!node) continue;
if ("url" in node) urls.add(node.url);
else if ("children" in node) urlsInChildren(node);
}
};
if (this.stash_root.value) urlsInChildren(this.stash_root.value);
return urls;
}
//
// Mutators
//
/** Creates a bookmark and waits for the model to reflect the creation.
* Returns the bookmark node in the model. */
async create(bm: browser.Bookmarks.CreateDetails): Promise<Node> {
const ret = await browser.bookmarks.create(bm);
return await shortPoll(() => {
const bm = this.by_id.get(ret.id as NodeID);
if (!bm) tryAgain();
return bm;
});
}
/** Updates a bookmark's title and waits for the model to reflect the
* update. */
async rename(bm: Bookmark | Folder, title: string): Promise<void> {
await browser.bookmarks.update(bm.id, {title});
await shortPoll(() => {
if (bm.title !== title) tryAgain();
});
}
/** Deletes a bookmark and waits for the model to reflect the deletion.
*
* If the node is part of the stash and belongs to an unnamed folder which
* is now empty, cleanup that folder as well.
*/
async remove(id: NodeID): Promise<void> {
const node = this.node(id);
if (!node) return;
const pos = this.positionOf(node);
await browser.bookmarks.remove(id);
// Wait for the model to catch up
await shortPoll(() => {
// Wait for the model to catch up
if (this.by_id.has(id)) tryAgain();
});
if (pos) await this.maybeCleanupEmptyFolder(pos.parent);
}
/** Deletes an entire tree of bookmarks and waits for the model to reflect
* the deletion. */
async removeTree(id: NodeID): Promise<void> {
await browser.bookmarks.removeTree(id);
// Wait for the model to catch up
await shortPoll(() => {
// Wait for the model to catch up
if (this.by_id.has(id)) tryAgain();
});
}
/** Moves a bookmark such that it precedes the item with index `toIndex` in
* the destination folder. (You can pass an index `>=` the length of the
* bookmark folder's children to move the item to the end of the folder.)
*
* Use this instead of `browser.bookmarks.move()`, which behaves differently
* in Chrome and Firefox... */
async move(id: NodeID, toParent: NodeID, toIndex: number): Promise<void> {
// Firefox's `index` parameter behaves like the bookmark is first
// removed, then re-added. Chrome's/Edge's behaves like the bookmark is
// first added, then removed from its old location, so the index of the
// item after the move will sometimes be toIndex-1 instead of toIndex;
// we account for this below.
const node = expect(this.node(id), () => `No such bookmark node: ${id}`);
const position = expect(
this.positionOf(node),
() => `Unable to locate node ${id} in its parent`,
);
// Clamp the destination index based on the model length, or the poll
// below won't see the index it's expecting. (This isn't 100%
// reliable--we might still get an exception if multiple concurrent
// moves are going on, but even Firefox itself has bugs in this
// situation, soooo... *shrug*)
const toParentFolder = expect(
this.folder(toParent),
() => `Unable to locate destination folder: ${toParent}`,
);
toIndex = Math.min(toParentFolder.children.length, Math.max(0, toIndex));
// istanbul ignore else
if (!!browser.runtime.getBrowserInfo) {
// We're using Firefox
if (node.parentId === toParent) {
if (toIndex > position.index) toIndex--;
}
}
await browser.bookmarks.move(id, {parentId: toParent, index: toIndex});
await shortPoll(() => {
const pos = this.positionOf(node);
if (!pos) tryAgain();
if (pos.parent.id !== toParent || pos.index !== toIndex) tryAgain();
});
await this.maybeCleanupEmptyFolder(position.parent);
}
/** Find and return the stash root, or create one if it doesn't exist. */
async ensureStashRoot(): Promise<Folder> {
if (this.stash_root.value) return this.stash_root.value;
await this.create({title: this.stash_root_name});
// GROSS HACK to avoid creating duplicate roots follows.
//
// We sample at irregular intervals for a bit to see if any other models
// are trying to create the stash root at the same time we are. If so,
// this sampling gives us a higher chance to observe each other. But if
// we consistently see a single candidate over time, we can assume we're
// the only one running right now.
const start = Date.now();
let delay = 10;
let candidates = this._maybeUpdateStashRoot();
while (Date.now() - start < delay) {
if (candidates.length > 1) {
// If we find MULTIPLE candidates so soon after finding NONE,
// there must be multiple threads trying to create the root
// folder. Let's try to remove one. We are guaranteed that all
// threads see the same ordering of candidate folders (and thus
// will all choose the same folder to save) because the
// candidate list is sorted deterministically.
await this.remove(candidates[1].id).catch(() => {});
delay += 10;
}
await new Promise(r => setTimeout(r, 5 * Math.random()));
candidates = this._maybeUpdateStashRoot();
}
// END GROSS HACK
return candidates[0];
}
/** Create a new folder at the top of the stash root (creating the stash
* root itself if it does not exist). If the name is not specified, a
* default name will be assigned based on the folder's creation time. */
async createStashFolder(name?: string): Promise<Folder> {
const stash_root = await this.ensureStashRoot();
const bm = await this.create({
parentId: stash_root.id,
title: name ?? genDefaultFolderName(new Date()),
index: 0,
});
return bm as Folder;
}
/** Removes the folder if it is empty, unnamed and within the stash root. */
private async maybeCleanupEmptyFolder(folder: Folder) {
// Folder does not have a default/unnamed-shape name
if (getDefaultFolderNameISODate(folder.title) === null) return;
if (folder.children.length > 0) return;
if (!this.stash_root.value) return;
if (!this.isNodeInFolder(folder, this.stash_root.value.id)) return;
// NOTE: This will never be recursive because remove() only calls us if
// we're removing a leaf node, which we are never doing here.
//
// ALSO NOTE: If the folder is suddenly NOT empty due to a race, stale
// model, etc., this will fail, because the browser itself will throw.
await this.remove(folder.id);
}
//
// Events which are detected automatically by this model; these can be
// called for testing purposes but otherwise you can ignore them.
//
// (In contrast to onFoo-style things, they are event listeners, not event
// senders.)
//
whenBookmarkCreated(id: string, new_bm: Bookmarks.BookmarkTreeNode) {
// istanbul ignore next -- this is kind of a dumb/redundant API, but it
// must conform to browser.bookmarks.onCreated...
if (id !== new_bm.id) throw new Error(`Bookmark IDs don't match`);
const nodeId = id as NodeID;
const parentId = (new_bm.parentId ?? "") as NodeID;
// The parent must already exist and be a folder
if (parentId) this.folder(parentId);
let node = this.by_id.get(nodeId);
if (!node) {
const $selected = ref(false);
if (isBrowserBTNFolder(new_bm)) {
const folder: Folder = reactive({
parentId: parentId,
id: nodeId,
dateAdded: new_bm.dateAdded,
title: new_bm.title ?? "",
children: [],
$selected,
$stats: computed(() => {
let bookmarkCount = 0;
let folderCount = 0;
let selectedCount = 0;
for (const c of folder.children) {
const n = this.node(c);
if (!n) continue;
if (isFolder(n)) ++folderCount;
if (isBookmark(n)) ++bookmarkCount;
if (n.$selected) ++selectedCount;
}
return {bookmarkCount, folderCount, selectedCount};
}),
$recursiveStats: computed(() => {
let bookmarkCount = folder.$stats.bookmarkCount;
let folderCount = folder.$stats.folderCount;
let selectedCount = folder.$stats.selectedCount;
for (const c of folder.children) {
const f = this.folder(c);
if (!f) continue;
const stats = f.$recursiveStats;
bookmarkCount += stats.bookmarkCount;
folderCount += stats.folderCount;
selectedCount += stats.selectedCount;
}
return {bookmarkCount, folderCount, selectedCount};
}),
});
node = folder;
} else if (new_bm.type === "separator") {
node = reactive({
parentId: parentId,
id: nodeId,
dateAdded: new_bm.dateAdded,
type: "separator" as "separator",
title: "" as "",
$selected,
});
} else {
node = reactive({
parentId: parentId,
id: nodeId,
dateAdded: new_bm.dateAdded,
title: new_bm.title ?? "",
url: new_bm.url ?? "",
$selected,
});
this._add_url(node);
}
this.by_id.set(node.id, node);
if (new_bm.parentId) {
const parent = expect(
this.folder(parentId),
() => `Don't know about parent folder ${parentId}`,
);
parent.children.splice(new_bm.index!, 0, node.id);
}
} else {
// For idempotency, if the bookmark already exists, we merge the new
// info we got with the existing record.
// See if we have a parent, and insert/move ourselves there
if (parentId) {
// Remove from old parent
if (node.parentId) {
const pos = this.positionOf(node);
if (pos) pos.parent.children.splice(pos.index, 1);
}
// Add to new parent
const parent = expect(
this.folder(parentId),
() => `Don't know about parent folder ${parentId}`,
);
parent.children.splice(new_bm.index!, 0, node.id);
}
// Merge title and URL
if ("title" in node) node.title = new_bm.title;
if ("url" in node) {
this._remove_url(node);
node.url = new_bm.url ?? "";
this._add_url(node);
}
if ("dateAdded" in node) node.dateAdded = new_bm.dateAdded;
}
// If we got children, bring them in as well.
if (isBrowserBTNFolder(new_bm) && new_bm.children) {
for (const child of new_bm.children)
this.whenBookmarkCreated(child.id, child);
}
// Finally, see if this folder is a candidate for being a stash root.
if ("children" in node) {
if (node.title === this.stash_root_name) this._stash_root_watch.add(node);
if (this._stash_root_watch.has(node)) this._maybeUpdateStashRoot();
}
}
whenBookmarkChanged(id: string, info: Bookmarks.OnChangedChangeInfoType) {
const node = expect(
this.by_id.get(id as NodeID),
() => `Got change event for unknown node ${id}: ${JSON.stringify(info)}`,
);
if ("title" in info) {
node.title = info.title;
if ("children" in node && node.title === this.stash_root_name) {
this._stash_root_watch.add(node);
}
}
if (info.url !== undefined && "url" in node) {
this._remove_url(node);
node.url = info.url;
this._add_url(node);
}
// If this bookmark has been renamed to != this.stash_root_name,
// _maybeUpdateStashRoot() will remove it from the watch set
// automatically.
if ("children" in node && this._stash_root_watch.has(node)) {
this._maybeUpdateStashRoot();
}
}
whenBookmarkMoved(id: string, info: {parentId: string; index: number}) {
const node = expect(
this.by_id.get(id as NodeID),
() => `Got move event for unknown node ${id}: ${JSON.stringify(info)}`,
);
const new_parent = expect(
this.folder(info.parentId as NodeID),
() => `Move of ${id} is going to unknown folder ${info.parentId}`,
);
const wasInRoot = this.isNodeInStashRoot(node);
const pos = this.positionOf(node);
if (pos) pos.parent.children.splice(pos.index, 1);
node.parentId = info.parentId as NodeID;
new_parent.children.splice(info.index, 0, node.id);
if ("children" in node && this._stash_root_watch.has(node)) {
this._maybeUpdateStashRoot();
}
const isInRoot = this.isNodeInStashRoot(node);
// Clear the selection if we moved the node out of the stash root.
if (wasInRoot !== isInRoot && !isInRoot) {
const clear = (n: Node) => {
n.$selected = false;
if ("children" in n) for (const c of this.childrenOf(n)) clear(c);
};
clear(node);
}
}
whenBookmarkRemoved(id: string) {
const node = this.by_id.get(id as NodeID);
if (!node) return;
// We must remove children before their parents, so that we never have a
// child referencing a parent that doesn't exist.
if ("children" in node) {
for (const c of Array.from(node.children)) this.whenBookmarkRemoved(c);
}
// Make sure the selectedCount gets updated correctly.
node.$selected = false;
const pos = this.positionOf(node);
if (pos) pos.parent.children.splice(pos.index, 1);
this.by_id.delete(node.id);
if ("url" in node) this._remove_url(node);
if ("children" in node && this._stash_root_watch.has(node)) {
// We must explicitly remove `node` here because its title is
// still "Tab Stash" even after it is deleted.
this._stash_root_watch.delete(node);
this._maybeUpdateStashRoot();
}
}
/** Update `this.stash_root` if appropriate. This function tries to be
* fairly efficient in most cases since it is expected to be called quite
* frequently.
*
* We avoid using watch() or watchEffect() here because we have to inspect
* quite a few objects (starting from the root) to determine the stash root,
* and so we want to minimize when this search is actually done. */
private _maybeUpdateStashRoot(): Folder[] {
// Collect the current candidate set from the watch, limiting ourselves
// only to actual candidates (folders with the right name).
let candidates = Array.from(this._stash_root_watch).filter(
bm => bm.children && bm.title === this.stash_root_name,
);
// Find the path from each candidate to the root, and make sure we're
// watching the whole path (so if a parent gets moved, we get called
// again).
const paths = filterMap(candidates, c => ({
folder: c,
path: this.pathTo(c),
}));
this._stash_root_watch = new Set(candidates);
for (const p of paths) {
for (const pos of p.path) this._stash_root_watch.add(pos.parent);
}
// Find the depth of the candidate closest to the root.
const depth = Math.min(...paths.map(p => p.path.length));
// Filter out candidates that are deeper than the minimum depth, and
// sort the remainder in a stable fashion according to their creation
// date and ID.
candidates = paths
.filter(p => p.path.length <= depth)
.map(p => p.folder)
.sort((a, b) => {
const byDate = (a.dateAdded ?? 0) - (b.dateAdded ?? 0);
if (byDate !== 0) return byDate;
if (a.id < b.id) return -1;
if (a.id > b.id) return 1;
return 0;
});
// The actual stash root is the first candidate.
if (this.stash_root.value !== candidates[0]) {
// If the stash root is about to change, then we need to clear the
// selection, because the user can't de-select items outside the
// stash root (and the UI will get stuck in selection mode).
this.clearSelection();
this.stash_root.value = candidates[0];
}
// But if we have multiple candidates, we need to raise the alarm that
// there is an ambiguity the user should resolve.
if (candidates.length > 1) {
this.stash_root_warning.value = {
text:
`You have multiple "${this.stash_root_name}" bookmark ` +
`folders, and Tab Stash isn't sure which one to use. ` +
`Click here to find out how to resolve the issue.`,
/* istanbul ignore next */
help: () => browser.tabs.create({active: true, url: ROOT_FOLDER_HELP}),
};
} else {
this.stash_root_warning.value = undefined;
}
// We return the candidate list here so that callers can see what
// candidates are available (since there may be ways of resolving
// conflicts we can't do here).
return candidates;
}
//
// Handling selection/deselection of bookmarks in the UI
//
isSelected(item: Node): boolean {
return item.$selected;
}
async clearSelection() {
for (const bm of this.by_id.values()) bm.$selected = false;
}
async setSelected(items: Iterable<Node>, isSelected: boolean) {
for (const item of items) item.$selected = isSelected;
}
*selectedItems(): Generator<Node> {
const self = this;
function* walk(bm: Node): Generator<Node> {
if (bm.$selected) {
yield bm;
// If a parent is selected, we don't want to return every single
// node in the subtree because this breaks drag-and-drop--we
// would want to move a folder as a single unit, rather than
// moving the folder, then all its children, then all their
// children, and so on (effectively flattening the tree).
return;
}
if ("children" in bm) {
for (const c of bm.children) {
const node = self.node(c);
if (node) yield* walk(node);
}
}
}
// We only consider items inside the stash root, since those are the
// only items that show up in the UI.
// istanbul ignore else -- when testing we should always have a root
if (this.stash_root.value) yield* walk(this.stash_root.value);
}
itemsInRange(start: Node, end: Node): Node[] | null {
let startPos = this.positionOf(start);
let endPos = this.positionOf(end);
if (!startPos || !endPos) return null;
if (startPos.parent !== endPos.parent) return null;
if (endPos.index < startPos.index) {
const tmp = endPos;
endPos = startPos;
startPos = tmp;
}
return filterMap(
startPos.parent.children.slice(startPos.index, endPos.index + 1),
id => this.node(id),
);
}
private _add_url(bm: Bookmark) {
this.bookmarksWithURL(bm.url).add(bm);
}
private _remove_url(bm: Bookmark) {
const index = this.by_url.get(urlToOpen(bm.url));
// istanbul ignore if -- internal consistency
if (!index) return;
index.delete(bm);
}
}
//
// Public helper functions for dealing with folders under the stash root
//
/** Given a folder name, check if it's an "default"-shaped folder name (i.e.
* just a timestamp) and return the timestamp portion of the name if so. */
export function getDefaultFolderNameISODate(n: string): string | null {
let m = n.match(/saved-([-0-9:.T]+Z)/);
return m ? m[1] : null;
}
/** Generate a "default"-shaped folder name from a timestamp (usually the
* timestamp of its creation). */
export function genDefaultFolderName(date: Date): string {
return `saved-${date.toISOString()}`;
}
/** Given a folder name as it appears in the bookmarks tree, return a "friendly"
* version to show to the user. This translates folder names that look like a
* "default"-shaped folder name into a user-friendly string, if applicable. */
export function friendlyFolderName(name: string): string {
const folderDate = getDefaultFolderNameISODate(name);
if (folderDate) return `Saved ${new Date(folderDate).toLocaleString()}`;
return name;
}
//
// Helper functions for the model
//
/** A cross-browser compatible way to tell if a bookmark returned by the
* `browser.bookmarks` API is a folder or not. */
function isBrowserBTNFolder(bm: Bookmarks.BookmarkTreeNode): boolean {
if (bm.type === "folder") return true; // for Firefox
if (bm.children) return true; // for Chrome (sometimes)
if (!("type" in bm) && !("url" in bm)) return true; // for Chrome
return false;
}