diff --git a/src/TemplateCollection.js b/src/TemplateCollection.js index 6e99a32a6..41e702f34 100755 --- a/src/TemplateCollection.js +++ b/src/TemplateCollection.js @@ -5,18 +5,61 @@ import Sortable from "./Util/Objects/Sortable.js"; import { isGlobMatch } from "./Util/GlobMatcher.js"; class TemplateCollection extends Sortable { + // Both caches are invalidated in `add`, the only entry point that mutates `items` + #allSortedCache; + #tagIndexCache; + constructor() { super(); this._filteredByGlobsCache = new Map(); } + add(item) { + this.#allSortedCache = undefined; + this.#tagIndexCache = undefined; + + super.add(item); + } + getAll() { return this.items.slice(); } + // Sorted once per mutation instead of once per collection API call + #getAllSorted() { + if (!this.#allSortedCache) { + this.#allSortedCache = this.sort(Sortable.sortFunctionDateInputPath); + } + + return this.#allSortedCache; + } + + // Tag name to items (in sorted order), so tag lookups don’t walk the full collection + #getTagIndex() { + if (!this.#tagIndexCache) { + let index = new Map(); + + for (let item of this.#getAllSorted()) { + for (let tagName of TemplateData.getIncludedTagNames(item.data)) { + let tagItems = index.get(tagName); + if (!tagItems) { + tagItems = []; + index.set(tagName, tagItems); + } + tagItems.push(item); + } + } + + this.#tagIndexCache = index; + } + + return this.#tagIndexCache; + } + getAllSorted() { - return this.sort(Sortable.sortFunctionDateInputPath); + // Callers sort and reverse the result in place, so hand out a copy + return this.#getAllSorted().slice(); } getSortedByDate() { @@ -56,20 +99,29 @@ class TemplateCollection extends Sortable { } getFilteredByTag(tagName) { - return this.getAllSorted().filter((item) => { - if (!tagName || TemplateData.getIncludedTagNames(item.data).includes(tagName)) { - return true; - } - return false; - }); + if (!tagName) { + return this.getAllSorted(); + } + + return this.#getTagIndex().get(tagName)?.slice() || []; } getFilteredByTags(...tags) { - return this.getAllSorted().filter((item) => { - let itemTags = new Set(TemplateData.getIncludedTagNames(item.data)); - return tags.every((requiredTag) => { - return itemTags.has(requiredTag); - }); + if (!tags.length) { + return this.getAllSorted(); + } + + let index = this.#getTagIndex(); + let [firstTag, ...remainingTags] = tags; + let matches = index.get(firstTag) || []; + if (!remainingTags.length) { + return matches.slice(); + } + + // Intersect the first tag’s matches with the remaining tags + let remainingItems = remainingTags.map((tagName) => new Set(index.get(tagName))); + return matches.filter((item) => { + return remainingItems.every((tagItems) => tagItems.has(item)); }); } } diff --git a/test/TemplateCollectionTest.js b/test/TemplateCollectionTest.js index ae9ab2c0b..c23ac118b 100644 --- a/test/TemplateCollectionTest.js +++ b/test/TemplateCollectionTest.js @@ -139,6 +139,79 @@ test("getFilteredByTag (added out of order, sorted)", async (t) => { t.deepEqual(dogs[0].template, tmpl1); }); +test("getFilteredByTag (results are cached, cache is invalidated by add)", async (t) => { + let $config = await getTemplateConfigInstance(); + + let tmpl1 = await getNewTemplateByNumber(1, $config); + let tmpl2 = await getNewTemplateByNumber(2, $config); + let tmpl3 = await getNewTemplateByNumber(3, $config); + + let c = new Collection(); + await addTemplate(c, tmpl1); + + let posts = c.getFilteredByTag("post"); + t.is(posts.length, 1); + + await addTemplate(c, tmpl2); + await addTemplate(c, tmpl3); + + t.is(c.getFilteredByTag("post").length, 2); + t.is(c.getFilteredByTag("cat").length, 2); + t.is(c.getFilteredByTags("post", "cat").length, 1); + t.is(c.getAllSorted().length, 3); + + // The result returned before the additions is unaffected + t.is(posts.length, 1); +}); + +test("getFilteredByTag (results are mutable copies)", async (t) => { + let $config = await getTemplateConfigInstance(); + + let tmpl1 = await getNewTemplateByNumber(1, $config); + let tmpl3 = await getNewTemplateByNumber(3, $config); + + let c = new Collection(); + await addTemplate(c, tmpl1); + await addTemplate(c, tmpl3); + + let posts = c.getFilteredByTag("post"); + posts.reverse(); + posts.pop(); + + let posts2 = c.getFilteredByTag("post"); + t.is(posts2.length, 2); + t.deepEqual(posts2[0].template, tmpl1); + t.deepEqual(posts2[1].template, tmpl3); + + let postsPlural = c.getFilteredByTags("post"); + postsPlural.pop(); + t.is(c.getFilteredByTags("post").length, 2); +}); + +test("getFilteredByTag (no tag name returns everything, sorted)", async (t) => { + let $config = await getTemplateConfigInstance(); + + let tmpl1 = await getNewTemplateByNumber(1, $config); + let tmpl4 = await getNewTemplateByNumber(4, $config); + let tmpl5 = await getNewTemplateByNumber(5, $config); + + let c = new Collection(); + await addTemplate(c, tmpl1); + await addTemplate(c, tmpl4); + await addTemplate(c, tmpl5); + + let all = c.getFilteredByTag(); + t.is(all.length, 3); + t.deepEqual(all[0].template, tmpl4); + t.deepEqual(all[1].template, tmpl1); + t.deepEqual(all[2].template, tmpl5); + + // `all` is not a tag name, it is filtered out of the included tag names + t.is(c.getFilteredByTag("all").length, 0); + t.is(c.getFilteredByTag("unknown-tag").length, 0); + t.is(c.getFilteredByTags().length, 3); +}); + test("getFilteredByTags", async (t) => { let $config = await getTemplateConfigInstance();