Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add index schema for querying the vault #22

Merged
merged 4 commits into from
Mar 4, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions boundaries.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ export const ELEMENTS = [
capture: ["scope", "elementName", "lib"],
mode: "full",
},
{
type: "const",
pattern: "*.const.ts",
mode: "file",
},

defineFolderScope("model"),
];
Expand All @@ -24,6 +29,7 @@ export const ELEMENT_TYPE_RULES = [
{ from: "*", allow: "shared" },
{ from: "main", allow: [["lib", { lib: "obsidian" }]] },
{ from: "(lib|lib:scope)", allow: [["lib", { lib: "${from.lib}" }]] },
{ from: "const", disallow: "*" },

...fromScopeAllowItself("model"),
...fromScopeElementAllowTargetScopeElements("model", "index", [["model", "collection"]]),
Expand Down
13 changes: 10 additions & 3 deletions src/lib/obsidian-dataview/types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { DateTime } from "luxon";
import type { STask } from "obsidian-dataview/lib/data-model/serialized/markdown";
import { DataArray } from "obsidian-dataview/lib/api/data-array";
import type { DataviewApi as ActualDataviewApi } from "obsidian-dataview/lib/api/plugin-api";
import type { SMarkdownPage, STask } from "obsidian-dataview/lib/data-model/serialized/markdown";

export { DataviewApi, getAPI, isPluginEnabled } from "obsidian-dataview";
export type { SMarkdownPage as DataviewMarkdownPage } from "obsidian-dataview/lib/data-model/serialized/markdown";
export { getAPI, isPluginEnabled } from "obsidian-dataview";

export type DataviewMarkdownPage = SMarkdownPage;

export interface DataviewMarkdownTask extends STask {
created?: DateTime;
Expand All @@ -11,3 +14,7 @@ export interface DataviewMarkdownTask extends STask {
start?: DateTime;
scheduled?: DateTime;
}

export interface DataviewApi extends ActualDataviewApi {
pages(query: string): DataArray<SMarkdownPage>;
}
37 changes: 37 additions & 0 deletions src/model/index/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Interval } from "luxon";
import { DeepReadonly } from "utility-types";

import { Collection } from "../collection/schema";

/** Provides an API for efficiently accessing {@link Collection}s in a vault. */
export interface VaultIndex {
/**
* Register a new {@link Collection} into the index.
*
* All calls to this function will be treated as adding a brand new collection, regardless
* of whether the input has already been registered in an earlier call.
*
* @param collection - the collection to register with this index.
* @throws if the index cannot support the given collection.
* @returns a {@link VaultCollectionIndex} that can be used to interact with the primary index.
*/
addCollection<C extends Collection>(collection: C): VaultCollectionIndex<C>;

/** @returns the list of collections registered with this index. */
getCollections(): Collection[];

/** @returns a list of collections that claim to include the given file path. */
getCollectionsWithFile(filePath: string): Collection[];
}

/** Provides an API for efficiently querying a {@link Collection} for its notes. */
export interface VaultCollectionIndex<C extends Collection> {
/** The collection that has been indexed. */
readonly collection: DeepReadonly<C>;

/** @returns all paths in the vault corresponding to this indexed collection. */
getNotes(): string[];

/** @returns all paths in the vault corresponding to the given interval, if applicable. */
getNotesFromInterval(interval: Interval): string[];
}
56 changes: 56 additions & 0 deletions src/util/__tests__/__snapshots__/luxon-utils.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`assertNoOverlaps > with message "user-provided message" > should reject "group of equal intervals" 1`] = `
[Error: user-provided message
- indexes between [0, 5) all overlap with [2025-03-01T00:00:00.000Z – 2025-03-07T00:00:00.000Z)]
`;

exports[`assertNoOverlaps > with message "user-provided message" > should reject "group of one interval after an overlapping group" 1`] = `
[Error: user-provided message
- indexes between [0, 3) all overlap with [2025-03-05T00:00:00.000Z – 2025-03-10T00:00:00.000Z)]
`;

