Skip to content

Commit 9936278

Browse files
committed
feat: add index schema for querying the vault
feat: implement error helper for "checkable" luxon types refactor: introduce "const" boundary for export-only files build: extend obsidian-dataview export with better type information
1 parent dbaca16 commit 9936278

File tree

9 files changed

+309
-5
lines changed

9 files changed

+309
-5
lines changed

boundaries.config.js

+6
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ export const ELEMENTS = [
1616
capture: ["scope", "elementName", "lib"],
1717
mode: "full",
1818
},
19+
{
20+
type: "const",
21+
pattern: "*.const.ts",
22+
mode: "file",
23+
},
1924

2025
defineFolderScope("model"),
2126
];
@@ -24,6 +29,7 @@ export const ELEMENT_TYPE_RULES = [
2429
{ from: "*", allow: "shared" },
2530
{ from: "main", allow: [["lib", { lib: "obsidian" }]] },
2631
{ from: "(lib|lib:scope)", allow: [["lib", { lib: "${from.lib}" }]] },
32+
{ from: "const", disallow: "*" },
2733

2834
...fromScopeAllowItself("model"),
2935
...fromScopeElementAllowTargetScopeElements("model", "index", [["model", "collection"]]),

src/lib/obsidian-dataview/types.ts

+10-3
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { DateTime } from "luxon";
2-
import type { STask } from "obsidian-dataview/lib/data-model/serialized/markdown";
2+
import { DataArray } from "obsidian-dataview/lib/api/data-array";
3+
import type { DataviewApi as ActualDataviewApi } from "obsidian-dataview/lib/api/plugin-api";
4+
import type { SMarkdownPage, STask } from "obsidian-dataview/lib/data-model/serialized/markdown";
35

4-
export { DataviewApi, getAPI, isPluginEnabled } from "obsidian-dataview";
5-
export type { SMarkdownPage as DataviewMarkdownPage } from "obsidian-dataview/lib/data-model/serialized/markdown";
6+
export { getAPI, isPluginEnabled } from "obsidian-dataview";
7+
8+
export type DataviewMarkdownPage = SMarkdownPage;
69

710
export interface DataviewMarkdownTask extends STask {
811
created?: DateTime;
@@ -11,3 +14,7 @@ export interface DataviewMarkdownTask extends STask {
1114
start?: DateTime;
1215
scheduled?: DateTime;
1316
}
17+
18+
export interface DataviewApi extends ActualDataviewApi {
19+
pages(query: string): DataArray<SMarkdownPage>;
20+
}

src/model/index/schema.ts

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Interval } from "luxon";
2+
import { DeepReadonly } from "utility-types";
3+
4+
import { Collection } from "../collection/schema";
5+
6+
/** Provides an API for efficiently accessing {@link Collection}s in a vault. */
7+
export interface VaultIndex {
8+
/**
9+
* Register a new {@link Collection} into the index.
10+
*
11+
* All calls to this function will be treated as adding a brand new collection, regardless
12+
* of whether the input has already been registered in an earlier call.
13+
*
14+
* @param collection - the collection to register with this index.
15+
* @throws if the index cannot support the given collection.
16+
* @returns a {@link VaultCollectionIndex} that can be used to interact with the primary index.
17+
*/
18+
addCollection<C extends Collection>(collection: C): VaultCollectionIndex<C>;
19+
20+
/** @returns the list of collections registered with this index. */
21+
getCollections(): Collection[];
22+
23+
/** @returns a list of collections that claim to include the given file path. */
24+
getCollectionsWithFile(filePath: string): Collection[];
25+
}
26+
27+
/** Provides an API for efficiently querying a {@link Collection} for its notes. */
28+
export interface VaultCollectionIndex<C extends Collection> {
29+
/** The collection that has been indexed. */
30+
readonly collection: DeepReadonly<C>;
31+
32+
/** @returns all paths in the vault corresponding to this indexed collection. */
33+
getNotes(): string[];
34+
35+
/** @returns all paths in the vault corresponding to the given interval, if applicable. */
36+
getNotesFromInterval(interval: Interval): string[];
37+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`assertNoOverlaps > with message "user-provided message" > should reject "group of equal intervals" 1`] = `
4+
[Error: user-provided message
5+
- indexes between [0, 5) all overlap with [2025-03-01T00:00:00.000Z – 2025-03-07T00:00:00.000Z)]
6+
`;
7+
8+
exports[`assertNoOverlaps > with message "user-provided message" > should reject "group of one interval after an overlapping group" 1`] = `
9+
[Error: user-provided message
10+
- indexes between [0, 3) all overlap with [2025-03-05T00:00:00.000Z – 2025-03-10T00:00:00.000Z)]
11+
`;
12+
13+
exports[`assertNoOverlaps > with message "user-provided message" > should reject "group of one interval before an overlapping group" 1`] = `
14+
[Error: user-provided message
15+
- indexes between [1, 4) all overlap with [2025-03-05T00:00:00.000Z – 2025-03-10T00:00:00.000Z)]
16+
`;
17+
18+
exports[`assertNoOverlaps > with message "user-provided message" > should reject "group of overlapping intervals with non-overlapping intervals in between" 1`] = `
19+
[Error: user-provided message
20+
- indexes between [0, 2) all overlap with [2025-03-01T00:00:00.000Z – 2025-03-05T00:00:00.000Z)
21+
- indexes between [3, 5) all overlap with [2025-03-21T00:00:00.000Z – 2025-03-25T00:00:00.000Z)
22+
- indexes between [6, 8) all overlap with [2025-04-13T00:00:00.000Z – 2025-04-17T00:00:00.000Z)]
23+
`;
24+
25+
exports[`assertNoOverlaps > with message "user-provided message" > should reject "group of overlapping intervals with the same end time" 1`] = `
26+
[Error: user-provided message
27+
- indexes between [0, 3) all overlap with [2025-03-01T00:00:00.000Z – 2025-03-06T00:00:00.000Z)]
28+
`;
29+
30+
exports[`assertNoOverlaps > with message "user-provided message" > should reject "group of overlapping intervals with the same start time" 1`] = `
31+
[Error: user-provided message
32+
- indexes between [0, 3) all overlap with [2025-03-01T00:00:00.000Z – 2025-03-06T00:00:00.000Z)]
33+
`;
34+
35+
exports[`assertNoOverlaps > with message "user-provided message" > should reject "pair of overlapping intervals" 1`] = `
36+
[Error: user-provided message
37+
- indexes between [0, 2) all overlap with [2025-03-01T00:00:00.000Z – 2025-03-05T00:00:00.000Z)]
38+
`;
39+
40+
exports[`assertNoOverlaps > with message undefined > should reject "group of equal intervals" 1`] = `[Error: - indexes between [0, 5) all overlap with [2025-03-01T00:00:00.000Z – 2025-03-07T00:00:00.000Z)]`;
41+
42+
exports[`assertNoOverlaps > with message undefined > should reject "group of one interval after an overlapping group" 1`] = `[Error: - indexes between [0, 3) all overlap with [2025-03-05T00:00:00.000Z – 2025-03-10T00:00:00.000Z)]`;
43+
44+
exports[`assertNoOverlaps > with message undefined > should reject "group of one interval before an overlapping group" 1`] = `[Error: - indexes between [1, 4) all overlap with [2025-03-05T00:00:00.000Z – 2025-03-10T00:00:00.000Z)]`;
45+
46+
exports[`assertNoOverlaps > with message undefined > should reject "group of overlapping intervals with non-overlapping intervals in between" 1`] = `
47+
[Error: - indexes between [0, 2) all overlap with [2025-03-01T00:00:00.000Z – 2025-03-05T00:00:00.000Z)
48+
- indexes between [3, 5) all overlap with [2025-03-21T00:00:00.000Z – 2025-03-25T00:00:00.000Z)
49+
- indexes between [6, 8) all overlap with [2025-04-13T00:00:00.000Z – 2025-04-17T00:00:00.000Z)]
50+
`;
51+
52+
exports[`assertNoOverlaps > with message undefined > should reject "group of overlapping intervals with the same end time" 1`] = `[Error: - indexes between [0, 3) all overlap with [2025-03-01T00:00:00.000Z – 2025-03-06T00:00:00.000Z)]`;
53+
54+
exports[`assertNoOverlaps > with message undefined > should reject "group of overlapping intervals with the same start time" 1`] = `[Error: - indexes between [0, 3) all overlap with [2025-03-01T00:00:00.000Z – 2025-03-06T00:00:00.000Z)]`;
55+
56+
exports[`assertNoOverlaps > with message undefined > should reject "pair of overlapping intervals" 1`] = `[Error: - indexes between [0, 2) all overlap with [2025-03-01T00:00:00.000Z – 2025-03-05T00:00:00.000Z)]`;
+78
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { Interval } from "luxon";
2+
3+
export const WITH_OVERLAPPING_INTERVALS = {
4+
"pair of overlapping intervals": [
5+
Interval.fromISO("2025-03-01/2025-03-04", { zone: "utc" }),
6+
Interval.fromISO("2025-03-02/2025-03-05", { zone: "utc" }),
7+
],
8+
9+
"group of equal intervals": [
10+
Interval.fromISO("2025-03-01/2025-03-07", { zone: "utc" }),
11+
Interval.fromISO("2025-03-01/2025-03-07", { zone: "utc" }),
12+
Interval.fromISO("2025-03-01/2025-03-07", { zone: "utc" }),
13+
Interval.fromISO("2025-03-01/2025-03-07", { zone: "utc" }),
14+
Interval.fromISO("2025-03-01/2025-03-07", { zone: "utc" }),
15+
],
16+
17+
"group of overlapping intervals with the same start time": [
18+
Interval.fromISO("2025-03-01/2025-03-04", { zone: "utc" }),
19+
Interval.fromISO("2025-03-01/2025-03-05", { zone: "utc" }),
20+
Interval.fromISO("2025-03-01/2025-03-06", { zone: "utc" }),
21+
],
22+
23+
"group of overlapping intervals with the same end time": [
24+
Interval.fromISO("2025-03-01/2025-03-06", { zone: "utc" }),
25+
Interval.fromISO("2025-03-02/2025-03-06", { zone: "utc" }),
26+
Interval.fromISO("2025-03-03/2025-03-06", { zone: "utc" }),
27+
],
28+
29+
"group of overlapping intervals with non-overlapping intervals in between": [
30+
Interval.fromISO("2025-03-01/2025-03-04", { zone: "utc" }),
31+
Interval.fromISO("2025-03-02/2025-03-05", { zone: "utc" }),
32+
Interval.fromISO("2025-03-13/2025-03-16", { zone: "utc" }),
33+
Interval.fromISO("2025-03-21/2025-03-24", { zone: "utc" }),
34+
Interval.fromISO("2025-03-22/2025-03-25", { zone: "utc" }),
35+
Interval.fromISO("2025-04-02/2025-04-05", { zone: "utc" }),
36+
Interval.fromISO("2025-04-13/2025-04-16", { zone: "utc" }),
37+
Interval.fromISO("2025-04-14/2025-04-17", { zone: "utc" }),
38+
],
39+
40+
"group of one interval before an overlapping group": [
41+
Interval.fromISO("2025-03-01/2025-03-03", { zone: "utc" }),
42+
Interval.fromISO("2025-03-05/2025-03-08", { zone: "utc" }),
43+
Interval.fromISO("2025-03-06/2025-03-09", { zone: "utc" }),
44+
Interval.fromISO("2025-03-07/2025-03-10", { zone: "utc" }),
45+
],
46+
47+
"group of one interval after an overlapping group": [
48+
Interval.fromISO("2025-03-05/2025-03-08", { zone: "utc" }),
49+
Interval.fromISO("2025-03-06/2025-03-09", { zone: "utc" }),
50+
Interval.fromISO("2025-03-07/2025-03-10", { zone: "utc" }),
51+
Interval.fromISO("2025-03-11/2025-03-13", { zone: "utc" }),
52+
],
53+
} as Record<string, Interval<true>[]>;
54+
55+
export const WITHOUT_OVERLAPPING_INTERVALS = {
56+
"empty group of intervals": [],
57+
"group of one interval": [Interval.fromISO("2025-03-01/2025-03-04", { zone: "utc" })],
58+
59+
"pair of non-overlapping intervals": [
60+
Interval.fromISO("2025-03-01/2025-03-04", { zone: "utc" }),
61+
Interval.fromISO("2025-03-14/2025-03-15", { zone: "utc" }),
62+
],
63+
64+
"group of adjacent intervals": [
65+
Interval.fromISO("2025-03-01/2025-03-02", { zone: "utc" }),
66+
Interval.fromISO("2025-03-02/2025-03-03", { zone: "utc" }),
67+
Interval.fromISO("2025-03-03/2025-03-04", { zone: "utc" }),
68+
Interval.fromISO("2025-03-04/2025-03-05", { zone: "utc" }),
69+
Interval.fromISO("2025-03-05/2025-03-06", { zone: "utc" }),
70+
],
71+
72+
"group of non-overlapping intervals": [
73+
Interval.fromISO("2025-03-01/2025-03-03", { zone: "utc" }),
74+
Interval.fromISO("2025-03-05/2025-03-07", { zone: "utc" }),
75+
Interval.fromISO("2025-03-09/2025-03-11", { zone: "utc" }),
76+
Interval.fromISO("2025-03-13/2025-03-15", { zone: "utc" }),
77+
],
78+
} as Record<string, Interval<true>[]>;

src/util/__tests__/luxon-utils.ts

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { entriesIn } from "lodash";
2+
import { DateTime, Duration, Interval } from "luxon";
3+
import { describe, expect, it } from "vitest";
4+
5+
import { assertNoOverlaps, newInvalidError } from "../luxon-utils";
6+
import { WITH_OVERLAPPING_INTERVALS, WITHOUT_OVERLAPPING_INTERVALS } from "./luxon-utils.const";
7+
8+
describe(newInvalidError.name, () => {
9+
const reason = "reason";
10+
const explanation = "explanation";
11+
12+
describe.each(["user-provided message", undefined])("with message %j", (message) => {
13+
it.each([DateTime, Duration, Interval].map((ctor) => ctor.invalid(reason, explanation)))(
14+
"should accept invalid $constructor.name",
15+
(invalidObj) => {
16+
const errorMessage = newInvalidError(invalidObj, message).message;
17+
expect(errorMessage).toMatch(message ?? "");
18+
expect(errorMessage).toMatch(reason);
19+
expect(errorMessage).toMatch(explanation);
20+
},
21+
);
22+
23+
it("should accept undefined", () => {
24+
const errorMessage = newInvalidError().message;
25+
expect(errorMessage).toMatch("unspecified error");
26+
});
27+
});
28+
});
29+
30+
describe(assertNoOverlaps.name, () => {
31+
describe.each(["user-provided message", undefined])("with message %j", (message) => {
32+
it.each(entriesIn(WITHOUT_OVERLAPPING_INTERVALS))("should accept %j", (_, intervals) => {
33+
expect(() => assertNoOverlaps(intervals, message)).not.toThrow();
34+
});
35+
36+
it.each(entriesIn(WITH_OVERLAPPING_INTERVALS))("should reject %j", (_, intervals) => {
37+
expect(() => assertNoOverlaps(intervals, message)).toThrowErrorMatchingSnapshot();
38+
});
39+
});
40+
});

src/util/luxon-utils.ts

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import assert from "assert";
2+
import { isString, sortBy } from "lodash";
3+
import { DateTime, Duration, Interval } from "luxon";
4+
5+
/**
6+
* @param obj - the invalid luxon object.
7+
* @param message - optional message to include with the error.
8+
*
9+
* @returns an {@link Error} with debug information extracted from the "invalid" input.
10+
*/
11+
export function newInvalidError(obj?: DateTime<false> | Duration<false> | Interval<false>, message?: string): Error {
12+
const lines = isString(message) ? [message] : [];
13+
lines.push(
14+
obj ?
15+
`Invalid${obj.constructor.name}: ${obj.invalidReason}. ${obj.invalidExplanation}`.trim()
16+
: `unspecified error`,
17+
);
18+
return new Error(lines.join("\n"));
19+
}
20+
21+
/**
22+
* @param sorted - an array of intervals sorted by start time (primary) and end time (secondary).
23+
*
24+
* @returns array of [index inclusive, index exclusive) pairs for each sub-sequence of overlapping intervals.
25+
*/
26+
export function getIndexCollisions(sorted: Interval<true>[]): [number, number][] {
27+
const collidingRanges: [number, number][] = [];
28+
29+
let startIncl = 0;
30+
for (let stopExcl = 2; stopExcl <= sorted.length; ++stopExcl) {
31+
const last = sorted[stopExcl - 1];
32+
const formerStartIncl = startIncl;
33+
while (startIncl < stopExcl && !intersects(sorted[startIncl], last)) {
34+
startIncl += 1;
35+
}
36+
if (startIncl - formerStartIncl > 1) {
37+
collidingRanges.push([formerStartIncl, stopExcl - 1]);
38+
}
39+
}
40+
if (sorted.length - startIncl > 1) {
41+
collidingRanges.push([startIncl, sorted.length]);
42+
}
43+
44+
return collidingRanges;
45+
}
46+
47+
/**
48+
* @param a - the first interval. Must be _earlier than_ the second interval.
49+
* @param b - the second interval. Must be _later than_ the first interval.
50+
*
51+
* @returns true if the two intervals have a non-empty intersection.
52+
*/
53+
export function intersects(a: Interval<true>, b: Interval<true>): boolean {
54+
assert(a.start <= b.start && a.end <= b.end);
55+
return b.overlaps(a) && !b.abutsStart(a);
56+
}
57+
58+
/**
59+
* Asserts that the given array of intervals have no intersections.
60+
*
61+
* @param unsorted - the array of intervals to check.
62+
* @param message - optional message to include with the error.
63+
* @throws an {@link Error} if the array has overlapping intervals.
64+
*/
65+
export function assertNoOverlaps(unsorted: ReadonlyArray<Interval<true>>, message?: string): void {
66+
const sorted = sortBy(unsorted, "start", "end");
67+
const indexCollisions = getIndexCollisions(sorted);
68+
69+
if (indexCollisions.length > 0) {
70+
const lines = isString(message) ? [message] : [];
71+
lines.push(
72+
...indexCollisions.map(([start, end]) => {
73+
const overlap = Interval.fromDateTimes(sorted[start].start, sorted[end - 1].end);
74+
return `\t-\tindexes between [${start}, ${end}) all overlap with ${overlap}`;
75+
}),
76+
);
77+
throw new Error(lines.join("\n"));
78+
}
79+
}

typedoc.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"entryPoints": ["src/**/*"],
3-
"exclude": ["**/node_modules/**/*", "**/__tests__/**/*"],
3+
"exclude": ["**/node_modules/**/*", "**/__tests__/**/*", "**/*.const.ts"],
44
"plugin": ["typedoc-plugin-coverage", "typedoc-github-theme"]
55
}

vite.config.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,14 @@ export default defineConfig(({ mode }) => ({
3636
test: {
3737
environment: "jsdom",
3838
include: ["src/**/__tests__/*"],
39+
exclude: ["**/*.const.{ts,tsx}"],
3940
coverage: {
4041
all: true,
4142
include: ["src/"],
4243
exclude: ["src/main.tsx", "src/lib/"],
4344
thresholds: { 100: true },
4445
reporter: ["text", "html", "clover", "json", "lcov"],
4546
},
46-
reporters: [["junit", { outputFile: "test-report.junit.xml" }], "default"],
47+
reporters: [["junit", { outputFile: "test-report.junit.xml" }], "verbose"],
4748
},
4849
}));

0 commit comments

Comments
 (0)