Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 55 additions & 25 deletions src/TemplateCollection.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import TemplateData from "./Data/TemplateData.js";
import Sortable from "./Util/Objects/Sortable.js";
import { isGlobMatch } from "./Util/GlobMatcher.js";

class TemplateCollection extends Sortable {
// tag name => sorted items, rebuilt after any `add()`
#tagIndex;
export default class TemplateCollection extends Sortable {
// Both caches are invalidated in `add`, the only entry point that mutates `items`
#allSortedCache;
#tagIndexCache;

constructor() {
super();
Expand All @@ -15,16 +16,49 @@ class TemplateCollection extends Sortable {
}

add(item) {
this.#tagIndex = undefined;
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() {
Expand Down Expand Up @@ -68,29 +102,25 @@ class TemplateCollection extends Sortable {
return this.getAllSorted();
}

if (!this.#tagIndex) {
this.#tagIndex = new Map();
for (let item of this.getAllSorted()) {
for (let tag of new Set(TemplateData.getIncludedTagNames(item.data))) {
if (!this.#tagIndex.has(tag)) {
this.#tagIndex.set(tag, []);
}
this.#tagIndex.get(tag).push(item);
}
}
}

return this.#tagIndex.get(tagName)?.slice() ?? [];
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));
});
}
}

export default TemplateCollection;
73 changes: 73 additions & 0 deletions test/TemplateCollectionTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down