exports[`assertNoOverlaps > with message "user-provided message" > should reject "group of one interval before an overlapping group" 1`] = `
[Error: user-provided message
- indexes between [1, 4) all overlap with [2025-03-05T00:00:00.000Z – 2025-03-10T00:00:00.000Z)]
`;

exports[`assertNoOverlaps > with message "user-provided message" > should reject "group of overlapping intervals with non-overlapping intervals in between" 1`] = `
[Error: user-provided message
- indexes between [0, 2) all overlap with [2025-03-01T00:00:00.000Z – 2025-03-05T00:00:00.000Z)
- indexes between [3, 5) all overlap with [2025-03-21T00:00:00.000Z – 2025-03-25T00:00:00.000Z)
- indexes between [6, 8) all overlap with [2025-04-13T00:00:00.000Z – 2025-04-17T00:00:00.000Z)]
`;

exports[`assertNoOverlaps > with message "user-provided message" > should reject "group of overlapping intervals with the same end time" 1`] = `
[Error: user-provided message
- indexes between [0, 3) all overlap with [2025-03-01T00:00:00.000Z – 2025-03-06T00:00:00.000Z)]
`;

exports[`assertNoOverlaps > with message "user-provided message" > should reject "group of overlapping intervals with the same start time" 1`] = `
[Error: user-provided message
- indexes between [0, 3) all overlap with [2025-03-01T00:00:00.000Z – 2025-03-06T00:00:00.000Z)]
`;

