diigo-cache: date windows and search, designed as skipped specs (goal 1d)
The public page's filters, toward the weekly PESOS roundup (#23): ?days=N (7/30/90/365 presets), gmail-style after/before (UTC days, half-open: after inclusive, before exclusive; yyyy/mm/dd accepted), and q (words, "phrases", #tags exact against diigo's comma-separated tags, - negation); q alone searches the last 365 days and says so; malformed filters are a 400. Filtering walks diigo's updated-desc pages from 1 and stops at the window, so ?days=7 costs one page. src/query.ts is the typed, JSDoc'd API (stubs that throw); 22 specs are describe.skip until it lands. The fake webapp gains a dated account. Generated-by: Claude Opus 5.5
This commit is contained in:
1 parent
860bbee2b5
commit
3e5e139dea
4 files changed
+507
-7
No files matched your search
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* @file the public page's filters — goal 1d, **not yet implemented**: the
|
||||||
|
* design is pinned by the skipped suites in tests/query.test.ts and
|
||||||
|
* tests/caches.test.ts ("public page filters").
|
||||||
|
*
|
||||||
|
* One filter, two spellings: devs pass a {@link FilterSpec} object
|
||||||
|
* (`{ days: 7, q: "#weekly" }`); URLs carry the same fields as params
|
||||||
|
* (`?days=7&q=%23weekly`, empty fields = absent). `q` also takes the
|
||||||
|
* search operators large audiences already know — gmail's `after:`,
|
||||||
|
* `before:`, `newer_than:7d`; Slack's `during:` (alias `in:`); GitHub's
|
||||||
|
* `created:`/`updated:` — plus words, `"phrases"`, `#tag`/`tag:tag`, and
|
||||||
|
* `-` negation.
|
||||||
|
*
|
||||||
|
* Time is UTC (Z) throughout; weeks are ISO 8601 (Monday first, so a
|
||||||
|
* weekend stays together). Every window is half-open: `after` inclusive
|
||||||
|
* from 00:00Z, `before` exclusive at 00:00Z — so GitHub's inclusive
|
||||||
|
* `a..b` ranges are deliberately not offered.
|
||||||
|
*
|
||||||
|
* Applied while walking diigo's pages (updated-desc). `by=updated` finds
|
||||||
|
* an old window by binary search over the sorted pages; `by=created`
|
||||||
|
* walks from page 1 (an old bookmark edited lately sits near page 1).
|
||||||
|
* Either way a request spends at most {@link FETCH_BUDGET} diigo fetches
|
||||||
|
* (cached pages are free) and says so when results are incomplete.
|
||||||
|
*
|
||||||
|
* @see https://mailmeteor.com/blog/search-gmail-by-date
|
||||||
|
* @see https://slack.com/help/articles/202528808
|
||||||
|
* @see https://docs.github.com/en/search-github/searching-on-github/searching-for-repositories
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Item } from "./items.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* diigo fetches one request may spend (cache hits are free); under the
|
||||||
|
* Workers free plan's 50 external subrequests per invocation
|
||||||
|
*
|
||||||
|
* @see https://developers.cloudflare.com/workers/platform/limits/
|
||||||
|
*/
|
||||||
|
export const FETCH_BUDGET = 40;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a filter as devs write it; URL params map onto these fields one-to-one
|
||||||
|
* (strings, with "" meaning absent). Dates are `yyyy-mm-dd` (gmail's
|
||||||
|
* `yyyy/mm/dd` too); periods are `2025`, `2025-03`, `2025-Q1`, or ISO week
|
||||||
|
* `2025-W01`.
|
||||||
|
*/
|
||||||
|
export type FilterSpec = {
|
||||||
|
/** the last N days, counted back from now (1–3650) */
|
||||||
|
days?: number | string;
|
||||||
|
/** inclusive lower bound, from 00:00Z */
|
||||||
|
after?: string;
|
||||||
|
/** exclusive upper bound, at 00:00Z */
|
||||||
|
before?: string;
|
||||||
|
/** a named period: shorthand for its after/before pair */
|
||||||
|
in?: string;
|
||||||
|
/** which date the window, the listed date, and the sort use */
|
||||||
|
by?: "updated" | "created" | "";
|
||||||
|
/** words, phrases, tags, and operators */
|
||||||
|
q?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** one search term; `negated` for a leading `-` */
|
||||||
|
export type Term = { text: string; negated: boolean };
|
||||||
|
|
||||||
|
/** a parsed filter; an empty one (no window, no terms) filters nothing */
|
||||||
|
export type Query = {
|
||||||
|
/** which date the window, the listed date, and the sort use (default
|
||||||
|
* `updated`, diigo's own order) */
|
||||||
|
by: "updated" | "created";
|
||||||
|
/** inclusive lower bound */
|
||||||
|
after?: Date;
|
||||||
|
/** exclusive upper bound */
|
||||||
|
before?: Date;
|
||||||
|
/** words and phrases: each must appear in title, description, or url */
|
||||||
|
words: Term[];
|
||||||
|
/** tags: exact, case-insensitive match against the item's tags */
|
||||||
|
tags: Term[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** a refused filter: the message is safe to show and log */
|
||||||
|
export type QueryError = { error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resolve a filter: fields and `q` operators combine; a bound given twice
|
||||||
|
* (by fields, operators, or both) is refused, as is an empty window.
|
||||||
|
*
|
||||||
|
* @param now the clock `days`/`newer_than` count back from (passed in,
|
||||||
|
* not read ambiently, so tests pin it)
|
||||||
|
*/
|
||||||
|
export function toQuery(spec: FilterSpec, now: Date): Query | QueryError {
|
||||||
|
void spec;
|
||||||
|
void now;
|
||||||
|
throw new Error("toQuery: not implemented (goal 1d)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@link toQuery} over the public page's URL params */
|
||||||
|
export function parseQuery(params: URLSearchParams, now: Date): Query | QueryError {
|
||||||
|
void params;
|
||||||
|
void now;
|
||||||
|
throw new Error("parseQuery: not implemented (goal 1d)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** does `item` fall in the window and satisfy every term? */
|
||||||
|
export function matches(item: Item, query: Query): boolean {
|
||||||
|
void item;
|
||||||
|
void query;
|
||||||
|
throw new Error("matches: not implemented (goal 1d)");
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { runInDurableObject } from "cloudflare:test";
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import worker from "../src/index.js";
|
import worker from "../src/index.js";
|
||||||
import { toWebkey } from "../src/webkeys.js";
|
import { toWebkey } from "../src/webkeys.js";
|
||||||
|
import { FETCH_BUDGET } from "../src/query.js";
|
||||||
|
|
||||||
// matches tests/fake-webapp.js: 30 items, every third private
|
// matches tests/fake-webapp.js: 30 items, every third private
|
||||||
const WEBAPP_ID = "fake-webapp-id";
|
const WEBAPP_ID = "fake-webapp-id";
|
||||||
@@ -139,3 +140,155 @@ describe("worker routes", () => {
|
|||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** `yyyy-Www` for the ISO 8601 week holding `d` (UTC) */
|
||||||
|
function isoWeek(d: Date): string {
|
||||||
|
const t = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||||
|
const day = t.getUTCDay() || 7;
|
||||||
|
t.setUTCDate(t.getUTCDate() + 4 - day); // the week's Thursday names its year
|
||||||
|
const year = t.getUTCFullYear();
|
||||||
|
const week = Math.ceil(((t.getTime() - Date.UTC(year, 0, 1)) / 86400000 + 1) / 7);
|
||||||
|
return `${year}-W${String(week).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skip("public page filters (goal 1d)", () => {
|
||||||
|
// the fake's dated account: "dated n" updated n-1 days + 1h ago;
|
||||||
|
// #weekly on every 7th; every 10th private; every 5th created 100
|
||||||
|
// days before it was updated
|
||||||
|
const call = (path: string, init?: RequestInit) =>
|
||||||
|
worker.fetch(new Request(`https://diigo-cache.test${path}`, init), env as never);
|
||||||
|
const wireDated = async () => {
|
||||||
|
const res = await call("/_wire", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ webappId: "fake-dated-webapp", mode: "public" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
return "/public/dated_dan";
|
||||||
|
};
|
||||||
|
const titlesIn = (html: string) => [...html.matchAll(/>(dated \d+)<\/a>/g)].map((m) => m[1]);
|
||||||
|
const dated = (...ns: number[]) => ns.map((n) => `dated ${n}`);
|
||||||
|
|
||||||
|
it("?days=7 lists the last week, walking only the diigo pages it needs", async () => {
|
||||||
|
const page = await wireDated();
|
||||||
|
const before = await testEnv.WEBAPP.calls();
|
||||||
|
const html = await (await call(`${page}?days=7`)).text();
|
||||||
|
expect(titlesIn(html)).toEqual(dated(1, 2, 3, 4, 5, 6, 7));
|
||||||
|
expect((await testEnv.WEBAPP.calls()) - before).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("?by=created windows, lists, and dates by when it was bookmarked", async () => {
|
||||||
|
const page = await wireDated();
|
||||||
|
const html = await (await call(`${page}?days=7&by=created`)).text();
|
||||||
|
// 5 was updated this week but bookmarked 100 days earlier
|
||||||
|
expect(titlesIn(html)).toEqual(dated(1, 2, 3, 4, 6, 7));
|
||||||
|
const updated = await (await call(`${page}?days=7`)).text();
|
||||||
|
expect(titlesIn(updated)).toContain("dated 5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("?by=created over a longer window sorts by created, newest first", async () => {
|
||||||
|
const page = await wireDated();
|
||||||
|
const html = await (await call(`${page}?days=150&by=created&q=${encodeURIComponent("#weekly")}`)).text();
|
||||||
|
// weekly: 7,14,21,28,35,42,49,56; 35 was created ~134 days ago
|
||||||
|
// (100 days before its update), so it sorts after 56 (~55 days)
|
||||||
|
expect(titlesIn(html)).toEqual(dated(7, 14, 21, 28, 42, 49, 56, 35));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("?days=30 pages the matches 24 at a time; links keep the filter", async () => {
|
||||||
|
const page = await wireDated();
|
||||||
|
const first = await (await call(`${page}?days=30`)).text();
|
||||||
|
const want = Array.from({ length: 30 }, (_, k) => k + 1).filter((n) => n % 10 !== 0);
|
||||||
|
expect(titlesIn(first)).toEqual(dated(...want.slice(0, 24)));
|
||||||
|
expect(first).toContain(`href="${page}?days=30&page=2"`);
|
||||||
|
const second = await (await call(`${page}?days=30&page=2`)).text();
|
||||||
|
expect(titlesIn(second)).toEqual(dated(...want.slice(24)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("?after=&before= selects a date range", async () => {
|
||||||
|
const page = await wireDated();
|
||||||
|
const day = (n: number) => new Date(Date.now() - (n - 1) * 24 * 60 * 60 * 1000 - 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||||
|
// items are 24h apart, so each has its own UTC day: from dated 12's
|
||||||
|
// day (inclusive) up to dated 9's day (exclusive); 10 is private
|
||||||
|
const html = await (await call(`${page}?after=${day(12)}&before=${day(9)}`)).text();
|
||||||
|
expect(titlesIn(html)).toEqual(dated(11, 12));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("?q=#weekly&days=60 filters by tag; private items stay out", async () => {
|
||||||
|
const page = await wireDated();
|
||||||
|
const html = await (await call(`${page}?days=60&q=${encodeURIComponent("#weekly")}`)).text();
|
||||||
|
expect(titlesIn(html)).toEqual(dated(7, 14, 21, 28, 35, 42, 49, 56));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("?q= alone searches all time", async () => {
|
||||||
|
const page = await wireDated();
|
||||||
|
const html = await (await call(`${page}?q=fragments`)).text();
|
||||||
|
expect(titlesIn(html)).toEqual(dated(7, 14, 21, 28, 35, 42, 49, 56));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a request spends at most FETCH_BUDGET diigo fetches; incomplete results say so, and a reload continues", async () => {
|
||||||
|
const res = await call("/_wire", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ webappId: "fake-deep-webapp", mode: "public" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// deep_dee: 50 diigo pages; a search matching nothing must walk them all
|
||||||
|
const before = await testEnv.WEBAPP.calls();
|
||||||
|
const first = await call("/public/deep_dee?q=nomatch", { headers: { accept: "application/json" } });
|
||||||
|
expect(await first.json()).toMatchObject({ complete: false, items: [] });
|
||||||
|
expect((await testEnv.WEBAPP.calls()) - before).toBeLessThanOrEqual(FETCH_BUDGET + 1);
|
||||||
|
const html = await (await call("/public/deep_dee?q=nomatch")).text();
|
||||||
|
expect(html).toMatch(/complete|no matches/);
|
||||||
|
const again = await call("/public/deep_dee?q=nomatch", { headers: { accept: "application/json" } });
|
||||||
|
expect(await again.json()).toMatchObject({ complete: true, items: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("JSON answers carry the canonical filter, so devs see what was understood", async () => {
|
||||||
|
const page = await wireDated();
|
||||||
|
const res = await call(`${page}?days=7&q=${encodeURIComponent("#Weekly")}`, { headers: { accept: "application/json" } });
|
||||||
|
expect(await res.json()).toMatchObject({
|
||||||
|
filter: { by: "updated", tags: [{ text: "weekly", negated: false }] },
|
||||||
|
complete: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("?in= an old week costs O(log pages) diigo fetches with by=updated, not a walk from page 1", async () => {
|
||||||
|
const res = await call("/_wire", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ webappId: "fake-deep-webapp", mode: "public" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// deep_dee: 1200 bookmarks, one a day, 50 diigo pages; a week ~2.5
|
||||||
|
// years back sits near page 38 — a walk from page 1 would cost ~38
|
||||||
|
const twoAndAHalfYearsAgo = new Date(Date.now() - 900 * 24 * 60 * 60 * 1000);
|
||||||
|
const week = isoWeek(twoAndAHalfYearsAgo);
|
||||||
|
const before = await testEnv.WEBAPP.calls();
|
||||||
|
const html = await (await call(`/public/deep_dee?in=${week}`)).text();
|
||||||
|
// one a day; every 10th is private
|
||||||
|
expect(titlesIn(html).length).toBeGreaterThanOrEqual(6);
|
||||||
|
expect(titlesIn(html).length).toBeLessThanOrEqual(7);
|
||||||
|
expect((await testEnv.WEBAPP.calls()) - before).toBeLessThanOrEqual(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a malformed filter is a 400 with a message, not an empty list", async () => {
|
||||||
|
const page = await wireDated();
|
||||||
|
const res = await call(`${page}?days=7x`);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(await res.text()).toMatch(/days/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the form offers the day presets, date inputs, and search, echoing the current filter", async () => {
|
||||||
|
const page = await wireDated();
|
||||||
|
const html = await (await call(`${page}?days=30&q=ocap`)).text();
|
||||||
|
expect(html).toMatch(/<select name="days">/);
|
||||||
|
expect(html).toMatch(/<select name="by">/);
|
||||||
|
for (const n of [7, 30, 90, 365]) expect(html).toContain(`<option value="${n}"`);
|
||||||
|
expect(html).toContain('<option value="30" selected>');
|
||||||
|
expect(html).toMatch(/<input type="date" name="after"/);
|
||||||
|
expect(html).toMatch(/<input type="date" name="before"/);
|
||||||
|
expect(html).toMatch(/<input[^>]* name="q"[^>]* value="ocap"/);
|
||||||
|
// times are UTC, and the page says so
|
||||||
|
expect(html).toMatch(/UTC/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,32 @@ import { RpcTarget, WorkerEntrypoint } from "cloudflare:workers";
|
|||||||
const KNOWN_WEBAPP = "fake-webapp-id";
|
const KNOWN_WEBAPP = "fake-webapp-id";
|
||||||
const TOTAL = 30;
|
const TOTAL = 30;
|
||||||
|
|
||||||
|
/** a second account, for date filters (goal 1d): 60 bookmarks, item n
|
||||||
|
* updated n-1 days + 1 hour before the call, in diigo's updated-desc
|
||||||
|
* order; tagged ocap, plus weekly every 7th; every 10th private; every
|
||||||
|
* 5th an old bookmark edited recently (created 100 days before updated) */
|
||||||
|
const DATED_WEBAPP = "fake-dated-webapp";
|
||||||
|
const DATED_TOTAL = 60;
|
||||||
|
/** the same shape, 1200 deep (~3.3 years, 50 pages): for walk cost */
|
||||||
|
const DEEP_WEBAPP = "fake-deep-webapp";
|
||||||
|
const DEEP_TOTAL = 1200;
|
||||||
|
const HOUR = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function datedItem(n, now, user = "dated_dan") {
|
||||||
|
return {
|
||||||
|
link_id: 5000 + n,
|
||||||
|
url: `https://dated.example/${n}`,
|
||||||
|
title: `dated ${n}`,
|
||||||
|
description: n % 7 === 0 ? "a week of fragments" : "",
|
||||||
|
private: n % 10 === 0,
|
||||||
|
u_name: user,
|
||||||
|
user_id: 777,
|
||||||
|
tags: n % 7 === 0 ? "ocap,weekly" : "ocap",
|
||||||
|
created_at: Math.floor((now - (n - 1) * 24 * HOUR - HOUR - (n % 5 === 0 ? 100 * 24 * HOUR : 0)) / 1000),
|
||||||
|
updated_at: Math.floor((now - (n - 1) * 24 * HOUR - HOUR) / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** item n (1-based, in diigo's order): diigo's field names, made-up
|
/** item n (1-based, in diigo's order): diigo's field names, made-up
|
||||||
* values. link_id and updated_at are scrambled (distinct; 7 and 11 are
|
* values. link_id and updated_at are scrambled (distinct; 7 and 11 are
|
||||||
* coprime to 31), so neither column sorts back into diigo's order — a
|
* coprime to 31), so neither column sorts back into diigo's order — a
|
||||||
@@ -35,32 +61,40 @@ let calls = 0;
|
|||||||
class FakeGet extends RpcTarget {
|
class FakeGet extends RpcTarget {
|
||||||
#path;
|
#path;
|
||||||
#params;
|
#params;
|
||||||
constructor(path, params) {
|
/** null (the plain account), "dated", or "deep" */
|
||||||
|
#dated;
|
||||||
|
constructor(path, params, dated) {
|
||||||
super();
|
super();
|
||||||
this.#path = path;
|
this.#path = path;
|
||||||
this.#params = params;
|
this.#params = params;
|
||||||
|
this.#dated = dated;
|
||||||
}
|
}
|
||||||
pathjoin(ref) {
|
pathjoin(ref) {
|
||||||
return new FakeGet(ref, this.#params);
|
return new FakeGet(ref, this.#params, this.#dated);
|
||||||
}
|
}
|
||||||
query(params) {
|
query(params) {
|
||||||
return new FakeGet(this.#path, { ...this.#params, ...params });
|
return new FakeGet(this.#path, { ...this.#params, ...params }, this.#dated);
|
||||||
}
|
}
|
||||||
async get() {
|
async get() {
|
||||||
calls += 1;
|
calls += 1;
|
||||||
if (this.#path !== "load_user_items") throw new Error(`fake webapp: no route ${this.#path}`);
|
if (this.#path !== "load_user_items") throw new Error(`fake webapp: no route ${this.#path}`);
|
||||||
const count = Number(this.#params.count);
|
const count = Number(this.#params.count);
|
||||||
const page = Number(this.#params.page_num);
|
const page = Number(this.#params.page_num);
|
||||||
|
const total = this.#dated === "deep" ? DEEP_TOTAL : this.#dated ? DATED_TOTAL : TOTAL;
|
||||||
|
const now = Date.now();
|
||||||
const items = [];
|
const items = [];
|
||||||
for (let n = (page - 1) * count + 1; n <= Math.min(page * count, TOTAL); n += 1) items.push(item(n));
|
for (let n = (page - 1) * count + 1; n <= Math.min(page * count, total); n += 1) {
|
||||||
return JSON.stringify({ total: TOTAL, items });
|
items.push(this.#dated ? datedItem(n, now, this.#dated === "deep" ? "deep_dee" : "dated_dan") : item(n));
|
||||||
|
}
|
||||||
|
return JSON.stringify({ total, items });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WebAppFactory extends WorkerEntrypoint {
|
export class WebAppFactory extends WorkerEntrypoint {
|
||||||
async at(id) {
|
async at(id) {
|
||||||
if (id !== KNOWN_WEBAPP) throw new Error("fake webapp: unknown id");
|
const kind = { [KNOWN_WEBAPP]: null, [DATED_WEBAPP]: "dated", [DEEP_WEBAPP]: "deep" }[id];
|
||||||
return new FakeGet("", {});
|
if (kind === undefined) throw new Error("fake webapp: unknown id");
|
||||||
|
return new FakeGet("", {}, kind);
|
||||||
}
|
}
|
||||||
/** how many diigo calls so far (test hook) */
|
/** how many diigo calls so far (test hook) */
|
||||||
async calls() {
|
async calls() {
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { matches, parseQuery, toQuery } from "../src/query.js";
|
||||||
|
import type { FilterSpec, Query, QueryError } from "../src/query.js";
|
||||||
|
|
||||||
|
// goal 1d (capsite checklist): the design as tests; unskip as it lands
|
||||||
|
|
||||||
|
const NOW = new Date("2026-10-07T12:00:00.000Z");
|
||||||
|
const DAY = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const fromUrl = (qs: string) => parseQuery(new URLSearchParams(qs), NOW);
|
||||||
|
const ok = (q: Query | QueryError) => {
|
||||||
|
if ("error" in q) throw new Error(`expected a query: ${q.error}`);
|
||||||
|
return q;
|
||||||
|
};
|
||||||
|
const url = (qs: string) => ok(fromUrl(qs));
|
||||||
|
const spec = (s: FilterSpec) => ok(toQuery(s, NOW));
|
||||||
|
const refused = (q: Query | QueryError) => {
|
||||||
|
expect(q).toHaveProperty("error");
|
||||||
|
return (q as QueryError).error;
|
||||||
|
};
|
||||||
|
const day = (iso: string) => new Date(`${iso}T00:00:00.000Z`);
|
||||||
|
const range = (q: Query) => [q.after?.toISOString().slice(0, 10), q.before?.toISOString().slice(0, 10)];
|
||||||
|
|
||||||
|
describe.skip("filters: one spec, two spellings (goal 1d)", () => {
|
||||||
|
it("devs write { days: 7 }; URLs write ?days=7; same query", () => {
|
||||||
|
expect(spec({ days: 7 })).toEqual(url("days=7"));
|
||||||
|
expect(spec({ days: 7 }).after).toEqual(new Date(NOW.getTime() - 7 * DAY));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an empty spec or URL filters nothing", () => {
|
||||||
|
expect(spec({})).toEqual({ by: "updated", words: [], tags: [] });
|
||||||
|
expect(url("")).toEqual({ by: "updated", words: [], tags: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty URL fields mean absent (a GET form submits them all)", () => {
|
||||||
|
expect(url("days=7&after=&before=&in=&by=&q=")).toEqual(url("days=7"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a days value that isn't a whole number from 1 to 3650", () => {
|
||||||
|
for (const bad of ["0", "-7", "7x", "1.5", "3651"]) expect(refused(fromUrl(`days=${bad}`))).toMatch(/days/);
|
||||||
|
expect(refused(toQuery({ days: 0 }, NOW))).toMatch(/days/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skip("windows: UTC, half-open (goal 1d)", () => {
|
||||||
|
it("after/before are UTC days: after inclusive from 00:00Z, before exclusive", () => {
|
||||||
|
expect(range(url("after=2026-01-01&before=2026-02-01"))).toEqual(["2026-01-01", "2026-02-01"]);
|
||||||
|
expect(url("after=2026/01/01").after).toEqual(day("2026-01-01"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("in= names a year, month, quarter, or ISO week", () => {
|
||||||
|
expect(range(url("in=2025"))).toEqual(["2025-01-01", "2026-01-01"]);
|
||||||
|
expect(range(url("in=2025-03"))).toEqual(["2025-03-01", "2025-04-01"]);
|
||||||
|
expect(range(url("in=2025-12"))).toEqual(["2025-12-01", "2026-01-01"]);
|
||||||
|
expect(range(url("in=2025-Q1"))).toEqual(["2025-01-01", "2025-04-01"]);
|
||||||
|
expect(range(url("in=2025-Q4"))).toEqual(["2025-10-01", "2026-01-01"]);
|
||||||
|
// ISO 8601: weeks start Monday; week 1 holds the year's first Thursday
|
||||||
|
expect(range(url("in=2025-W01"))).toEqual(["2024-12-30", "2025-01-06"]);
|
||||||
|
expect(range(url("in=2026-W53"))).toEqual(["2026-12-28", "2027-01-04"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses malformed dates and periods", () => {
|
||||||
|
expect(refused(fromUrl("after=2026-13-01"))).toMatch(/after/);
|
||||||
|
expect(refused(fromUrl("before=yesterday"))).toMatch(/before/);
|
||||||
|
// 2025 has 52 ISO weeks (it starts on a Wednesday, not a leap year)
|
||||||
|
for (const bad of ["2025-13", "2025-Q5", "2025-W53", "2025-W00", "25", "2025-1"]) {
|
||||||
|
expect(refused(fromUrl(`in=${bad}`))).toMatch(/in/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a bound given twice, and an empty window", () => {
|
||||||
|
expect(refused(fromUrl("days=7&after=2026-01-01"))).toMatch(/days.*after|after.*days/);
|
||||||
|
expect(refused(fromUrl("in=2025&days=7"))).toMatch(/in/);
|
||||||
|
expect(refused(fromUrl("in=2025&before=2025-02-01"))).toMatch(/in/);
|
||||||
|
expect(refused(fromUrl("after=2026-02-01&before=2026-02-01"))).toMatch(/after.*before|before.*after/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("days combines with before", () => {
|
||||||
|
const q = url("days=30&before=2026-10-01");
|
||||||
|
expect(q.after).toEqual(new Date(NOW.getTime() - 30 * DAY));
|
||||||
|
expect(q.before).toEqual(day("2026-10-01"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("by=created or by=updated picks the date (default updated); anything else is refused", () => {
|
||||||
|
expect(url("days=7").by).toBe("updated");
|
||||||
|
expect(url("days=7&by=created").by).toBe("created");
|
||||||
|
expect(refused(fromUrl("days=7&by=modified"))).toMatch(/by/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skip("q: terms and operators (goal 1d)", () => {
|
||||||
|
const q = (text: string) => url(`q=${encodeURIComponent(text)}`);
|
||||||
|
|
||||||
|
it("words, phrases, #tags, tag:, and - negation; case-insensitive", () => {
|
||||||
|
const parsed = q('Foo bar -baz "exact phrase" #Weekly tag:ocap -#draft -tag:old');
|
||||||
|
expect(parsed.words).toEqual([
|
||||||
|
{ text: "foo", negated: false },
|
||||||
|
{ text: "bar", negated: false },
|
||||||
|
{ text: "baz", negated: true },
|
||||||
|
{ text: "exact phrase", negated: false },
|
||||||
|
]);
|
||||||
|
expect(parsed.tags).toEqual([
|
||||||
|
{ text: "weekly", negated: false },
|
||||||
|
{ text: "ocap", negated: false },
|
||||||
|
{ text: "draft", negated: true },
|
||||||
|
{ text: "old", negated: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gmail's after:/before:/newer_than: set the window", () => {
|
||||||
|
expect(range(q("after:2026-01-01 before:2026/02/01"))).toEqual(["2026-01-01", "2026-02-01"]);
|
||||||
|
expect(q("newer_than:7d").after).toEqual(new Date(NOW.getTime() - 7 * DAY));
|
||||||
|
expect(q("newer_than:2w").after).toEqual(new Date(NOW.getTime() - 14 * DAY));
|
||||||
|
// months and years are calendar steps back, in UTC
|
||||||
|
expect(q("newer_than:1m").after).toEqual(new Date("2026-09-07T12:00:00.000Z"));
|
||||||
|
expect(q("newer_than:1y").after).toEqual(new Date("2025-10-07T12:00:00.000Z"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Slack's during: (alias in:) names a period", () => {
|
||||||
|
expect(range(q("during:2025-Q1"))).toEqual(["2025-01-01", "2025-04-01"]);
|
||||||
|
expect(range(q("in:2025-W01"))).toEqual(["2024-12-30", "2025-01-06"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GitHub-style created:/updated: name the date and the period at once", () => {
|
||||||
|
const c = q("created:2025");
|
||||||
|
expect(c.by).toBe("created");
|
||||||
|
expect(range(c)).toEqual(["2025-01-01", "2026-01-01"]);
|
||||||
|
expect(q("updated:2025-W03").by).toBe("updated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("operators and fields combine, but a bound given twice is refused", () => {
|
||||||
|
const both = url(`by=created&q=${encodeURIComponent("during:2025 #weekly")}`);
|
||||||
|
expect(both.by).toBe("created");
|
||||||
|
expect(both.tags).toEqual([{ text: "weekly", negated: false }]);
|
||||||
|
expect(refused(fromUrl(`days=7&q=${encodeURIComponent("newer_than:30d")}`))).toMatch(/newer_than|days/);
|
||||||
|
expect(refused(fromUrl(`by=updated&q=${encodeURIComponent("created:2025")}`))).toMatch(/by|created/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an unknown word:stuff is plain text (so urls work as words)", () => {
|
||||||
|
expect(q("https://erights.org foo:bar").words).toEqual([
|
||||||
|
{ text: "https://erights.org", negated: false },
|
||||||
|
{ text: "foo:bar", negated: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a malformed known operator is refused, not searched as text", () => {
|
||||||
|
expect(refused(fromUrl(`q=${encodeURIComponent("newer_than:7x")}`))).toMatch(/newer_than/);
|
||||||
|
expect(refused(fromUrl(`q=${encodeURIComponent("during:2025-Q5")}`))).toMatch(/during/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** an item updated at `iso`, diigo-shaped */
|
||||||
|
const item = (iso: string, extra: Record<string, unknown> = {}) => ({
|
||||||
|
updated_at: new Date(iso).getTime() / 1000,
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
url: "https://example.com/",
|
||||||
|
tags: "",
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
const query = (q: Partial<Query>): Query => ({ by: "updated", words: [], tags: [], ...q });
|
||||||
|
|
||||||
|
describe.skip("matches (goal 1d)", () => {
|
||||||
|
it("the window is half-open: after inclusive, before exclusive", () => {
|
||||||
|
const q = query({ after: day("2026-01-01"), before: day("2026-02-01") });
|
||||||
|
expect(matches(item("2026-01-01T00:00:00.000Z"), q)).toBe(true);
|
||||||
|
expect(matches(item("2025-12-31T23:59:59.000Z"), q)).toBe(false);
|
||||||
|
expect(matches(item("2026-01-31T23:59:59.000Z"), q)).toBe(true);
|
||||||
|
expect(matches(item("2026-02-01T00:00:00.000Z"), q)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("by=created windows on created_at instead of updated_at", () => {
|
||||||
|
const jan = { after: day("2026-01-01") };
|
||||||
|
// an old bookmark, edited recently
|
||||||
|
const edited = item("2026-01-05T00:00:00Z", { created_at: new Date("2025-06-01T00:00:00Z").getTime() / 1000 });
|
||||||
|
expect(matches(edited, query({ ...jan, by: "updated" }))).toBe(true);
|
||||||
|
expect(matches(edited, query({ ...jan, by: "created" }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every word must appear in the title, description, or url (case-insensitive)", () => {
|
||||||
|
const it1 = item("2026-01-05T00:00:00Z", { title: "Capability Patterns", url: "https://erights.org/x" });
|
||||||
|
expect(matches(it1, query({ words: [{ text: "capability", negated: false }] }))).toBe(true);
|
||||||
|
expect(matches(it1, query({ words: [{ text: "erights", negated: false }] }))).toBe(true);
|
||||||
|
expect(
|
||||||
|
matches(it1, query({ words: [{ text: "capability", negated: false }, { text: "missing", negated: false }] })),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a negated word excludes", () => {
|
||||||
|
const it1 = item("2026-01-05T00:00:00Z", { title: "Capability Patterns" });
|
||||||
|
expect(matches(it1, query({ words: [{ text: "patterns", negated: true }] }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a phrase matches only contiguously", () => {
|
||||||
|
const it1 = item("2026-01-05T00:00:00Z", { description: "object capability patterns" });
|
||||||
|
expect(matches(it1, query({ words: [{ text: "capability patterns", negated: false }] }))).toBe(true);
|
||||||
|
expect(matches(it1, query({ words: [{ text: "patterns object", negated: false }] }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a tag matches a whole comma-separated tag, case-insensitive; not a substring", () => {
|
||||||
|
const it1 = item("2026-01-05T00:00:00Z", { tags: "ocap,Weekly-Notes,js" });
|
||||||
|
expect(matches(it1, query({ tags: [{ text: "weekly-notes", negated: false }] }))).toBe(true);
|
||||||
|
expect(matches(it1, query({ tags: [{ text: "weekly", negated: false }] }))).toBe(false);
|
||||||
|
expect(matches(it1, query({ tags: [{ text: "js", negated: true }] }))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user