exports[`assertNoOverlaps > with message "user-provided message" > should reject "pair of overlapping intervals" 1`] = `
[Error: user-provided message
- indexes between [0, 2) all overlap with [2025-03-01T00:00:00.000Z – 2025-03-05T00:00:00.000Z)]
`;

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)]`;

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)]`;

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)]`;

exports[`assertNoOverlaps > with message undefined > should reject "group of overlapping intervals with non-overlapping intervals in between" 1`] = `
[Error: - indexes between [0, 2) all overlap with [2025-03-01T00:00:00.000Z – 2025-03-05T00:00:00.000Z)
- indexes between [3, 5) all overlap with [2025-03-21T00:00:00.000Z – 2025-03-25T00:00:00.000Z)
- indexes between [6, 8) all overlap with [2025-04-13T00:00:00.000Z – 2025-04-17T00:00:00.000Z)]
`;

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)]`;

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)]`;

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 changes: 78 additions & 0 deletions src/util/__tests__/luxon-utils.const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Interval } from "luxon";

export const WITH_OVERLAPPING_INTERVALS = {
"pair of overlapping intervals": [
Interval.fromISO("2025-03-01/2025-03-04", { zone: "utc" }),
Interval.fromISO("2025-03-02/2025-03-05", { zone: "utc" }),
],

"group of equal intervals": [
Interval.fromISO("2025-03-01/2025-03-07", { zone: "utc" }),
Interval.fromISO("2025-03-01/2025-03-07", { zone: "utc" }),
Interval.fromISO("2025-03-01/2025-03-07", { zone: "utc" }),
Interval.fromISO("2025-03-01/2025-03-07", { zone: "utc" }),
Interval.fromISO("2025-03-01/2025-03-07", { zone: "utc" }),
],

"group of overlapping intervals with the same start time": [
Interval.fromISO("2025-03-01/2025-03-04", { zone: "utc" }),
Interval.fromISO("2025-03-01/2025-03-05", { zone: "utc" }),
Interval.fromISO("2025-03-01/2025-03-06", { zone: "utc" }),
],

"group of overlapping intervals with the same end time": [
Interval.fromISO("2025-03-01/2025-03-06", { zone: "utc" }),
Interval.fromISO("2025-03-02/2025-03-06", { zone: "utc" }),
Interval.fromISO("2025-03-03/2025-03-06", { zone: "utc" }),
],

"group of overlapping intervals with non-overlapping intervals in between": [
Interval.fromISO("2025-03-01/2025-03-04", { zone: "utc" }),
Interval.fromISO("2025-03-02/2025-03-05", { zone: "utc" }),
Interval.fromISO("2025-03-13/2025-03-16", { zone: "utc" }),
Interval.fromISO("2025-03-21/2025-03-24", { zone: "utc" }),
Interval.fromISO("2025-03-22/2025-03-25", { zone: "utc" }),
Interval.fromISO("2025-04-02/2025-04-05", { zone: "utc" }),
Interval.fromISO("2025-04-13/2025-04-16", { zone: "utc" }),
Interval.fromISO("2025-04-14/2025-04-17", { zone: "utc" }),
],

"group of one interval before an overlapping group": [
Interval.fromISO("2025-03-01/2025-03-03", { zone: "utc" }),
Interval.fromISO("2025-03-05/2025-03-08", { zone: "utc" }),
Interval.fromISO("2025-03-06/2025-03-09", { zone: "utc" }),
Interval.fromISO("2025-03-07/2025-03-10", { zone: "utc" }),
],

"group of one interval after an overlapping group": [
Interval.fromISO("2025-03-05/2025-03-08", { zone: "utc" }),
Interval.fromISO("2025-03-06/2025-03-09", { zone: "utc" }),
Interval.fromISO("2025-03-07/2025-03-10", { zone: "utc" }),
Interval.fromISO("2025-03-11/2025-03-13", { zone: "utc" }),
],
} as Record<string, Interval<true>[]>;

export const WITHOUT_OVERLAPPING_INTERVALS = {
"empty group of intervals": [],
"group of one interval": [Interval.fromISO("2025-03-01/2025-03-04", { zone: "utc" })],

"pair of non-overlapping intervals": [
Interval.fromISO("2025-03-01/2025-03-04", { zone: "utc" }),
Interval.fromISO("2025-03-14/2025-03-15", { zone: "utc" }),
],

"group of adjacent intervals": [
Interval.fromISO("2025-03-01/2025-03-02", { zone: "utc" }),
Interval.fromISO("2025-03-02/2025-03-03", { zone: "utc" }),
Interval.fromISO("2025-03-03/2025-03-04", { zone: "utc" }),
Interval.fromISO("2025-03-04/2025-03-05", { zone: "utc" }),
Interval.fromISO("2025-03-05/2025-03-06", { zone: "utc" }),
],

"group of non-overlapping intervals": [
Interval.fromISO("2025-03-01/2025-03-03", { zone: "utc" }),
Interval.fromISO("2025-03-05/2025-03-07", { zone: "utc" }),
Interval.fromISO("2025-03-09/2025-03-11", { zone: "utc" }),
Interval.fromISO("2025-03-13/2025-03-15", { zone: "utc" }),
],
} as Record<string, Interval<true>[]>;
40 changes: 40 additions & 0 deletions src/util/__tests__/luxon-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { entriesIn } from "lodash";
import { DateTime, Duration, Interval } from "luxon";
import { describe, expect, it } from "vitest";

import { assertNoOverlaps, newInvalidError } from "../luxon-utils";
import { WITH_OVERLAPPING_INTERVALS, WITHOUT_OVERLAPPING_INTERVALS } from "./luxon-utils.const";

describe(newInvalidError.name, () => {
const reason = "reason";
const explanation = "explanation";

describe.each(["user-provided message", undefined])("with message %j", (message) => {
it.each([DateTime, Duration, Interval].map((ctor) => ctor.invalid(reason, explanation)))(
"should accept invalid $constructor.name",
(invalidObj) => {
const errorMessage = newInvalidError(invalidObj, message).message;
expect(errorMessage).toMatch(message ?? "");
expect(errorMessage).toMatch(reason);
expect(errorMessage).toMatch(explanation);
},
);

it("should accept undefined", () => {
const errorMessage = newInvalidError().message;
expect(errorMessage).toMatch("unspecified error");
});
});
});

describe(assertNoOverlaps.name, () => {
describe.each(["user-provided message", undefined])("with message %j", (message) => {
it.each(entriesIn(WITHOUT_OVERLAPPING_INTERVALS))("should accept %j", (_, intervals) => {
expect(() => assertNoOverlaps(intervals, message)).not.toThrow();
});

it.each(entriesIn(WITH_OVERLAPPING_INTERVALS))("should reject %j", (_, intervals) => {
expect(() => assertNoOverlaps(intervals, message)).toThrowErrorMatchingSnapshot();
});
});
});
79 changes: 79 additions & 0 deletions src/util/luxon-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import assert from "assert";
import { isString, sortBy } from "lodash";
import { DateTime, Duration, Interval } from "luxon";

/**
* @param obj - the invalid luxon object.
* @param message - optional message to include with the error.
*
* @returns an {@link Error} with debug information extracted from the "invalid" input.
*/
export function newInvalidError(obj?: DateTime<false> | Duration<false> | Interval<false>, message?: string): Error {
const lines = isString(message) ? [message] : [];
lines.push(
obj ?
`Invalid${obj.constructor.name}: ${obj.invalidReason}. ${obj.invalidExplanation}`.trim()
: `unspecified error`,
);
return new Error(lines.join("\n"));
}

/**
* @param sorted - an array of intervals sorted by start time (primary) and end time (secondary).
*
* @returns array of [index inclusive, index exclusive) pairs for each sub-sequence of overlapping intervals.
*/
export function getIndexCollisions(sorted: Interval<true>[]): [number, number][] {
const collidingRanges: [number, number][] = [];

let startIncl = 0;
for (let stopExcl = 2; stopExcl <= sorted.length; ++stopExcl) {
const last = sorted[stopExcl - 1];
const formerStartIncl = startIncl;
while (startIncl < stopExcl && !intersects(sorted[startIncl], last)) {
startIncl += 1;
}
if (startIncl - formerStartIncl > 1) {
collidingRanges.push([formerStartIncl, stopExcl - 1]);
}
}
if (sorted.length - startIncl > 1) {
collidingRanges.push([startIncl, sorted.length]);
}

return collidingRanges;
}

/**
* @param a - the first interval. Must be _earlier than_ the second interval.
* @param b - the second interval. Must be _later than_ the first interval.
*
* @returns true if the two intervals have a non-empty intersection.
*/
export function intersects(a: Interval<true>, b: Interval<true>): boolean {
assert(a.start <= b.start && a.end <= b.end);
return b.overlaps(a) && !b.abutsStart(a);
}

/**
* Asserts that the given array of intervals have no intersections.
*
* @param unsorted - the array of intervals to check.
* @param message - optional message to include with the error.
* @throws an {@link Error} if the array has overlapping intervals.
*/
export function assertNoOverlaps(unsorted: ReadonlyArray<Interval<true>>, message?: string): void {
const sorted = sortBy(unsorted, "start", "end");
const indexCollisions = getIndexCollisions(sorted);

if (indexCollisions.length > 0) {
const lines = isString(message) ? [message] : [];
lines.push(
...indexCollisions.map(([start, end]) => {
const overlap = Interval.fromDateTimes(sorted[start].start, sorted[end - 1].end);
return `\t-\tindexes between [${start}, ${end}) all overlap with ${overlap}`;
}),
);
throw new Error(lines.join("\n"));
}
}
2 changes: 1 addition & 1 deletion typedoc.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"entryPoints": ["src/**/*"],
"exclude": ["**/node_modules/**/*", "**/__tests__/**/*"],
"exclude": ["**/node_modules/**/*", "**/__tests__/**/*", "**/*.const.ts"],
"plugin": ["typedoc-plugin-coverage", "typedoc-github-theme"]
}
3 changes: 2 additions & 1 deletion vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,14 @@ export default defineConfig(({ mode }) => ({
test: {
environment: "jsdom",
include: ["src/**/__tests__/*"],
exclude: ["**/*.const.{ts,tsx}"],
coverage: {
all: true,
include: ["src/"],
exclude: ["src/main.tsx", "src/lib/"],
thresholds: { 100: true },
reporter: ["text", "html", "clover", "json", "lcov"],
},
reporters: [["junit", { outputFile: "test-report.junit.xml" }], "default"],
reporters: [["junit", { outputFile: "test-report.junit.xml" }], "verbose"],
},
}));