= {}) {
- let snapshot: AppWebSignInSnapshot = {
- status: "signed_out",
- signIn: null,
- collections: [],
- selectedCollectionId: null,
- problem: null,
- timing: null,
- ...initial,
- };
- const listeners = new Set<() => void>();
- const publish = (patch: Partial) => {
- snapshot = { ...snapshot, ...patch };
- for (const listener of listeners) listener();
- };
- const result: NativeSignInBinding = {
- viewKey: ++key,
- portal: { reserve: vi.fn(), open: vi.fn(), cancel: vi.fn() },
- session: {
- getSnapshot: () => snapshot,
- subscribe(listener) {
- listeners.add(listener);
- return () => {
- listeners.delete(listener);
- };
- },
- start: vi.fn(async () => snapshot),
- authorize: vi.fn(async () => {
- publish({ status: "authorizing" });
- return snapshot;
- }),
- renewExpiredPairing: vi.fn(async () => {
- publish({ problem: null });
- return snapshot;
- }),
- forget: vi.fn(async () => snapshot),
- close: vi.fn(async () => {
- publish({ status: "closed", signIn: null });
- }),
- setForeground: vi.fn(),
- },
- };
- return { result, publish };
-}
-const paired: NonNullable = {
- requestId: "request",
- installationId: "installation",
- deviceId: "device",
- appId: "app",
- kind: "app-runtime",
- verificationUri: "https://mdbase.test/approve",
- state: "paired",
- accountId: "account",
- accountEmail: "reader@example.test",
- collectionIds: [],
- createCollections: false,
- requestedCreateCollections: false,
-};
-
-describe("public SDK sign-in UI bindings (stand-ins, not native acceptance)", () => {
- it("one Sign in action reserves the tab and delegates authorization", async () => {
- const h = binding();
- render( );
- fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
- await waitFor(() =>
- expect(h.result.session.authorize).toHaveBeenCalledOnce(),
- );
- expect(h.result.portal.reserve).toHaveBeenCalledOnce();
- expect(
- vi.mocked(h.result.portal.reserve).mock.invocationCallOrder[0],
- ).toBeLessThan(
- vi.mocked(h.result.session.authorize).mock.invocationCallOrder[0],
- );
- expect(screen.getByRole("status").textContent).toBe(
- "Waiting for approval in the mdbase tab…",
- );
- expect(
- screen.getByRole("button", { name: "Open the mdbase tab again" }),
- ).toBeTruthy();
- });
- it("keeps additional consent open and closes the owned portal only after completion", async () => {
- const h = binding({ status: "authorizing", signIn: paired });
- render( );
- expect(h.result.portal.cancel).not.toHaveBeenCalled();
- await act(async () => h.publish({ status: "unselected" }));
- await waitFor(() => expect(h.result.portal.cancel).toHaveBeenCalledOnce());
- expect(h.result.session.authorize).not.toHaveBeenCalled();
- });
- it.each([
- "unselected",
- "opening",
- "setup_required",
- "ready",
- "blocked",
- "closed",
- ] as const)(
- "cleans up the owned portal when the SDK reaches %s",
- async (status) => {
- const h = binding({ status: "authorizing", signIn: paired });
- const view = render(
- ,
- );
- await act(async () => h.publish({ status }));
- expect(h.result.portal.cancel).toHaveBeenCalledOnce();
- view.unmount();
- expect(h.result.session.close).not.toHaveBeenCalled();
- },
- );
- it("closes its popup on retired-view cleanup without closing the borrowed SDK session", () => {
- const h = binding({ status: "authorizing", signIn: paired });
- const view = render(
- ,
- );
- view.unmount();
- expect(h.result.portal.cancel).toHaveBeenCalledOnce();
- expect(h.result.session.close).not.toHaveBeenCalled();
- });
- it("auto-detects the SDK's approval publication without an app polling action", async () => {
- const h = binding({ status: "authorizing" });
- render( );
- await act(async () => h.publish({ status: "unselected", signIn: paired }));
- expect(screen.getByText("Signed in as reader@example.test")).toBeTruthy();
- expect(
- screen.getByText(
- "Opening collections isn't available in this build yet.",
- ),
- ).toBeTruthy();
- expect(h.result.session.authorize).not.toHaveBeenCalled();
- expect(screen.queryByText(/Set up|Continue setup/)).toBeNull();
- });
- it("one expired retry reserves BEFORE asynchronous SDK renewal", async () => {
- const h = binding({ status: "blocked", problem: "expired" });
- render( );
- fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
- await waitFor(() =>
- expect(h.result.session.authorize).toHaveBeenCalledOnce(),
- );
- expect(h.result.session.renewExpiredPairing).toHaveBeenCalledOnce();
- expect(
- vi.mocked(h.result.portal.reserve).mock.invocationCallOrder[0],
- ).toBeLessThan(
- vi.mocked(h.result.session.renewExpiredPairing).mock
- .invocationCallOrder[0],
- );
- });
- it("rapid repeated clicks do not create another UI authorization attempt", async () => {
- const h = binding();
- let finish!: (value: AppWebSignInSnapshot) => void;
- vi.mocked(h.result.session.authorize).mockImplementation(
- () =>
- new Promise((resolve) => {
- finish = resolve;
- }),
- );
- render( );
- const button = screen.getByRole("button", { name: "Sign in" });
- fireEvent.click(button);
- fireEvent.click(button);
- expect(h.result.session.authorize).toHaveBeenCalledOnce();
- await act(async () => finish(h.result.session.getSnapshot()));
- });
- it("publishes local sign-out only after SDK close completes", async () => {
- const h = binding({ status: "unselected", signIn: paired });
- let finish!: () => void;
- vi.mocked(h.result.session.close).mockImplementation(
- () =>
- new Promise((resolve) => {
- finish = resolve;
- }),
- );
- const onSignedOut = vi.fn();
- render( );
- fireEvent.click(screen.getByRole("button", { name: "Sign out" }));
- expect(onSignedOut).not.toHaveBeenCalled();
- await act(async () => finish());
- expect(onSignedOut).toHaveBeenCalledOnce();
- });
- it("a failed close cannot present sign-out or qualify a new owner", async () => {
- const h = binding({ status: "unselected", signIn: paired });
- vi.mocked(h.result.session.close).mockRejectedValue(
- new Error("private diagnostic"),
- );
- const onSignedOut = vi.fn();
- render( );
- fireEvent.click(screen.getByRole("button", { name: "Sign out" }));
- await waitFor(() =>
- expect(screen.getByRole("alert").textContent).toBe(
- "Sign-in was interrupted. Please try again.",
- ),
- );
- expect(onSignedOut).not.toHaveBeenCalled();
- expect(screen.queryByText("private diagnostic")).toBeNull();
- });
- it("forgets only the SDK's selected collection and closes no borrowed session on unmount", async () => {
- const h = binding({
- status: "unselected",
- signIn: paired,
- selectedCollectionId: "selected",
- });
- const view = render(
- ,
- );
- fireEvent.click(screen.getByRole("button", { name: "Forget collection" }));
- await waitFor(() =>
- expect(h.result.session.forget).toHaveBeenCalledWith("selected"),
- );
- view.unmount();
- expect(h.result.session.close).not.toHaveBeenCalled();
- });
-});
diff --git a/src/components/native-signin.tsx b/src/components/native-signin.tsx
deleted file mode 100644
index 6789fe3..0000000
--- a/src/components/native-signin.tsx
+++ /dev/null
@@ -1,169 +0,0 @@
-import { useEffect, useRef, useState, useSyncExternalStore } from "react";
-import {
- SignInTabBlocked,
- authorizeNativeSignIn,
- signInCopy,
- signInPresentation,
- type createNativeSignIn,
-} from "../data/native-signin-ui";
-
-interface NativeSignInProps {
- /** Actual SDK instance plus its UI tab reservation; caller owns the lifetime. */
- binding: ReturnType;
- /** Close succeeded. Owner may show signed-out UI; original stores stay. */
- onSignedOut(): void;
-}
-
-/** Presentation/actions only: no polling, custody, grants or session engine. */
-export function NativeSignIn(props: NativeSignInProps) {
- return ;
-}
-
-function NativeSignInView({ binding, onSignedOut }: NativeSignInProps) {
- const { session, portal } = binding;
- const snapshot = useSyncExternalStore(
- (listener) => session.subscribe(listener),
- () => session.getSnapshot(),
- () => session.getSnapshot(),
- );
- const [problem, setProblem] = useState<"tab_blocked" | "interrupted" | null>(
- null,
- );
- const [working, setWorking] = useState(false);
- const busy = useRef(false);
- const mounted = useRef(false);
-
- useEffect(() => {
- mounted.current = true;
- const foreground = () => {
- if (session.getSnapshot().status === "closed") return;
- try {
- session.setForeground(document.visibilityState === "visible");
- } catch {
- if (mounted.current) setProblem("interrupted");
- }
- };
- document.addEventListener("visibilitychange", foreground);
- // External SDK synchronization, not a React-state reset. A fenced owner
- // will publish closure or refuse the next explicit action.
- if (session.getSnapshot().status !== "closed") {
- try {
- session.setForeground(document.visibilityState === "visible");
- } catch {
- /* Retired owner; do not publish late state. */
- }
- }
- return () => {
- mounted.current = false;
- document.removeEventListener("visibilitychange", foreground);
- portal.cancel();
- // Borrowed SDK session: its actual owner handles unmount/HMR shutdown.
- };
- }, [session, portal]);
-
- useEffect(() => {
- // A paired account can still be authorizing additional consent. Only the
- // public completed/terminal status closes this UI's original popup.
- if (
- [
- "unselected",
- "opening",
- "setup_required",
- "ready",
- "blocked",
- "closed",
- ].includes(snapshot.status)
- ) {
- portal.cancel();
- }
- }, [portal, snapshot.status]);
-
- const act = async (action: () => Promise) => {
- if (busy.current) return;
- busy.current = true;
- setWorking(true);
- setProblem(null);
- try {
- await action();
- } catch (reason) {
- if (mounted.current) {
- setProblem(
- reason instanceof SignInTabBlocked ? "tab_blocked" : "interrupted",
- );
- }
- } finally {
- if (mounted.current) {
- busy.current = false;
- setWorking(false);
- }
- }
- };
- const view = signInPresentation(snapshot, "TaskNotes Planner", problem);
- const signOut = async () => {
- await session.close();
- portal.cancel();
- if (mounted.current) onSignedOut();
- };
- const authorize = () => authorizeNativeSignIn(session, portal);
-
- return (
-
-
-
-
-
TaskNotes Planner
-
- Plan tasks on a timeline.
- {view.identity ? {view.identity}
: null}
-
- {view.message}
-
- {view.unavailable ? {signInCopy.unavailable}
: null}
-
- {view.waiting ? (
- void act(authorize)}
- >
- {signInCopy.reopen}
-
- ) : !view.paired || view.error ? (
- void act(authorize)}
- >
- {view.action}
-
- ) : null}
- {view.paired ? (
- void act(signOut)}
- >
- {signInCopy.signOut}
-
- ) : null}
- {snapshot.selectedCollectionId ? (
- {
- const collectionId = snapshot.selectedCollectionId;
- if (collectionId) void act(() => session.forget(collectionId));
- }}
- >
- Forget collection
-
- ) : null}
-
-
-
- );
-}
diff --git a/src/components/planner.empty.test.tsx b/src/components/planner.empty.test.tsx
index 486febf..fc74a37 100644
--- a/src/components/planner.empty.test.tsx
+++ b/src/components/planner.empty.test.tsx
@@ -13,7 +13,6 @@ beforeEach(() => {
);
HTMLElement.prototype.scrollTo = vi.fn();
vi.stubEnv("VITE_MDBASE_ENV", "");
- vi.stubEnv("VITE_MDBASE_CONNECT_URL", "");
});
afterEach(() => {
cleanup();
@@ -38,22 +37,13 @@ async function showCollection(patch: Partial = {}) {
describe("Planner empty states", () => {
it.each([
- [
- "lab",
- "https://connect-lab.mdbase.dev",
- "https://lab.tasknotes-app.pages.dev",
- ],
- [
- "",
- "https://connect-staging.mdbase.dev",
- "https://staging.tasknotes-app.pages.dev",
- ],
- ["", "", "https://app.tasknotes.dev"],
+ ["lab", "https://lab.tasknotes-app.pages.dev"],
+ ["staging", "https://staging.tasknotes-app.pages.dev"],
+ ["", "https://app.tasknotes.dev"],
])(
- "hands an empty collection to TaskNotes in its deployment (%s, %s)",
- async (environment, connectUrl, origin) => {
+ "hands an empty collection to TaskNotes in its deployment (%s)",
+ async (environment, origin) => {
vi.stubEnv("VITE_MDBASE_ENV", environment);
- vi.stubEnv("VITE_MDBASE_CONNECT_URL", connectUrl);
history.replaceState(
null,
"",
diff --git a/src/components/planner.test.tsx b/src/components/planner.test.tsx
index 7a26e28..5ae1965 100644
--- a/src/components/planner.test.tsx
+++ b/src/components/planner.test.tsx
@@ -5,12 +5,11 @@ import {
waitFor,
within,
} from "@testing-library/react";
-import { MdbaseConnectError, type ConnectProblem } from "@mdbase-dev/connect";
+import { MdbaseError } from "@mdbase-dev/sdk";
import { Planner } from "./planner";
import type {
- PendingPlannerMutation,
PlannerCollection,
PlannerRepository,
PlannerTask,
@@ -20,14 +19,12 @@ import type {
vi.mock("./gantt-chart", () => ({
GanttChart: ({
tasks,
- mutationsDisabled,
onCompletionChange,
onDependenciesChange,
onScheduleChange,
onSelect,
}: {
tasks: readonly PlannerTask[];
- mutationsDisabled?: boolean;
onCompletionChange(task: PlannerTask): Promise;
onDependenciesChange(
task: PlannerTask,
@@ -44,7 +41,6 @@ vi.mock("./gantt-chart", () => ({
Select test task
void onScheduleChange(tasks[0], {
@@ -55,7 +51,6 @@ vi.mock("./gantt-chart", () => ({
Move test task
void onDependenciesChange(tasks[0], []).catch(() => undefined)
@@ -64,7 +59,6 @@ vi.mock("./gantt-chart", () => ({
Change test dependencies
void onCompletionChange(tasks[0]).catch(() => undefined)
@@ -134,137 +128,14 @@ describe("Planner connection recovery", () => {
expect(onChangeCollection).toHaveBeenCalledOnce();
});
- it("recovers a durable pending handle explicitly and reconciles by loading", async () => {
- let completeRecovery = () => undefined;
- let pending: readonly PendingPlannerMutation[] = [
- {
- requestId: "request-1",
- operation: "update",
- createdAt: "2026-08-23T12:00:00.000Z",
- },
- ];
- const repository = repositoryFixture({
- pendingMutations: () => pending,
- recoverPendingMutation: vi.fn(() => {
- return new Promise((resolve) => {
- completeRecovery = () => {
- pending = [];
- resolve();
- };
- });
- }),
- });
- render(
- undefined} />,
- );
-
- fireEvent.click(
- await screen.findByRole("button", { name: "Recover pending change" }),
- );
-
- await waitFor(() =>
- expect(repository.recoverPendingMutation).toHaveBeenCalledWith(
- "request-1",
- ),
- );
- expect(screen.getByRole("button", { name: "Recovering…" })).toBeDisabled();
- expect(screen.getByRole("button", { name: "Save view" })).toBeDisabled();
-
- completeRecovery();
- await waitFor(() => expect(repository.load).toHaveBeenCalledTimes(2));
- expect(
- screen.queryByRole("button", { name: "Recover pending change" }),
- ).not.toBeInTheDocument();
- });
-
- it("reconciles a consumed recovery rejection before releasing writes", async () => {
- let pending: readonly PendingPlannerMutation[] = [pendingMutation()];
- let finishReload: () => void = () => undefined;
- let loads = 0;
- const load = vi.fn(() => {
- loads += 1;
- if (loads === 1) return Promise.resolve(structuredClone(collection));
- return new Promise((resolve) => {
- finishReload = () => resolve(structuredClone(collection));
- });
- });
- const repository = repositoryFixture({
- load,
- pendingMutations: () => pending,
- recoverPendingMutation: vi.fn(async () => {
- pending = [];
- throw new Error("The original update was rejected.");
- }),
- });
- render(
- undefined} />,
- );
-
- fireEvent.click(
- await screen.findByRole("button", { name: "Recover pending change" }),
- );
-
- await waitFor(() => expect(load).toHaveBeenCalledTimes(2));
- expect(screen.getByRole("button", { name: "Recovering…" })).toBeDisabled();
- expect(screen.getByRole("button", { name: "Save view" })).toBeDisabled();
-
- finishReload();
- await waitFor(() =>
- expect(
- screen.queryByRole("button", { name: "Recover pending change" }),
- ).not.toBeInTheDocument(),
- );
- expect(screen.getByRole("button", { name: "Save view" })).toBeEnabled();
- });
-
- it("retains the write gate without reloading when recovery remains retryable", async () => {
- const pending = pendingMutation();
- const repository = repositoryFixture({
- pendingMutations: () => [pending],
- recoverPendingMutation: vi.fn(async () => {
- throw new Error("The connector is temporarily unavailable.");
- }),
- });
- render(
- undefined} />,
- );
-
- fireEvent.click(
- await screen.findByRole("button", { name: "Recover pending change" }),
- );
-
- await waitFor(() =>
- expect(repository.recoverPendingMutation).toHaveBeenCalledTimes(1),
- );
- await waitFor(() =>
- expect(
- screen.getByRole("button", { name: "Recover pending change" }),
- ).toBeEnabled(),
- );
- expect(repository.load).toHaveBeenCalledTimes(1);
- expect(screen.getByRole("button", { name: "Save view" })).toBeDisabled();
- });
-
- it("keeps an uncertain optimistic schedule until explicit recovery", async () => {
+ it("keeps an uncertain optimistic schedule", async () => {
const task = plannerTask();
- let pending: readonly PendingPlannerMutation[] = [];
- const problem = {
- problem_version: 1,
- code: "operation_outcome_unknown",
- category: "transport",
- recovery: "recover_mutation",
- message: "The schedule may already have changed.",
- operation_outcome: "unknown",
- } as unknown as ConnectProblem;
const updateSchedule = vi.fn(async () => {
- pending = [
- {
- requestId: "request-1",
- operation: "update",
- createdAt: "2026-08-23T12:00:00.000Z",
- },
- ];
- throw new MdbaseConnectError(problem);
+ throw new MdbaseError({
+ code: "outcome_unknown",
+ recovery: "refresh",
+ message: "The schedule may already have changed.",
+ });
});
const repository = repositoryFixture({
load: vi.fn(async () => ({
@@ -272,8 +143,6 @@ describe("Planner connection recovery", () => {
tasks: [task],
})),
updateSchedule,
- pendingMutations: () => pending,
- recoverPendingMutation: vi.fn(),
});
render(
undefined} />,
@@ -283,67 +152,12 @@ describe("Planner connection recovery", () => {
await screen.findByRole("button", { name: "Move test task" }),
);
+ await waitFor(() => expect(updateSchedule).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(screen.getByTestId("scheduled-value")).toHaveTextContent(
"2026-08-24",
),
);
- expect(
- await screen.findByRole("button", { name: "Recover pending change" }),
- ).toBeInTheDocument();
- expect(updateSchedule).toHaveBeenCalledTimes(1);
- });
-
- it("blocks every subsequent write while a mutation awaits recovery", async () => {
- const task = plannerTask();
- const repository = repositoryFixture({
- load: vi.fn(async () => ({
- ...structuredClone(collection),
- tasks: [task],
- })),
- pendingMutations: () => [
- {
- requestId: "request-1",
- operation: "update",
- createdAt: "2026-08-23T12:00:00.000Z",
- },
- ],
- recoverPendingMutation: vi.fn(),
- });
- render(
- undefined} />,
- );
-
- const ganttWrites = [
- await screen.findByRole("button", { name: "Move test task" }),
- screen.getByRole("button", { name: "Change test dependencies" }),
- screen.getByRole("button", { name: "Complete test task" }),
- ];
- for (const button of ganttWrites) {
- expect(button).toBeDisabled();
- fireEvent.click(button);
- }
-
- expect(
- screen.getByRole("button", { name: "Recover pending change" }),
- ).toBeEnabled();
- expect(screen.getByRole("button", { name: "Save view" })).toBeDisabled();
-
- fireEvent.click(screen.getByRole("button", { name: "Select test task" }));
- expect(
- document.querySelector(
- ".task-inspector .planning-field select",
- ),
- ).toBeDisabled();
- expect(
- screen.getByRole("button", { name: "Save schedule" }),
- ).toBeDisabled();
-
- expect(repository.updateSchedule).not.toHaveBeenCalled();
- expect(repository.updateDependencies).not.toHaveBeenCalled();
- expect(repository.updateProperties).not.toHaveBeenCalled();
- expect(repository.toggleCompletion).not.toHaveBeenCalled();
- expect(repository.saveView).not.toHaveBeenCalled();
});
});
@@ -416,7 +230,6 @@ function repositoryFixture(
updateProperties: vi.fn(),
toggleCompletion: vi.fn(),
saveView: vi.fn(),
- pendingMutations: () => [],
...overrides,
};
}
@@ -452,11 +265,3 @@ function plannerTask(): PlannerTask {
],
};
}
-
-function pendingMutation(): PendingPlannerMutation {
- return {
- requestId: "request-1",
- operation: "update",
- createdAt: "2026-08-23T12:00:00.000Z",
- };
-}
diff --git a/src/components/planner.tsx b/src/components/planner.tsx
index 70abfb3..c98acda 100644
--- a/src/components/planner.tsx
+++ b/src/components/planner.tsx
@@ -48,7 +48,6 @@ import { taskNotesHref } from "../data/tasknotes-handoff";
import type {
PlannerCollection,
- PendingPlannerMutation,
RejectedPlannerWrite,
PlannerRepository,
PlannerTask,
@@ -95,18 +94,11 @@ export function Planner({
const [saveError, setSaveError] = useState("");
const [selectedId, setSelectedId] = useState(null);
const [todayRequest, setTodayRequest] = useState(0);
- const [pendingMutations, setPendingMutations] = useState<
- readonly PendingPlannerMutation[]
- >([]);
const [rejectedWrites, setRejectedWrites] = useState<
readonly RejectedPlannerWrite[]
>([]);
- const [recoveringMutation, setRecoveringMutation] = useState("");
- const [recoveryError, setRecoveryError] = useState("");
const [collectionActionError, setCollectionActionError] = useState("");
const refreshTimer = useRef(null);
- const mutationsDisabled =
- pendingMutations.length > 0 || Boolean(recoveringMutation);
const savedOnDevice = useSyncExternalStore(
useCallback(
@@ -118,8 +110,7 @@ export function Planner({
const notice: ConnectionNotice | null =
connectionNotice ?? (savedOnDevice ? "saved_on_device" : null);
- const refreshPendingMutations = useCallback(() => {
- setPendingMutations(repository.pendingMutations?.() ?? []);
+ const refreshRejectedWrites = useCallback(() => {
setRejectedWrites(repository.rejectedWrites?.() ?? []);
}, [repository]);
@@ -138,7 +129,7 @@ export function Planner({
setShowCompleted(options?.showCompleted ?? false);
if (options?.zoom !== undefined)
setZoom(clampZoom(Math.round(options.zoom)));
- refreshPendingMutations();
+ refreshRejectedWrites();
} catch (reason) {
setError(errorMessage(reason));
} finally {
@@ -146,7 +137,7 @@ export function Planner({
setRefreshing(false);
}
},
- [activeViewKey, refreshPendingMutations, repository],
+ [activeViewKey, refreshRejectedWrites, repository],
);
useEffect(() => {
@@ -209,7 +200,6 @@ export function Planner({
const unscheduled = visibleTasks.filter((task) => !taskSpan(task)).length;
async function saveSchedule(task: PlannerTask, update: ScheduleUpdate) {
- requireMutationsAvailable(mutationsDisabled);
const previous = task;
setCollection((current) =>
replaceCollectionTask(current, withSchedule(task, update)),
@@ -222,7 +212,7 @@ export function Planner({
setCollection((current) => replaceCollectionTask(current, previous));
throw reason;
} finally {
- refreshPendingMutations();
+ refreshRejectedWrites();
}
}
@@ -230,7 +220,6 @@ export function Planner({
task: PlannerTask,
dependencies: readonly TaskDependency[],
) {
- requireMutationsAvailable(mutationsDisabled);
const previous = task;
setCollection((current) =>
replaceCollectionTask(current, { ...task, blockedBy: [...dependencies] }),
@@ -243,49 +232,25 @@ export function Planner({
setCollection((current) => replaceCollectionTask(current, previous));
throw reason;
} finally {
- refreshPendingMutations();
+ refreshRejectedWrites();
}
}
async function saveProperties(task: PlannerTask, update: TaskPropertyUpdate) {
- requireMutationsAvailable(mutationsDisabled);
try {
const saved = await repository.updateProperties(task, update);
setCollection((current) => replaceCollectionTask(current, saved));
} finally {
- refreshPendingMutations();
+ refreshRejectedWrites();
}
}
async function toggleCompletion(task: PlannerTask) {
- requireMutationsAvailable(mutationsDisabled);
try {
const saved = await repository.toggleCompletion(task);
setCollection((current) => replaceCollectionTask(current, saved));
} finally {
- refreshPendingMutations();
- }
- }
-
- async function recoverMutation(requestId: string) {
- if (!repository.recoverPendingMutation) return;
- setRecoveringMutation(requestId);
- setRecoveryError("");
- try {
- await repository.recoverPendingMutation(requestId);
- await load(true);
- } catch (reason) {
- setRecoveryError(errorMessage(reason));
- const remaining = repository.pendingMutations?.() ?? [];
- if (remaining.some((pending) => pending.requestId === requestId)) {
- setPendingMutations(remaining);
- } else {
- // A consumed handle is definitive even when recovery returns a rejection.
- // Reconcile before releasing the write gate.
- await load(true);
- }
- } finally {
- setRecoveringMutation("");
+ refreshRejectedWrites();
}
}
@@ -317,10 +282,6 @@ export function Planner({
async function saveView(event: FormEvent) {
event.preventDefault();
- if (mutationsDisabled) {
- setSaveError(pendingMutationMessage);
- return;
- }
if (!collection || !saveName.trim()) return;
setSavingView(true);
setSaveError("");
@@ -343,7 +304,7 @@ export function Planner({
} catch (reason) {
setSaveError(errorMessage(reason));
} finally {
- refreshPendingMutations();
+ refreshRejectedWrites();
setSavingView(false);
}
}
@@ -391,7 +352,6 @@ export function Planner({
aria-label="Add task"
className="add-task-control"
type="button"
- disabled={mutationsDisabled}
onClick={() => setCreateOpen(true)}
>
@@ -402,7 +362,6 @@ export function Planner({
collection.activeView?.writable ? "Save changes" : "Save view"
}
className="save-view-control"
- disabled={mutationsDisabled}
type="button"
onClick={openSaveView}
>
@@ -553,21 +512,6 @@ export function Planner({
) : null}
- {pendingMutations.map((pending) => (
-
- A {pending.operation} may already have completed. Recover its original
- request before making it again.
- void recoverMutation(pending.requestId)}
- >
- {recoveringMutation === pending.requestId
- ? "Recovering…"
- : "Recover pending change"}
-
-
- ))}
{rejectedWrites.map((write) => (
A change wasn’t saved: {write.message}
@@ -576,18 +520,13 @@ export function Planner({
onClick={() =>
void repository
.dismissRejectedWrite?.(write.id)
- .finally(refreshPendingMutations)
+ .finally(refreshRejectedWrites)
}
>
Dismiss
))}
- {recoveryError ? (
-
- {recoveryError}
-
- ) : null}
{error ? (
Tasks may be out of date. {error}
@@ -603,7 +542,6 @@ export function Planner({
setCreateOpen(true)}
>
Add your first task
@@ -621,7 +559,6 @@ export function Planner({
) : (
setSelectedId(null)}
onSaveDependencies={saveDependencies}
@@ -649,10 +585,9 @@ export function Planner({
) : null}
setCreateOpen(false)}
- onSettled={refreshPendingMutations}
+ onSettled={refreshRejectedWrites}
onCreated={(task) => {
setQuery("");
setProject("all");
@@ -718,7 +653,7 @@ export function Planner({
{savingView ? "Saving…" : "Save view"}
@@ -731,13 +666,6 @@ export function Planner({
);
}
-const pendingMutationMessage =
- "Recover the pending change before making another collection change.";
-
-function requireMutationsAvailable(disabled: boolean): void {
- if (disabled) throw new Error(pendingMutationMessage);
-}
-
function replaceCollectionTask(
collection: PlannerCollection | null,
task: PlannerTask,
diff --git a/src/components/root.tsx b/src/components/root.tsx
deleted file mode 100644
index 7ec0039..0000000
--- a/src/components/root.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import { lazy, Suspense } from "react";
-import { App } from "./app";
-
-// LAB builds open collections through the public SDK entry, loaded only there.
-// Other builds keep Connect: the SDK has no production release trust yet.
-const SdkApp = lazy(() => import("./sdk-app"));
-
-export function Root() {
- return import.meta.env.VITE_MDBASE_ENV === "lab" ? (
-
-
-
- ) : (
-
- );
-}
diff --git a/src/components/sdk-app.tsx b/src/components/sdk-app.tsx
index 9bc8bbd..d71ecd3 100644
--- a/src/components/sdk-app.tsx
+++ b/src/components/sdk-app.tsx
@@ -19,7 +19,7 @@ const app = {
mark: ,
};
-/** Planner over the page's one SDK entry (LAB builds; see root.tsx). The
+/** Planner over the page's one SDK entry. The
* shared collection UI owns sign-in, the collection list, setup and problems,
* and starts the entry (never closed here: it is the page's singleton). */
export default function SdkApp({ mdbase: supplied }: { mdbase?: Mdbase } = {}) {
diff --git a/src/components/task-create-dialog.tsx b/src/components/task-create-dialog.tsx
index dfe762a..c18afb5 100644
--- a/src/components/task-create-dialog.tsx
+++ b/src/components/task-create-dialog.tsx
@@ -6,14 +6,12 @@ import { useModalFocus } from "./use-modal-focus";
/** One form draft is one creation intent, including a retry after a lost reply. */
export function TaskCreateDialog({
open,
- disabled,
repository,
onClose,
onCreated,
onSettled,
}: {
open: boolean;
- disabled: boolean;
repository: PlannerRepository;
onClose(): void;
onCreated(task: PlannerTask): void;
@@ -41,7 +39,7 @@ export function TaskCreateDialog({
async function submit(event: FormEvent) {
event.preventDefault();
- if (pending.current || disabled || !draft.title.trim()) return;
+ if (pending.current || !draft.title.trim()) return;
pending.current = true;
setBusy(true);
setFailure(null);
@@ -100,7 +98,7 @@ export function TaskCreateDialog({
{busy ? "Adding…" : "Add task"}
diff --git a/src/components/task-inspector.test.tsx b/src/components/task-inspector.test.tsx
index e691c0e..5fd3e15 100644
--- a/src/components/task-inspector.test.tsx
+++ b/src/components/task-inspector.test.tsx
@@ -7,7 +7,7 @@ import {
} from "@testing-library/react";
import { TaskInspector } from "./task-inspector";
-import { MdbaseConnectError, type ConnectProblem } from "@mdbase-dev/connect";
+import { MdbaseError } from "@mdbase-dev/sdk";
import type { PlannerTask } from "../domain/task";
@@ -164,15 +164,13 @@ describe("TaskInspector", () => {
expect(screen.getByLabelText("Status")).toHaveValue("done");
});
- it("does not roll an uncertain property choice back before recovery", async () => {
- const problem = {
- problem_version: 1,
- code: "operation_outcome_unknown",
- category: "transport",
- recovery: "recover_mutation",
+ it("does not roll an uncertain property choice back", async () => {
+ const problem = new MdbaseError({
+ code: "outcome_unknown",
+ recovery: "refresh",
message: "The status change may already have completed.",
- operation_outcome: "unknown",
- } as unknown as ConnectProblem;
+ });
+ const onSaveProperties = vi.fn().mockRejectedValue(problem);
render(
{
onClose={() => undefined}
onSave={vi.fn().mockResolvedValue(undefined)}
onSaveDependencies={vi.fn().mockResolvedValue(undefined)}
- onSaveProperties={vi
- .fn()
- .mockRejectedValue(new MdbaseConnectError(problem))}
+ onSaveProperties={onSaveProperties}
/>,
);
@@ -190,47 +186,11 @@ describe("TaskInspector", () => {
target: { value: "done" },
});
- await screen.findByText("The status change may already have completed.");
+ await waitFor(() => expect(onSaveProperties).toHaveBeenCalled());
+ await screen.findByRole("alert");
expect(screen.getByLabelText("Status")).toHaveValue("done");
});
- it("disables mutation-producing inspector controls while recovery is pending", () => {
- const onSave = vi.fn();
- const onSaveDependencies = vi.fn();
- const onSaveProperties = vi.fn();
- render(
- undefined}
- onSave={onSave}
- onSaveDependencies={onSaveDependencies}
- onSaveProperties={onSaveProperties}
- />,
- );
-
- expect(screen.getByLabelText("Status")).toBeDisabled();
- expect(screen.getByLabelText("Priority")).toBeDisabled();
- expect(screen.getByLabelText("Scheduled")).toBeDisabled();
- expect(
- screen.getByRole("button", { name: "Save schedule" }),
- ).toBeDisabled();
- expect(
- screen.getByRole("button", {
- name: "Remove relationship from research",
- }),
- ).toBeDisabled();
-
- fireEvent.change(screen.getByLabelText("Status"), {
- target: { value: "done" },
- });
- fireEvent.click(screen.getByRole("button", { name: "Save schedule" }));
- expect(onSave).not.toHaveBeenCalled();
- expect(onSaveDependencies).not.toHaveBeenCalled();
- expect(onSaveProperties).not.toHaveBeenCalled();
- });
-
it("adds and clears TaskNotes project memberships", async () => {
const launch = {
...task,
diff --git a/src/components/task-inspector.tsx b/src/components/task-inspector.tsx
index c73d117..33692b4 100644
--- a/src/components/task-inspector.tsx
+++ b/src/components/task-inspector.tsx
@@ -34,7 +34,6 @@ import type { FormEvent } from "react";
export function TaskInspector({
task,
allTasks,
- mutationsDisabled = false,
onClose,
onSave,
onSaveDependencies,
@@ -42,7 +41,6 @@ export function TaskInspector({
}: {
task: PlannerTask;
allTasks: readonly PlannerTask[];
- mutationsDisabled?: boolean;
onClose(): void;
onSave(task: PlannerTask, update: ScheduleUpdate): Promise;
onSaveDependencies(
@@ -125,7 +123,7 @@ export function TaskInspector({
async function submit(event: FormEvent) {
event.preventDefault();
- if (validation || mutationsDisabled) return;
+ if (validation) return;
setSaving(true);
setScheduleError("");
try {
@@ -141,7 +139,6 @@ export function TaskInspector({
}
async function saveDependencies(dependencies: readonly TaskDependency[]) {
- if (mutationsDisabled) return;
setDependencySaving(true);
setDependencyError("");
try {
@@ -154,7 +151,7 @@ export function TaskInspector({
}
async function addDependency() {
- if (!dependencySource || mutationsDisabled) return;
+ if (!dependencySource) return;
const allowed = canAddDependency(allTasks, dependencySource, task.id);
if (!allowed.allowed) {
setDependencyError(allowed.reason);
@@ -172,7 +169,6 @@ export function TaskInspector({
}
async function saveProperty(update: TaskPropertyUpdate) {
- if (mutationsDisabled) return;
setPropertySaving(true);
setPropertyError("");
try {
@@ -220,7 +216,7 @@ export function TaskInspector({
Status
{
const value = event.target.value;
@@ -238,7 +234,7 @@ export function TaskInspector({
Priority
{
const value = event.target.value;
@@ -253,10 +249,7 @@ export function TaskInspector({
))}
-
+
Projects
Planner groups tasks by their first selected project.
{projectOptions.length ? (
@@ -337,14 +330,13 @@ export function TaskInspector({
setScheduled(event.target.value)}
/>
setDue(event.target.value)}
/>
{
setScheduled("");
@@ -397,7 +388,7 @@ export function TaskInspector({
{saving ? "Saving…" : "Save schedule"}
@@ -417,7 +408,7 @@ export function TaskInspector({
{dependency.title}
void saveDependencies(
@@ -441,7 +432,7 @@ export function TaskInspector({
void saveDependencies(
@@ -464,7 +455,6 @@ export function TaskInspector({
Blocking task
setDependencySource(event.target.value)}
>
@@ -479,7 +469,6 @@ export function TaskInspector({
Relationship type
setDependencyType(event.target.value as TaskDependencyRelType)
@@ -494,9 +483,7 @@ export function TaskInspector({
void addDependency()}
>
diff --git a/src/data/connect-popup.ts b/src/data/connect-popup.ts
deleted file mode 100644
index 2fbe49e..0000000
--- a/src/data/connect-popup.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import type { AppWebSignInPortal } from "@mdbase-dev/sdk/app-host";
-
-export class ConnectPopupBlocked extends Error {
- constructor() {
- super("Your browser blocked the sign-in tab.");
- }
-}
-
-export interface ConnectPopup extends AppWebSignInPortal {
- readonly closed: boolean;
-}
-
-/** UI-only capability: callers pass only the SDK-validated Connect URI.
- * Keep the production popup opener so its parent can close it across origins.
- * The tab fallback detaches its opener before navigation. */
-export function openConnectPopup(
- name = "mdbase-connect-authorization",
-): ConnectPopup {
- let popup: Window | null = null;
- try {
- popup = window.open("", name, "popup,width=620,height=760");
- } catch {
- /* Try a tab. */
- }
- if (!popup) {
- try {
- popup = window.open("", "_blank");
- // A literal noopener feature returns no usable blank handle. Detach
- // immediately instead, before the SDK supplies the navigation URI.
- if (popup) popup.opener = null;
- } catch {
- try {
- popup?.close();
- } catch {
- /* Display cleanup only. */
- }
- popup = null;
- }
- }
- if (!popup) throw new ConnectPopupBlocked();
- const original = popup;
- let closed = false;
- return {
- get closed() {
- return closed || original.closed;
- },
- navigate(uri) {
- if (closed || original.closed) throw new Error("Sign-in window closed.");
- original.location.href = uri;
- },
- close() {
- if (closed) return;
- closed = true;
- try {
- original.close();
- } catch {
- /* Display cleanup does not undo approval. */
- }
- },
- };
-}
diff --git a/src/data/connect.test.ts b/src/data/connect.test.ts
deleted file mode 100644
index 73e68ec..0000000
--- a/src/data/connect.test.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import {
- effectiveCapabilities,
- type MdbaseConnectionInfo,
- type MdbaseOperation,
-} from "@mdbase-dev/connect";
-import { capabilityOperationsForContractVersion } from "@mdbase-dev/connect-protocol";
-import manifest from "../generated-mdbase-app.json";
-import {
- assertPlannerOrigin,
- connect,
- isAuthorizationCallback,
- plannerSession,
-} from "./connect";
-
-// Real installed beta95 SDK, not a mocked constructor or capability evaluator.
-describe("Planner's installed Connect runtime", () => {
- it("constructs the application session and loads the exact v2 declaration without network", async () => {
- const network = vi
- .spyOn(globalThis, "fetch")
- .mockRejectedValue(new Error("Unexpected network"));
- const result = await connect.manifest();
- expect(result.ok).toBe(true);
- if (!result.ok) throw new Error(result.problem.message);
- expect(result.value.requirements?.capabilities).toEqual(
- manifest.requirements.capabilities,
- );
- expect(plannerSession.getSnapshot().status).toBe("not_started");
- expect(network).not.toHaveBeenCalled();
- });
-
- it("evaluates genuine v2 groups, rejects stale v1 operations, and grants no excluded operations", async () => {
- const result = await connect.manifest();
- if (!result.ok) throw new Error(result.problem.message);
- const declaration = result.value;
- const requirements = declaration.requirements!.capabilities!;
- const operations = [
- ...new Set(
- requirements.required.flatMap(
- (id) => capabilityOperationsForContractVersion(2, id) ?? [],
- ),
- ),
- ];
- const info: MdbaseConnectionInfo = {
- collectionId: "fixture",
- displayName: "Fixture",
- operations,
- scope: { access: "full_collection", contracts: [] },
- authority: { kind: "connector", durability: "computer" },
- route: "relay",
- directAccess: "unavailable",
- };
- const effective = effectiveCapabilities(requirements, declaration, info);
- expect(effective.contractVersion).toBe(2);
- expect(effective.requiredAvailable).toBe(true);
- expect(Object.keys(effective.values)).toEqual(
- manifest.requirements.capabilities.required,
- );
- expect(operations).toContain("create");
- expect(operations).not.toContain("delete");
- expect(operations.some((op) => /file|timer|sync/i.test(op))).toBe(false);
- // Prior Planner grant's exact operation union is not authority for whole v2 groups.
- const legacy: MdbaseOperation[] = [
- "describe",
- "assess_collection_setup",
- "apply_collection_setup",
- "update_type",
- "assess_type_pack",
- "apply_type_pack",
- "changes",
- "read",
- "query",
- "update",
- "list_views",
- "execute_view",
- "read_view_source",
- "create_view_source",
- "update_view_source",
- ];
- const stale = effectiveCapabilities(requirements, declaration, {
- ...info,
- operations: legacy,
- });
- expect(stale.requiredAvailable).toBe(false);
- expect(stale.values["records.edit"]?.state).toBe("requires_authorization");
- expect(stale.values["views.manage"]?.state).toBe("requires_authorization");
- const denied = effectiveCapabilities(requirements, declaration, {
- ...info,
- operations: [],
- });
- expect(denied.requiredAvailable).toBe(false);
- expect(
- Object.values(denied.values).every(
- (value) => value.state === "requires_authorization",
- ),
- ).toBe(true);
- });
-
- it("requires the manifest origin instead of silently accepting a localhost alias", () => {
- expect(() => assertPlannerOrigin(manifest.homepage)).not.toThrow();
- expect(() => assertPlannerOrigin("http://localhost:4174/")).toThrow(
- /Open Planner at/,
- );
- expect(
- isAuthorizationCallback(
- `${manifest.redirect_uris[0]}?state=fixture&error=access_denied`,
- ),
- ).toBe(true);
- });
-});
diff --git a/src/data/connect.ts b/src/data/connect.ts
deleted file mode 100644
index 3f63377..0000000
--- a/src/data/connect.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import {
- MdbaseBrowserSelection,
- MdbaseConnect,
- type JsonObject,
- type MdbaseAppManifest,
-} from "@mdbase-dev/connect";
-
-import manifest from "../generated-mdbase-app.json";
-
-const serverUrl =
- import.meta.env.VITE_MDBASE_CONNECT_URL ?? "https://connect.mdbase.dev";
-const loopbackUrl =
- import.meta.env.VITE_MDBASE_CONNECT_LOOPBACK_URL ?? "http://127.0.0.1:28485";
-// The callback must share the manifest origin (including in development).
-const redirectUri = manifest.redirect_uris[0];
-
-export function assertPlannerOrigin(value = location.href): void {
- if (new URL(value).origin !== new URL(manifest.homepage).origin)
- throw new Error(
- `Open Planner at ${manifest.homepage} to connect a collection.`,
- );
-}
-
-export const connect = new MdbaseConnect({
- serverUrl,
- loopbackUrl,
- manifest: manifest as MdbaseAppManifest,
- redirectUri,
- relayEncryption: "required",
-});
-
-export const plannerSession = connect.application({
- selection: new MdbaseBrowserSelection({ fallbackPath: joinBase("") }),
- autoSelect: "never",
-});
-
-export function isAuthorizationCallback(value: string): boolean {
- try {
- const url = new URL(value);
- return (
- url.searchParams.has("state") &&
- (url.searchParams.has("code") || url.searchParams.has("error"))
- );
- } catch {
- return false;
- }
-}
-
-function joinBase(path: string): string {
- const base = import.meta.env.BASE_URL;
- return `${base.endsWith("/") ? base : `${base}/`}${path}`;
-}
diff --git a/src/data/mdbase-repository.test.ts b/src/data/mdbase-repository.test.ts
deleted file mode 100644
index db189d8..0000000
--- a/src/data/mdbase-repository.test.ts
+++ /dev/null
@@ -1,312 +0,0 @@
-import { type JsonObject, type MdbaseConnection } from "@mdbase-dev/connect";
-import { TASKNOTES_CONTRACT_DIGEST } from "@tasknotes/model/mdbase";
-import { TASKNOTES_SPEC_VERSION } from "@tasknotes/model";
-import { parse } from "yaml";
-
-import { MdbasePlannerRepository } from "./mdbase-repository";
-
-describe("MdbasePlannerRepository", () => {
- it("creates a tasknotesPlanner Base from contract-defined filters", async () => {
- const createViewSource = vi.fn(async (input: { document: string }) =>
- success({
- path: "TaskNotes/Views/high-work.base",
- format: "obsidian.base",
- revision: "view-1",
- document: input.document,
- }),
- );
- const update = vi.fn(async (input: { patch: JsonObject }) =>
- success({
- path: "tasks/plan.md",
- revision: "task-2",
- types: ["task"],
- contract: {
- id: "tasknotes.task",
- version: TASKNOTES_SPEC_VERSION,
- type: "task",
- },
- effectiveFrontmatter: {
- id: "plan",
- title: "Plan launch",
- status: "open",
- priority: "normal",
- ...input.patch,
- },
- }),
- );
- const connection = {
- describe: vi.fn(async () =>
- success({
- collectionId: "collection-1",
- displayName: "Work",
- changeCursor: 4,
- contracts: [
- {
- id: "tasknotes.task",
- version: TASKNOTES_SPEC_VERSION,
- digest: TASKNOTES_CONTRACT_DIGEST,
- implementations: [
- {
- typeName: "task",
- fields: {
- id: "id",
- title: "title",
- status: "status",
- priority: "priority",
- scheduled: "scheduled",
- due: "due",
- projects: "projects",
- },
- binding: {
- status: {
- default: "open",
- definitions: [
- {
- value: "open",
- label: "Open",
- color: "#64748b",
- order: 1,
- is_completed: false,
- is_skipped: false,
- },
- {
- value: "done",
- label: "Done",
- color: "#22c55e",
- order: 2,
- is_completed: true,
- is_skipped: false,
- },
- ],
- },
- priority: {
- default: "normal",
- definitions: [
- {
- value: "normal",
- label: "Normal",
- color: "#f59e0b",
- weight: 2,
- },
- {
- value: "high",
- label: "High",
- color: "#ef4444",
- weight: 3,
- },
- ],
- },
- archive: { archived_tag: "archived" },
- },
- },
- ],
- },
- ],
- }),
- ),
- listViews: vi.fn(async () =>
- success({ views: [], meta: { totalCount: 0 } }),
- ),
- queryAll: vi.fn(async () =>
- success({
- results: [
- {
- path: "tasks/plan.md",
- revision: "task-1",
- types: ["task"],
- effectiveFrontmatter: {
- id: "plan",
- title: "Plan launch",
- status: "open",
- priority: "normal",
- },
- },
- ],
- meta: { totalCount: 1, hasMore: false },
- }),
- ),
- read: vi.fn(async () =>
- success({
- path: "tasks/plan.md",
- revision: "task-1",
- types: ["task"],
- effectiveFrontmatter: {
- id: "plan",
- title: "Plan launch",
- status: "open",
- priority: "normal",
- },
- }),
- ),
- update,
- createViewSource,
- } as unknown as MdbaseConnection;
- const repository = new MdbasePlannerRepository(connection);
-
- const collection = await repository.load();
- const contract = { id: "tasknotes.task", version: TASKNOTES_SPEC_VERSION };
- expect(connection.queryAll).toHaveBeenCalledWith(
- expect.objectContaining({ contract }),
- { firstPageSize: 500, pageSize: 1_000 },
- );
- expect(collection.statuses).toMatchObject([
- { value: "open", label: "Open", isCompleted: false },
- { value: "done", label: "Done", isCompleted: true },
- ]);
- const completed = await repository.toggleCompletion(collection.tasks[0]);
- expect(completed).toMatchObject({ status: "done", completed: true });
- expect(update).toHaveBeenCalledWith(
- expect.objectContaining({
- patch: expect.objectContaining({
- status: "done",
- completedDate: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
- }),
- }),
- );
- const regrouped = await repository.updateProperties(collection.tasks[0], {
- projects: ["[[Launch]]"],
- });
- expect(regrouped.projects).toEqual(["[[Launch]]"]);
- expect(update).toHaveBeenLastCalledWith(
- expect.objectContaining({
- patch: expect.objectContaining({ projects: ["[[Launch]]"] }),
- }),
- );
- const schedule = { scheduled: "2026-09-09", due: "2026-09-12" };
- expect(
- await repository.updateSchedule(collection.tasks[0], schedule),
- ).toMatchObject(schedule);
- expect(connection.read).toHaveBeenLastCalledWith({
- path: "tasks/plan.md",
- contract: { ...contract, type: "task" },
- });
- expect(update).toHaveBeenLastCalledWith({
- path: "tasks/plan.md",
- contract: { ...contract, type: "task" },
- ifRevision: "task-1",
- patch: schedule,
- });
- await repository.saveView({
- name: "High work",
- status: "open",
- priority: "high",
- project: "[[Launch]]",
- showCompleted: false,
- zoom: 4,
- });
-
- const input = createViewSource.mock.calls[0][0];
- const document = parse(input.document) as {
- views: Array>;
- };
- expect(document.views[0]).toMatchObject({
- type: "tasknotesPlanner",
- name: "High work",
- options: {
- zoom: 4,
- project: "[[Launch]]",
- status: "open",
- priority: "high",
- showCompleted: false,
- },
- });
- expect(JSON.stringify(document.views[0].filters)).toContain(
- 'note[\\"status\\"] == \\"open\\"',
- );
- });
-
- it("updates a saved Base with its revision and preserves other views and options", async () => {
- const path = "TaskNotes/Views/work.base";
- const readViewSource = vi.fn(async () =>
- success({
- path,
- revision: "view-3",
- document: JSON.stringify({
- formulas: { effort: "1 + 1" },
- views: [
- { type: "table", name: "Other" },
- {
- type: "tasknotesPlanner",
- name: "Work",
- options: { custom: "preserved", zoom: 2 },
- },
- ],
- }),
- }),
- );
- const updateViewSource = vi.fn(async (input: { document: string }) =>
- success({ path, format: "obsidian.base", revision: "view-4", ...input }),
- );
- const createViewSource = vi.fn();
- const repository = new MdbasePlannerRepository({
- readViewSource,
- updateViewSource,
- createViewSource,
- } as unknown as MdbaseConnection);
- const saved = await repository.saveView({
- name: "Work",
- zoom: 4,
- view: {
- key: `${path}#work`,
- path,
- id: "work",
- name: "Work",
- format: "obsidian.base",
- revision: "view-1",
- writable: true,
- options: {},
- },
- });
- expect(readViewSource).toHaveBeenCalledWith({ path });
- expect(updateViewSource).toHaveBeenCalledWith({
- path,
- ifRevision: "view-3",
- document: expect.any(String),
- });
- expect(parse(updateViewSource.mock.calls[0][0].document)).toMatchObject({
- formulas: { effort: "1 + 1" },
- views: [
- { type: "table", name: "Other" },
- { type: "tasknotesPlanner", options: { custom: "preserved", zoom: 4 } },
- ],
- });
- expect(saved.revision).toBe("view-4");
- expect(createViewSource).not.toHaveBeenCalled();
- });
-
- it("recovers the persisted mutation handle without replaying a write", async () => {
- const recover = vi.fn(async () => success({ revision: "task-2" }));
- const pending = {
- requestId: "request-1",
- operation: "update",
- fingerprint: "sha256:fingerprint",
- status: "outcome_unknown",
- createdAt: "2026-08-23T12:00:00.000Z",
- recover,
- } as const;
- const update = vi.fn();
- const connection = {
- pendingMutations: vi.fn(() => [pending]),
- pendingMutation: vi.fn(() => pending),
- update,
- } as unknown as MdbaseConnection;
- const repository = new MdbasePlannerRepository(connection);
-
- expect(repository.pendingMutations()).toEqual([
- {
- requestId: "request-1",
- operation: "update",
- createdAt: "2026-08-23T12:00:00.000Z",
- },
- ]);
-
- await repository.recoverPendingMutation("request-1");
-
- expect(recover).toHaveBeenCalledWith({ timeoutMs: 30_000 });
- expect(update).not.toHaveBeenCalled();
- });
-});
-
-function success(value: Value) {
- return { ok: true as const, value, diagnostics: [] };
-}
diff --git a/src/data/mdbase-repository.ts b/src/data/mdbase-repository.ts
deleted file mode 100644
index 1c668ce..0000000
--- a/src/data/mdbase-repository.ts
+++ /dev/null
@@ -1,796 +0,0 @@
-import {
- type CollectionContractDescriptor,
- type JsonObject,
- type MdbaseConnection,
- type SavedNamedView,
- type SavedViewDocument,
-} from "@mdbase-dev/connect";
-import { TASKNOTES_CONTRACT_DIGEST } from "@tasknotes/model/mdbase";
-import {
- normalizeDependencyList,
- serializeDependencies,
-} from "@tasknotes/model/mapping";
-import {
- buildTaskUpdatePlan,
- specFrontmatterToTaskInfo,
- taskInfoToSpecFields,
-} from "@tasknotes/model/operations";
-import {
- DEFAULT_FIELD_MAPPING,
- getDefaultCompletedStatus,
- TASKNOTES_SPEC_VERSION,
- type StatusConfig,
-} from "@tasknotes/model";
-import { parse, stringify } from "yaml";
-import { parseFrontmatter } from "@tasknotes/model/frontmatter";
-
-import { requireOutcome } from "./outcome";
-import { taskCreationFields, taskCreationPath } from "./task-creation";
-
-import type {
- PlannerCollection,
- PendingPlannerMutation,
- PlannerPriorityOption,
- PlannerRepository,
- PlannerStatusOption,
- PlannerTask,
- PlannerView,
- PlannerViewOptions,
- SavePlannerViewInput,
- ScheduleUpdate,
- TaskDependency,
- TaskPropertyUpdate,
-} from "../domain/task";
-
-const contract = {
- id: "tasknotes.task",
- version: TASKNOTES_SPEC_VERSION,
-} as const;
-
-const PLANNER_VIEW_TYPE = "tasknotesPlanner";
-const PLANNER_VIEW_FOLDER = "TaskNotes/Views";
-
-export interface TaskTypeConfiguration {
- typeName: string;
- fields: Record;
- statuses: PlannerStatusOption[];
- priorities: PlannerPriorityOption[];
- defaultStatus: string;
- defaultPriority: string;
- archiveTag?: string;
-}
-
-export class MdbasePlannerRepository implements PlannerRepository {
- private changeCursor = 0;
- private collection: PlannerCollection | null = null;
- private taskTypes = new Map();
-
- constructor(private readonly connection: MdbaseConnection) {}
-
- async load(viewKey?: string): Promise {
- const [description, viewList] = await Promise.all([
- this.connection.describe().then(requireOutcome),
- this.connection.listViews().then(requireOutcome),
- ]);
- const descriptor = requireTaskNotesContract(description.contracts);
- this.taskTypes = taskTypeConfigurations(descriptor);
- this.changeCursor = description.changeCursor;
- const views = plannerViews(viewList.views);
- const activeView = views.find((view) => view.key === viewKey);
- const records = activeView
- ? requireOutcome(
- await this.connection.executeView({
- path: activeView.path,
- view: activeView.id,
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
- limit: 2_000,
- render: false,
- }),
- ).results
- : requireOutcome(
- await this.connection.queryAll(
- {
- contract,
- frontmatterMode: "effective",
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
- },
- { firstPageSize: 500, pageSize: 1_000 },
- ),
- ).results;
- const tasks = records.flatMap((record) => {
- try {
- return [this.taskFromRecord(record)];
- } catch {
- return [];
- }
- });
- this.collection = {
- id: description.collectionId,
- name: description.displayName,
- tasks,
- views,
- ...(activeView ? { activeView } : {}),
- statuses: aggregateStatuses(this.taskTypes.values()),
- priorities: aggregatePriorities(this.taskTypes.values()),
- };
- return structuredClone(this.collection);
- }
-
- async createTask(title: string, intent: string): Promise {
- if (!title.trim()) throw new Error("Give this task a title.");
- await this.load();
- const config = this.taskTypes.values().next().value;
- if (!config)
- throw new Error("The TaskNotes contract has no implementing types.");
- const type = requireOutcome(
- await this.connection.readType({ name: config.typeName }),
- );
- const path = taskCreationPath(
- parseFrontmatter(type.document).frontmatter,
- intent,
- );
- const saved = requireOutcome(
- await this.connection.create({
- ...(path === undefined ? {} : { path }),
- contract: { ...contract, type: config.typeName },
- frontmatter: taskCreationFields(title, intent, config),
- }),
- );
- return this.taskFromRecord(saved);
- }
-
- async updateSchedule(
- task: PlannerTask,
- update: ScheduleUpdate,
- ): Promise {
- return this.updateTask(task, {
- scheduled: update.scheduled ?? null,
- due: update.due ?? null,
- });
- }
-
- async updateDependencies(
- task: PlannerTask,
- dependencies: readonly TaskDependency[],
- ): Promise {
- return this.updateTask(task, {
- blockedBy: serializeDependencies(dependencies),
- });
- }
-
- async updateProperties(
- task: PlannerTask,
- update: TaskPropertyUpdate,
- ): Promise {
- const selector = this.selector(task);
- const current = requireOutcome(
- await this.connection.read({ path: task.path, contract: selector }),
- );
- const config = task.providerType
- ? this.taskTypes.get(task.providerType)
- : undefined;
- if (!config)
- throw new Error("This task type does not expose TaskNotes settings.");
- if (
- update.status !== undefined &&
- !config.statuses.some(({ value }) => value === update.status)
- )
- throw new Error("This status is not available for this task type.");
- if (
- update.priority !== undefined &&
- !config.priorities.some(({ value }) => value === update.priority)
- )
- throw new Error("This priority is not available for this task type.");
-
- const values = current.effectiveFrontmatter;
- const original = specFrontmatterToTaskInfo(values, task.path);
- const plan = buildTaskUpdatePlan({
- originalTask: original,
- updates: {
- ...(update.status === undefined ? {} : { status: update.status }),
- ...(update.priority === undefined ? {} : { priority: update.priority }),
- ...(update.projects === undefined ? {} : { projects: update.projects }),
- },
- fieldMapping: DEFAULT_FIELD_MAPPING,
- statuses: modelStatuses(config.statuses),
- now: new Date().toISOString(),
- });
- const next = taskInfoToSpecFields(plan.updatedTask);
- const patch: JsonObject = {};
- for (const field of [
- "status",
- "priority",
- "projects",
- "completedDate",
- ] as const) {
- if (values[field] === next[field]) continue;
- patch[field] = (next[field] ?? null) as JsonObject[string];
- }
- return this.persistTask(task, current.revision, selector, patch);
- }
-
- async toggleCompletion(task: PlannerTask): Promise {
- if (task.recurrence)
- throw new Error(
- "Complete recurring tasks in TaskNotes, where you can choose an occurrence.",
- );
- const config = task.providerType
- ? this.taskTypes.get(task.providerType)
- : undefined;
- if (!config)
- throw new Error("This task type does not expose TaskNotes settings.");
- const completedStatus = getDefaultCompletedStatus(
- modelStatuses(config.statuses),
- );
- if (!config.statuses.some(({ value }) => value === completedStatus))
- throw new Error(
- "This task type does not define a completed TaskNotes status.",
- );
- return this.updateProperties(task, {
- status: task.completed ? config.defaultStatus : completedStatus,
- });
- }
-
- async saveView(input: SavePlannerViewInput): Promise {
- const name = input.name.trim();
- if (!name) throw new Error("Give this view a name before saving it.");
- const document = plannerViewDefinition(
- name,
- input,
- this.taskTypes.values(),
- );
- if (input.view) {
- if (input.view.format !== "obsidian.base" || !input.view.writable)
- throw new Error("This Planner view cannot be changed here.");
- const source = requireOutcome(
- await this.connection.readViewSource({ path: input.view.path }),
- );
- const value = recordValue(parse(source.document));
- const views = objectList(value.views);
- const index = stableIds(
- views.map((view) => stringValue(view.name) ?? ""),
- ).indexOf(input.view.id);
- if (index < 0)
- throw new Error("This view is no longer in its Base file.");
- const previous = views[index];
- views[index] = {
- ...previous,
- ...document.views[0],
- options: {
- ...recordValue(previous.options),
- ...recordValue(document.views[0].options),
- },
- };
- value.views = views;
- const saved = requireOutcome(
- await this.connection.updateViewSource({
- path: source.path,
- ifRevision: source.revision,
- document: stringify(value, { lineWidth: 0 }),
- }),
- );
- return viewFromDefinition(
- saved.path,
- saved.format,
- saved.revision,
- true,
- document.views[0],
- );
- }
-
- const saved = requireOutcome(
- await this.connection.createViewSource({
- path: plannerViewSourcePath(name),
- format: "obsidian.base",
- name,
- document: stringify(document, { lineWidth: 0 }),
- }),
- );
- return viewFromDefinition(
- saved.path,
- saved.format,
- saved.revision,
- true,
- document.views[0],
- );
- }
-
- pendingMutations(): readonly PendingPlannerMutation[] {
- return this.connection.pendingMutations().map((pending) => ({
- requestId: pending.requestId,
- operation: String(pending.operation),
- createdAt: pending.createdAt,
- }));
- }
-
- async recoverPendingMutation(requestId: string): Promise {
- const pending = this.connection.pendingMutation(requestId);
- if (!pending) return;
- requireOutcome(await pending.recover({ timeoutMs: 30_000 }));
- }
-
- private async updateTask(
- task: PlannerTask,
- patch: JsonObject,
- ): Promise {
- const selector = this.selector(task);
- const current = requireOutcome(
- await this.connection.read({ path: task.path, contract: selector }),
- );
- return this.persistTask(task, current.revision, selector, patch);
- }
-
- private async persistTask(
- task: PlannerTask,
- revision: string,
- selector: typeof contract & { type?: string },
- patch: JsonObject,
- ): Promise {
- const result = requireOutcome(
- await this.connection.update({
- path: task.path,
- contract: selector,
- ifRevision: revision,
- patch,
- }),
- );
- const saved = this.taskFromRecord(result);
- if (this.collection)
- this.collection.tasks = this.collection.tasks.map((candidate) =>
- candidate.id === saved.id ? saved : candidate,
- );
- return structuredClone(saved);
- }
-
- async watch(listener: () => void): Promise<() => void> {
- const subscription = requireOutcome(
- await this.connection.watch({ cursor: this.changeCursor }),
- );
- const unsubscribe = subscription.subscribe(() => listener());
- return () => {
- unsubscribe();
- subscription.close();
- };
- }
-
- private selector(task: PlannerTask) {
- return {
- ...contract,
- ...(task.providerType ? { type: task.providerType } : {}),
- };
- }
-
- private taskFromRecord(record: PlannerRecord): PlannerTask {
- return plannerTaskFromRecord(record, this.taskTypes);
- }
-}
-
-export interface PlannerRecord {
- path: string;
- types: readonly string[];
- frontmatter?: JsonObject;
- effectiveFrontmatter?: JsonObject;
- contract?: { type: string };
-}
-
-/** Shared portable task mapping; native SDK callers supply unprojected fields. */
-export function plannerTaskFromRecord(
- record: PlannerRecord,
- configurations: ReadonlyMap,
-): PlannerTask {
- const raw = record.effectiveFrontmatter ?? record.frontmatter ?? {};
- const providerType =
- record.contract?.type ??
- record.types.find((type) => configurations.has(type));
- const configuration = providerType
- ? configurations.get(providerType)
- : undefined;
- if (!configuration)
- throw new Error(`No TaskNotes binding was found for ${record.path}.`);
- const values = record.contract
- ? raw
- : canonicalValues(raw, configuration.fields);
- const title = stringValue(values.title) ?? basename(record.path);
- const status = stringValue(values.status) ?? configuration.defaultStatus;
- const priority =
- stringValue(values.priority) ?? configuration.defaultPriority;
- const statusDefinition = configuration.statuses.find(
- ({ value }) => value === status,
- );
- const priorityDefinition = configuration.priorities.find(
- ({ value }) => value === priority,
- );
- return {
- id: stringValue(values.id) ?? record.path,
- path: record.path,
- title,
- status,
- priority,
- scheduled: stringValue(values.scheduled),
- due: stringValue(values.due),
- projects: stringList(values.projects),
- blockedBy: normalizeDependencyList(values.blockedBy) ?? [],
- completed: statusDefinition?.isCompleted ?? false,
- recurrence: stringValue(values.recurrence),
- providerType,
- statusLabel: statusDefinition?.label ?? status,
- statusColor: statusDefinition?.color ?? "#64748b",
- priorityLabel: priorityDefinition?.label ?? priority,
- priorityColor: priorityDefinition?.color ?? "#64748b",
- statusOptions: configuration.statuses,
- priorityOptions: configuration.priorities,
- };
-}
-
-function requireTaskNotesContract(
- contracts: readonly CollectionContractDescriptor[],
-): CollectionContractDescriptor {
- const descriptor = contracts.find(
- (candidate) =>
- candidate.id === contract.id &&
- candidate.version === contract.version &&
- candidate.digest === TASKNOTES_CONTRACT_DIGEST,
- );
- if (!descriptor)
- throw new Error(
- `This collection does not provide tasknotes.task ${TASKNOTES_SPEC_VERSION}.`,
- );
- return descriptor;
-}
-
-export function taskTypeConfigurations(descriptor: {
- implementations: readonly {
- typeName: string;
- fields: Record;
- binding?: unknown;
- }[];
-}): Map {
- return new Map(
- descriptor.implementations.map((implementation) => {
- const binding = recordValue(implementation.binding);
- const status = recordValue(binding.status);
- const priority = recordValue(binding.priority);
- const archive = recordValue(binding.archive);
- const statuses = objectList(status.definitions).map(
- (definition, index) => ({
- value: stringValue(definition.value) ?? `status-${index + 1}`,
- label:
- stringValue(definition.label) ??
- humanize(stringValue(definition.value) ?? `Status ${index + 1}`),
- color: stringValue(definition.color) ?? "#64748b",
- ...(stringValue(definition.icon)
- ? { icon: stringValue(definition.icon) }
- : {}),
- order: numberValue(definition.order) ?? index,
- isCompleted: booleanValue(definition.is_completed),
- isSkipped: booleanValue(definition.is_skipped),
- }),
- );
- const priorities = objectList(priority.definitions).map(
- (definition, index) => ({
- value: stringValue(definition.value) ?? `priority-${index + 1}`,
- label:
- stringValue(definition.label) ??
- humanize(stringValue(definition.value) ?? `Priority ${index + 1}`),
- color: stringValue(definition.color) ?? "#64748b",
- ...(stringValue(definition.icon)
- ? { icon: stringValue(definition.icon) }
- : {}),
- weight: numberValue(definition.weight) ?? index,
- }),
- );
- const configuration: TaskTypeConfiguration = {
- typeName: implementation.typeName,
- fields: { ...implementation.fields },
- statuses,
- priorities,
- defaultStatus:
- stringValue(status.default) ?? statuses[0]?.value ?? "none",
- defaultPriority:
- stringValue(priority.default) ?? priorities[0]?.value ?? "none",
- ...(stringValue(archive.archived_tag)
- ? { archiveTag: stringValue(archive.archived_tag) }
- : {}),
- };
- return [implementation.typeName, configuration];
- }),
- );
-}
-
-export function plannerViews(
- documents: readonly SavedViewDocument[],
-): PlannerView[] {
- return documents.flatMap((document) =>
- document.views.flatMap((view) =>
- isPlannerPresentation(view)
- ? [
- {
- key: `${document.source.path}#${view.id}`,
- path: document.source.path,
- id: view.id,
- name: view.name,
- format: document.source.format,
- revision: document.source.revision,
- writable:
- document.source.writable &&
- document.source.format === "obsidian.base",
- options: plannerOptions(view.presentation?.options),
- },
- ]
- : [],
- ),
- );
-}
-
-function isPlannerPresentation(view: SavedNamedView): boolean {
- return (
- view.presentation?.type === PLANNER_VIEW_TYPE ||
- view.presentation?.type === "tasknotes.planner"
- );
-}
-
-function plannerOptions(value: unknown): PlannerViewOptions {
- const options = recordValue(value);
- return {
- ...(numberValue(options.zoom) === undefined
- ? {}
- : { zoom: numberValue(options.zoom) }),
- ...(stringValue(options.project)
- ? { project: stringValue(options.project) }
- : {}),
- ...(stringValue(options.status)
- ? { status: stringValue(options.status) }
- : {}),
- ...(stringValue(options.priority)
- ? { priority: stringValue(options.priority) }
- : {}),
- ...(typeof options.showCompleted === "boolean"
- ? { showCompleted: options.showCompleted }
- : {}),
- };
-}
-
-export function plannerViewDefinition(
- name: string,
- input: SavePlannerViewInput,
- taskTypes: Iterable,
-) {
- const configurations = [...taskTypes];
- const primary = configurations[0];
- const filters = plannerFilters(input, configurations);
- const order = primary
- ? ["title", "scheduled", "due", "status", "priority"].flatMap((role) =>
- primary.fields[role] ? [basesProperty(primary.fields[role])] : [],
- )
- : undefined;
- const sort = primary
- ? [
- sortDefinition(primary.fields.scheduled, "ASC"),
- sortDefinition(primary.fields.due, "ASC"),
- sortDefinition(primary.fields.priority, "DESC"),
- ].filter(Boolean)
- : undefined;
- return {
- views: [
- compact({
- type: PLANNER_VIEW_TYPE,
- name,
- filters,
- order,
- sort,
- options: compact({
- zoom: input.zoom,
- project: input.project === "all" ? undefined : input.project,
- status: input.status === "all" ? undefined : input.status,
- priority: input.priority === "all" ? undefined : input.priority,
- showCompleted: input.showCompleted ?? false,
- }),
- }),
- ],
- };
-}
-
-function plannerFilters(
- input: SavePlannerViewInput,
- taskTypes: readonly TaskTypeConfiguration[],
-): unknown {
- const groups = taskTypes.map((configuration) => {
- const statusField = noteProperty(configuration.fields.status);
- const conditions: string[] = [`${statusField}.isEmpty() == false`];
- if (input.status && input.status !== "all")
- conditions.push(`${statusField} == ${literal(input.status)}`);
- else if (!input.showCompleted)
- for (const status of configuration.statuses.filter(
- ({ isCompleted }) => isCompleted,
- ))
- conditions.push(`${statusField} != ${literal(status.value)}`);
- if (configuration.archiveTag)
- conditions.push(
- `file.hasTag(${literal(configuration.archiveTag)}) != true`,
- );
- if (input.priority && input.priority !== "all")
- conditions.push(
- `${noteProperty(configuration.fields.priority)} == ${literal(input.priority)}`,
- );
- if (input.project && input.project !== "all")
- conditions.push(
- `${noteProperty(configuration.fields.projects)}.contains(${literal(input.project)})`,
- );
- return conditions.length === 1 ? conditions[0] : { and: conditions };
- });
- if (!groups.length) return undefined;
- return groups.length === 1 ? groups[0] : { or: groups };
-}
-
-export function viewFromDefinition(
- path: string,
- format: string,
- revision: string,
- writable: boolean,
- view: Record,
-): PlannerView {
- const name = stringValue(view.name) ?? "Planner";
- const id = identifier(name, "view");
- return {
- key: `${path}#${id}`,
- path,
- id,
- name,
- format,
- revision,
- writable: writable && format === "obsidian.base",
- options: plannerOptions(view.options),
- };
-}
-
-export function aggregateStatuses(
- configurations: Iterable,
-): PlannerStatusOption[] {
- const values = new Map();
- for (const configuration of configurations)
- for (const option of configuration.statuses)
- if (!values.has(option.value)) values.set(option.value, option);
- return [...values.values()].sort(
- (left, right) =>
- left.order - right.order || left.label.localeCompare(right.label),
- );
-}
-
-export function aggregatePriorities(
- configurations: Iterable,
-): PlannerPriorityOption[] {
- const values = new Map();
- for (const configuration of configurations)
- for (const option of configuration.priorities)
- if (!values.has(option.value)) values.set(option.value, option);
- return [...values.values()].sort(
- (left, right) =>
- right.weight - left.weight || left.label.localeCompare(right.label),
- );
-}
-
-export function modelStatuses(
- options: readonly PlannerStatusOption[],
-): StatusConfig[] {
- return options.map((option) => ({
- id: option.value,
- value: option.value,
- label: option.label,
- color: option.color,
- ...(option.icon ? { icon: option.icon } : {}),
- isCompleted: option.isCompleted,
- isSkipped: option.isSkipped,
- order: option.order,
- autoArchive: false,
- autoArchiveDelay: 0,
- }));
-}
-
-function canonicalValues(
- raw: JsonObject,
- fields: Record,
-): JsonObject {
- return Object.fromEntries(
- Object.entries(fields).flatMap(([role, field]) =>
- raw[field] === undefined ? [] : [[role, raw[field]]],
- ),
- ) as JsonObject;
-}
-
-export function plannerViewSourcePath(name: string): string {
- return `${PLANNER_VIEW_FOLDER}/${slug(name) || "planner"}.base`;
-}
-
-function slug(value: string): string {
- return value
- .trim()
- .toLocaleLowerCase()
- .normalize("NFKD")
- .replace(/\p{Mark}+/gu, "")
- .replace(/[^a-z0-9]+/g, "-")
- .replace(/^-+|-+$/g, "");
-}
-
-export function stableIds(names: string[]): string[] {
- const seen = new Map();
- return names.map((name) => {
- const base = identifier(name, "view");
- const count = (seen.get(base) ?? 0) + 1;
- seen.set(base, count);
- return count === 1 ? base : `${base}-${count}`;
- });
-}
-
-function identifier(value: string, fallback: string): string {
- const normalized = value
- .toLocaleLowerCase()
- .replace(/[^a-z0-9_.:]+/g, "-")
- .replace(/^-+|-+$/g, "");
- return /^[a-z]/.test(normalized) ? normalized : `${fallback}-${normalized}`;
-}
-
-function sortDefinition(field: string | undefined, direction: "ASC" | "DESC") {
- return field ? { property: basesProperty(field), direction } : undefined;
-}
-
-function noteProperty(field: string | undefined): string {
- return `note[${literal(field ?? "missing")}]`;
-}
-
-function basesProperty(field: string): string {
- return /^[A-Za-z_][A-Za-z0-9_-]*$/.test(field)
- ? `note.${field}`
- : noteProperty(field);
-}
-
-function literal(value: string): string {
- return JSON.stringify(value);
-}
-
-function compact>(value: T): T {
- return Object.fromEntries(
- Object.entries(value).filter(([, item]) => item !== undefined),
- ) as T;
-}
-
-function objectList(value: unknown): Array> {
- return Array.isArray(value) ? value.map(recordValue) : [];
-}
-
-function stringValue(value: unknown): string | undefined {
- return typeof value === "string" && value.trim() ? value : undefined;
-}
-
-function stringList(value: unknown): string[] {
- return Array.isArray(value)
- ? value.filter((entry): entry is string => typeof entry === "string")
- : typeof value === "string"
- ? [value]
- : [];
-}
-
-function recordValue(value: unknown): Record {
- return value && typeof value === "object" && !Array.isArray(value)
- ? (value as Record)
- : {};
-}
-
-function numberValue(value: unknown): number | undefined {
- return typeof value === "number" && Number.isFinite(value)
- ? value
- : undefined;
-}
-
-function booleanValue(value: unknown): boolean {
- return value === true;
-}
-
-function humanize(value: string): string {
- return value
- .replace(/[-_]+/g, " ")
- .replace(/\b\w/g, (letter) => letter.toLocaleUpperCase());
-}
-
-function basename(path: string): string {
- return path.split("/").at(-1)?.replace(/\.md$/i, "") ?? path;
-}
diff --git a/src/data/native-signin-ui.test.ts b/src/data/native-signin-ui.test.ts
deleted file mode 100644
index 4a1403e..0000000
--- a/src/data/native-signin-ui.test.ts
+++ /dev/null
@@ -1,252 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import type { AppWebSignInSnapshot } from "@mdbase-dev/sdk/app-host";
-import {
- openSignInPortal,
- createSignInPortalUi,
- SignInTabBlocked,
- signInCopy,
- signInPresentation,
- unavailableCollection,
-} from "./native-signin-ui";
-
-function snapshot(
- patch: Partial = {},
-): AppWebSignInSnapshot {
- return {
- status: "signed_out",
- signIn: null,
- collections: [],
- selectedCollectionId: null,
- problem: null,
- timing: null,
- ...patch,
- };
-}
-const paired: NonNullable = {
- requestId: "request",
- installationId: "installation",
- deviceId: "device",
- appId: "app",
- kind: "app-runtime",
- verificationUri: "https://mdbase.test/approve",
- state: "paired",
- accountId: "account",
- accountEmail: "writer@example.test",
- collectionIds: [],
- createCollections: false,
- requestedCreateCollections: false,
-};
-
-describe("native sign-in presentation, not an authentication engine", () => {
- it("uses canonical signed-out wording", () => {
- expect(signInPresentation(snapshot(), "TaskNotes Planner").message).toBe(
- "Sign in with your mdbase account to sync TaskNotes Planner across your devices.",
- );
- });
- it("renders SDK authorizing without introducing an app poller", () => {
- const view = signInPresentation(
- snapshot({ status: "authorizing" }),
- "Planner",
- );
- expect(view.message).toBe("Waiting for approval in the mdbase tab…");
- expect(view.waiting).toBe(true);
- });
- it("shows email only from a paired SDK view", () => {
- expect(
- signInPresentation(
- snapshot({ status: "unselected", signIn: paired }),
- "Planner",
- ).message,
- ).toBe("Signed in as writer@example.test");
- expect(
- signInPresentation(
- snapshot({
- status: "authorizing",
- signIn: { ...paired, state: "account_selected" },
- }),
- "Planner",
- ).identity,
- ).toBeNull();
- });
- it("does not invent an email or use account IDs as labels", () => {
- const view = signInPresentation(
- snapshot({
- status: "unselected",
- signIn: { ...paired, accountEmail: null },
- }),
- "Planner",
- );
- expect(view.identity).toBeNull();
- expect(view.message).toBe(signInCopy.unavailable);
- expect(view.message).not.toContain(paired.accountId);
- });
- it("uses actual selected collection metadata for opening", () => {
- const view = signInPresentation(
- snapshot({
- status: "opening",
- signIn: paired,
- selectedCollectionId: "selected",
- collections: [
- { collectionId: "selected", displayName: "Research", role: "owner" },
- ],
- }),
- "Planner",
- );
- expect(view.message).toBe("Signed in. Opening Research…");
- });
- it("refuses to invent a collection name from an identifier", () => {
- const view = signInPresentation(
- snapshot({ status: "opening", selectedCollectionId: "private-id" }),
- "Planner",
- );
- expect(view.message).not.toContain("private-id");
- });
- it("does not mistake a generic refusal for the user declining", () => {
- expect(
- signInPresentation(
- snapshot({ status: "blocked", problem: "refused" }),
- "Planner",
- ).message,
- ).toBe(signInCopy.interrupted);
- });
- it("does not imply saved/sync or reminders capability from authorization", () => {
- const view = signInPresentation(
- snapshot({ status: "unselected", signIn: paired }),
- "Planner",
- );
- expect(JSON.stringify(view)).not.toMatch(
- /saved on this device|reminders|Set up|Continue setup/,
- );
- expect(view.unavailable).toBe(true);
- });
- it("clears visible identity after SDK closure", () => {
- const view = signInPresentation(
- snapshot({ status: "closed", signIn: paired }),
- "Planner",
- );
- expect(view.paired).toBe(false);
- expect(view.identity).toBeNull();
- });
- it("has a distinct blocked-tab action without rendering raw errors", () => {
- const view = signInPresentation(snapshot(), "Planner", "tab_blocked");
- expect(view.message).toBe("Your browser blocked the sign-in tab.");
- expect(view.action).toBe("Open sign-in");
- });
- it("opens the production popup synchronously, keeps its opener and navigates the original handle", () => {
- const opener = {};
- const popup = {
- opener,
- closed: false,
- location: { href: "" },
- close: vi.fn(),
- };
- vi.stubGlobal("window", { open: vi.fn(() => popup) });
- try {
- const portal = openSignInPortal("planner-owned");
- expect(window.open).toHaveBeenCalledWith(
- "",
- "planner-owned",
- "popup,width=620,height=760",
- );
- expect(popup.opener).toBe(opener);
- portal.navigate("https://mdbase.test/approve");
- expect(popup.location.href).toBe("https://mdbase.test/approve");
- portal.close();
- portal.close();
- expect(popup.close).toHaveBeenCalledOnce();
- expect(() => portal.navigate("https://mdbase.test/late")).toThrow(
- "Sign-in window closed.",
- );
- } finally {
- vi.unstubAllGlobals();
- }
- });
- it("falls back to a tab with noopener semantics when popup opening fails", () => {
- const tab = {
- opener: {},
- closed: false,
- location: { href: "" },
- close: vi.fn(),
- };
- vi.stubGlobal("window", {
- open: vi
- .fn()
- .mockImplementationOnce(() => {
- throw new Error("popup blocked");
- })
- .mockReturnValue(tab),
- });
- try {
- const portal = openSignInPortal();
- expect(window.open).toHaveBeenLastCalledWith("", "_blank");
- expect(tab.opener).toBeNull();
- portal.navigate("https://mdbase.test/approve");
- expect(tab.location.href).toBe("https://mdbase.test/approve");
- } finally {
- vi.unstubAllGlobals();
- }
- });
- it("retains ownership after SDK handoff, reuses reopen and disposes only its original handle", () => {
- const popup = { closed: false, location: { href: "" }, close: vi.fn() };
- vi.stubGlobal("window", { open: vi.fn(() => popup) });
- try {
- const ui = createSignInPortalUi();
- ui.reserve();
- const original = ui.open();
- ui.reserve();
- expect(ui.open()).toBe(original);
- expect(window.open).toHaveBeenCalledOnce();
- ui.cancel();
- ui.cancel();
- expect(popup.close).toHaveBeenCalledOnce();
- } finally {
- vi.unstubAllGlobals();
- }
- });
- it("reopens a manually closed popup and isolates different UI owners' names", () => {
- const first = { closed: false, location: { href: "" }, close: vi.fn() };
- const second = { closed: false, location: { href: "" }, close: vi.fn() };
- const open = vi.fn().mockReturnValueOnce(first).mockReturnValue(second);
- vi.stubGlobal("window", { open });
- try {
- const ui = createSignInPortalUi();
- ui.reserve();
- const original = ui.open();
- first.closed = true;
- ui.reserve();
- expect(ui.open()).not.toBe(original);
- const other = createSignInPortalUi();
- other.reserve();
- expect(open.mock.calls[0]?.[1]).toBe(open.mock.calls[1]?.[1]);
- expect(open.mock.calls[2]?.[1]).not.toBe(open.mock.calls[0]?.[1]);
- } finally {
- vi.unstubAllGlobals();
- }
- });
- it("distinguishes an actually blocked tab", () => {
- vi.stubGlobal("window", { open: () => null });
- try {
- expect(openSignInPortal).toThrow(SignInTabBlocked);
- } finally {
- vi.unstubAllGlobals();
- }
- });
- it("the unavailable collection callback never manufactures a connection", async () => {
- // Only signal is observed; fake host/scope/collection data is not consumed.
- await expect(
- unavailableCollection({
- signal: new AbortController().signal,
- } as Parameters[0]),
- ).rejects.toThrow(signInCopy.unavailable);
- });
- it("preserves original cancellation before the unavailable boundary", async () => {
- const controller = new AbortController();
- const reason = new Error("original cancelled");
- controller.abort(reason);
- await expect(
- unavailableCollection({ signal: controller.signal } as Parameters<
- typeof unavailableCollection
- >[0]),
- ).rejects.toBe(reason);
- });
-});
diff --git a/src/data/native-signin-ui.ts b/src/data/native-signin-ui.ts
deleted file mode 100644
index d569e0c..0000000
--- a/src/data/native-signin-ui.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-import { AppWebSignInSession } from "@mdbase-dev/sdk/app-host";
-import type {
- AppWebSignInSessionOptions,
- AppWebSignInSnapshot,
-} from "@mdbase-dev/sdk/app-host";
-import { openConnectPopup, type ConnectPopup } from "./connect-popup";
-export { ConnectPopupBlocked as SignInTabBlocked } from "./connect-popup";
-
-export type NativeSignInSession = Pick<
- AppWebSignInSession,
- | "getSnapshot"
- | "subscribe"
- | "start"
- | "authorize"
- | "renewExpiredPairing"
- | "forget"
- | "close"
- | "setForeground"
->;
-
-export const signInCopy = Object.freeze({
- signIn: "Sign in",
- waiting: "Waiting for approval in the mdbase tab…",
- reopen: "Open the mdbase tab again",
- tabBlocked: "Your browser blocked the sign-in tab.",
- openSignIn: "Open sign-in",
- interrupted: "Sign-in was interrupted. Please try again.",
- signOut: "Sign out",
- unavailable: "Opening collections isn't available in this build yet.",
-});
-
-/** UI-only public portal capability. The SDK supplies the validated URI. */
-export const openSignInPortal = openConnectPopup;
-
-// Popup names are UI ownership identities, never native IDs or namespaces.
-let nextPortalKey = 0;
-
-/** Keep the owned handle after SDK handoff, including when it was closed by hand. */
-export function createSignInPortalUi() {
- const name = `tasknotes-planner-connect-${++nextPortalKey}`;
- let active: ConnectPopup | null = null;
- return {
- reserve() {
- if (!active || active.closed) active = openSignInPortal(name);
- },
- open() {
- if (!active || active.closed) active = openSignInPortal(name);
- return active;
- },
- cancel() {
- const original = active;
- active = null;
- original?.close();
- },
- };
-}
-
-// React presentation identity only, never a native actor/record/mutation ID.
-let nextViewKey = 0;
-
-export interface NativeSignInBinding {
- session: NativeSignInSession;
- portal: ReturnType;
- /** UI-only React identity. */
- viewKey: number;
-}
-
-/** Only a public SDK constructor and UI portal; no collection/SQL factory. */
-export function createNativeSignIn(
- options: Omit<
- AppWebSignInSessionOptions,
- "openPortal" | "openCollection" | "autoSelect"
- >,
-): NativeSignInBinding {
- options.signal.throwIfAborted();
- const portal = createSignInPortalUi();
- const session = new AppWebSignInSession({
- ...options,
- openPortal: portal.open,
- openCollection: unavailableCollection,
- autoSelect: "never",
- });
- return { session, portal, viewKey: ++nextViewKey };
-}
-
-/** Reserve in the user gesture BEFORE any asynchronous SDK renewal/start. */
-export async function authorizeNativeSignIn(
- session: NativeSignInSession,
- portal: ReturnType,
-) {
- portal.reserve();
- try {
- if (session.getSnapshot().status === "not_started") await session.start();
- if (session.getSnapshot().problem === "expired")
- await session.renewExpiredPairing();
- return await session.authorize();
- } catch (error) {
- portal.cancel();
- throw error;
- }
-}
-
-/** Explicit pre-factory boundary, not a pretend collection or setup result. */
-export const unavailableCollection: AppWebSignInSessionOptions["openCollection"] =
- async ({ signal }) => {
- signal.throwIfAborted();
- throw new Error(signInCopy.unavailable);
- };
-
-export function signInPresentation(
- snapshot: AppWebSignInSnapshot,
- appName: string,
- problem: "tab_blocked" | "interrupted" | null = null,
-) {
- const paired =
- snapshot.status !== "closed" && snapshot.signIn?.state === "paired";
- const email = paired ? snapshot.signIn?.accountEmail : null;
- const identity = email ? `Signed in as ${email}` : null;
- const selected = snapshot.collections.find(
- ({ collectionId }) => collectionId === snapshot.selectedCollectionId,
- );
- const waiting = snapshot.status === "authorizing" && problem === null;
- const interrupted =
- problem === "interrupted" || snapshot.status === "blocked";
- const message =
- problem === "tab_blocked"
- ? signInCopy.tabBlocked
- : interrupted
- ? signInCopy.interrupted
- : waiting
- ? signInCopy.waiting
- : snapshot.status === "opening" && selected
- ? `Signed in. Opening ${selected.displayName}…`
- : (identity ??
- (paired
- ? signInCopy.unavailable
- : `Sign in with your mdbase account to sync ${appName} across your devices.`));
- return {
- message,
- identity: identity === message ? null : identity,
- waiting,
- paired,
- unavailable: paired && message !== signInCopy.unavailable,
- error: problem !== null || snapshot.status === "blocked",
- action:
- problem === "tab_blocked" ? signInCopy.openSignIn : signInCopy.signIn,
- disabled: ["starting", "opening", "closed"].includes(snapshot.status),
- };
-}
diff --git a/src/data/next-planner-repository.ts b/src/data/next-planner-repository.ts
index dffd8a2..8e8608c 100644
--- a/src/data/next-planner-repository.ts
+++ b/src/data/next-planner-repository.ts
@@ -21,7 +21,6 @@ import { stringify } from "yaml";
import { parseFrontmatter } from "@tasknotes/model/frontmatter";
import { taskCreationFields, taskCreationPath } from "./task-creation";
import { isOperationOutcomeUnknown } from "./outcome";
-import type { JsonObject } from "@mdbase-dev/connect";
import type {
PlannerCollection,
PlannerRepository,
@@ -41,7 +40,7 @@ import {
plannerViewDefinition,
taskTypeConfigurations,
type TaskTypeConfiguration,
-} from "./mdbase-repository";
+} from "./planner-mapping";
import {
NextPlannerViews,
type NextPlannerViewAccess,
@@ -235,7 +234,7 @@ export class NextPlannerRepository implements PlannerRepository {
? []
: [[field, next[field] ?? null]],
),
- ) as JsonObject;
+ ) as Record;
});
}
@@ -355,7 +354,7 @@ export class NextPlannerRepository implements PlannerRepository {
patch: (
record: wire.RecordView,
config: TaskTypeConfiguration,
- ) => JsonObject,
+ ) => Record,
): Promise {
const signal = this.signal();
await this.revalidateConfigurations(signal);
@@ -554,7 +553,7 @@ function configurationSignature(
function canonical(
record: wire.RecordView,
config: TaskTypeConfiguration,
-): JsonObject {
+): Record {
const raw = Object.fromEntries(
[...(record.effective ?? record.frontmatter)].map(([key, value]) => [
key,
@@ -565,7 +564,7 @@ function canonical(
Object.entries(config.fields).flatMap(([role, field]) =>
raw[field] === undefined ? [] : [[role, raw[field]]],
),
- ) as JsonObject;
+ ) as Record;
}
function task(
@@ -581,7 +580,7 @@ function task(
key,
toPlain(value),
]),
- ) as JsonObject,
+ ) as Record,
},
configurations,
);
diff --git a/src/data/outcome.test.ts b/src/data/outcome.test.ts
index cdb5b8f..de9e170 100644
--- a/src/data/outcome.test.ts
+++ b/src/data/outcome.test.ts
@@ -1,41 +1,21 @@
-import {
- MdbaseConnectError,
- type ConnectOutcome,
- type ConnectProblem,
-} from "@mdbase-dev/connect";
+import { MdbaseError } from "@mdbase-dev/sdk";
-import {
- errorMessage,
- isOperationOutcomeUnknown,
- requireOutcome,
-} from "./outcome";
+import { errorMessage, isOperationOutcomeUnknown } from "./outcome";
-describe("Connect outcomes", () => {
- it("preserves structured unknown mutation outcomes", () => {
- const problem = {
- problem_version: 1,
- code: "operation_outcome_unknown",
- category: "transport",
- recovery: "recover_mutation",
- message: "The task update may already have completed.",
- operation_outcome: "unknown",
- details: { request_id: "request-1" },
- } as unknown as ConnectProblem;
- const outcome = {
- ok: false,
- problem,
- } as ConnectOutcome;
-
- let thrown: unknown;
- try {
- requireOutcome(outcome);
- } catch (reason) {
- thrown = reason;
- }
-
- expect(thrown).toBeInstanceOf(MdbaseConnectError);
- expect((thrown as MdbaseConnectError).problem).toBe(problem);
- expect(isOperationOutcomeUnknown(thrown)).toBe(true);
+describe("mdbase outcomes", () => {
+ it("recognises unknown write outcomes", () => {
+ expect(
+ isOperationOutcomeUnknown(
+ new MdbaseError({
+ code: "outcome_unknown",
+ recovery: "refresh",
+ message: "The task update may already have completed.",
+ }),
+ ),
+ ).toBe(true);
+ expect(isOperationOutcomeUnknown(new Error("Title is required"))).toBe(
+ false,
+ );
});
it("shows person-facing copy, not replica text, for coded mdbase problems", () => {
diff --git a/src/data/outcome.ts b/src/data/outcome.ts
index d083775..65be203 100644
--- a/src/data/outcome.ts
+++ b/src/data/outcome.ts
@@ -1,30 +1,14 @@
-import {
- MdbaseConnectError,
- type ConnectOutcome,
- type ConnectProblemCode,
-} from "@mdbase-dev/connect";
import { describeError } from "@mdbase-dev/sdk";
-export function requireOutcome(
- outcome: ConnectOutcome,
-): Value {
- if (!outcome.ok) throw new MdbaseConnectError(outcome.problem);
- return outcome.value;
-}
-
export function isOperationOutcomeUnknown(reason: unknown): boolean {
- return (
- (reason instanceof MdbaseConnectError &&
- reason.problem.operation_outcome === "unknown") ||
- describeError(reason)?.code === "outcome_unknown"
- );
+ return describeError(reason)?.code === "outcome_unknown";
}
/** Person-facing copy. Coded mdbase problems use the SDK's copy and keep their
* technical text in the console; the app's own errors keep their message. */
export function errorMessage(reason: unknown): string {
const description = describeError(reason);
- // Codes without SDK copy keep the problem's own message (Connect problems carry one).
+ // Codes without SDK copy keep the problem's own message.
if (description && description.key !== "unknown") {
console.warn(
"mdbase problem:",
diff --git a/src/data/planner-mapping.ts b/src/data/planner-mapping.ts
new file mode 100644
index 0000000..9ebb9ca
--- /dev/null
+++ b/src/data/planner-mapping.ts
@@ -0,0 +1,323 @@
+import type { PlainValue } from "@mdbase-dev/sdk";
+import { normalizeDependencyList } from "@tasknotes/model/mapping";
+import type { StatusConfig } from "@tasknotes/model";
+
+import type {
+ PlannerPriorityOption,
+ PlannerStatusOption,
+ PlannerTask,
+ SavePlannerViewInput,
+} from "../domain/task";
+
+const PLANNER_VIEW_TYPE = "tasknotesPlanner";
+
+export interface TaskTypeConfiguration {
+ typeName: string;
+ fields: Record;
+ statuses: PlannerStatusOption[];
+ priorities: PlannerPriorityOption[];
+ defaultStatus: string;
+ defaultPriority: string;
+ archiveTag?: string;
+}
+
+export interface PlannerRecord {
+ path: string;
+ types: readonly string[];
+ frontmatter: Record;
+}
+
+/** Shared portable task mapping; native SDK callers supply unprojected fields. */
+export function plannerTaskFromRecord(
+ record: PlannerRecord,
+ configurations: ReadonlyMap,
+): PlannerTask {
+ const providerType = record.types.find((type) => configurations.has(type));
+ const configuration = providerType
+ ? configurations.get(providerType)
+ : undefined;
+ if (!configuration)
+ throw new Error(`No TaskNotes binding was found for ${record.path}.`);
+ const values = canonicalValues(record.frontmatter, configuration.fields);
+ const title = stringValue(values.title) ?? basename(record.path);
+ const status = stringValue(values.status) ?? configuration.defaultStatus;
+ const priority =
+ stringValue(values.priority) ?? configuration.defaultPriority;
+ const statusDefinition = configuration.statuses.find(
+ ({ value }) => value === status,
+ );
+ const priorityDefinition = configuration.priorities.find(
+ ({ value }) => value === priority,
+ );
+ return {
+ id: stringValue(values.id) ?? record.path,
+ path: record.path,
+ title,
+ status,
+ priority,
+ scheduled: stringValue(values.scheduled),
+ due: stringValue(values.due),
+ projects: stringList(values.projects),
+ blockedBy: normalizeDependencyList(values.blockedBy) ?? [],
+ completed: statusDefinition?.isCompleted ?? false,
+ recurrence: stringValue(values.recurrence),
+ providerType,
+ statusLabel: statusDefinition?.label ?? status,
+ statusColor: statusDefinition?.color ?? "#64748b",
+ priorityLabel: priorityDefinition?.label ?? priority,
+ priorityColor: priorityDefinition?.color ?? "#64748b",
+ statusOptions: configuration.statuses,
+ priorityOptions: configuration.priorities,
+ };
+}
+
+export function taskTypeConfigurations(descriptor: {
+ implementations: readonly {
+ typeName: string;
+ fields: Record;
+ binding?: unknown;
+ }[];
+}): Map {
+ return new Map(
+ descriptor.implementations.map((implementation) => {
+ const binding = recordValue(implementation.binding);
+ const status = recordValue(binding.status);
+ const priority = recordValue(binding.priority);
+ const archive = recordValue(binding.archive);
+ const statuses = objectList(status.definitions).map(
+ (definition, index) => ({
+ value: stringValue(definition.value) ?? `status-${index + 1}`,
+ label:
+ stringValue(definition.label) ??
+ humanize(stringValue(definition.value) ?? `Status ${index + 1}`),
+ color: stringValue(definition.color) ?? "#64748b",
+ ...(stringValue(definition.icon)
+ ? { icon: stringValue(definition.icon) }
+ : {}),
+ order: numberValue(definition.order) ?? index,
+ isCompleted: booleanValue(definition.is_completed),
+ isSkipped: booleanValue(definition.is_skipped),
+ }),
+ );
+ const priorities = objectList(priority.definitions).map(
+ (definition, index) => ({
+ value: stringValue(definition.value) ?? `priority-${index + 1}`,
+ label:
+ stringValue(definition.label) ??
+ humanize(stringValue(definition.value) ?? `Priority ${index + 1}`),
+ color: stringValue(definition.color) ?? "#64748b",
+ ...(stringValue(definition.icon)
+ ? { icon: stringValue(definition.icon) }
+ : {}),
+ weight: numberValue(definition.weight) ?? index,
+ }),
+ );
+ const configuration: TaskTypeConfiguration = {
+ typeName: implementation.typeName,
+ fields: { ...implementation.fields },
+ statuses,
+ priorities,
+ defaultStatus:
+ stringValue(status.default) ?? statuses[0]?.value ?? "none",
+ defaultPriority:
+ stringValue(priority.default) ?? priorities[0]?.value ?? "none",
+ ...(stringValue(archive.archived_tag)
+ ? { archiveTag: stringValue(archive.archived_tag) }
+ : {}),
+ };
+ return [implementation.typeName, configuration];
+ }),
+ );
+}
+
+export function plannerViewDefinition(
+ name: string,
+ input: SavePlannerViewInput,
+ taskTypes: Iterable,
+) {
+ const configurations = [...taskTypes];
+ const primary = configurations[0];
+ const filters = plannerFilters(input, configurations);
+ const order = primary
+ ? ["title", "scheduled", "due", "status", "priority"].flatMap((role) =>
+ primary.fields[role] ? [basesProperty(primary.fields[role])] : [],
+ )
+ : undefined;
+ const sort = primary
+ ? [
+ sortDefinition(primary.fields.scheduled, "ASC"),
+ sortDefinition(primary.fields.due, "ASC"),
+ sortDefinition(primary.fields.priority, "DESC"),
+ ].filter(Boolean)
+ : undefined;
+ return {
+ views: [
+ compact({
+ type: PLANNER_VIEW_TYPE,
+ name,
+ filters,
+ order,
+ sort,
+ options: compact({
+ zoom: input.zoom,
+ project: input.project === "all" ? undefined : input.project,
+ status: input.status === "all" ? undefined : input.status,
+ priority: input.priority === "all" ? undefined : input.priority,
+ showCompleted: input.showCompleted ?? false,
+ }),
+ }),
+ ],
+ };
+}
+
+function plannerFilters(
+ input: SavePlannerViewInput,
+ taskTypes: readonly TaskTypeConfiguration[],
+): unknown {
+ const groups = taskTypes.map((configuration) => {
+ const statusField = noteProperty(configuration.fields.status);
+ const conditions: string[] = [`${statusField}.isEmpty() == false`];
+ if (input.status && input.status !== "all")
+ conditions.push(`${statusField} == ${literal(input.status)}`);
+ else if (!input.showCompleted)
+ for (const status of configuration.statuses.filter(
+ ({ isCompleted }) => isCompleted,
+ ))
+ conditions.push(`${statusField} != ${literal(status.value)}`);
+ if (configuration.archiveTag)
+ conditions.push(
+ `file.hasTag(${literal(configuration.archiveTag)}) != true`,
+ );
+ if (input.priority && input.priority !== "all")
+ conditions.push(
+ `${noteProperty(configuration.fields.priority)} == ${literal(input.priority)}`,
+ );
+ if (input.project && input.project !== "all")
+ conditions.push(
+ `${noteProperty(configuration.fields.projects)}.contains(${literal(input.project)})`,
+ );
+ return conditions.length === 1 ? conditions[0] : { and: conditions };
+ });
+ if (!groups.length) return undefined;
+ return groups.length === 1 ? groups[0] : { or: groups };
+}
+
+export function aggregateStatuses(
+ configurations: Iterable,
+): PlannerStatusOption[] {
+ const values = new Map();
+ for (const configuration of configurations)
+ for (const option of configuration.statuses)
+ if (!values.has(option.value)) values.set(option.value, option);
+ return [...values.values()].sort(
+ (left, right) =>
+ left.order - right.order || left.label.localeCompare(right.label),
+ );
+}
+
+export function aggregatePriorities(
+ configurations: Iterable,
+): PlannerPriorityOption[] {
+ const values = new Map();
+ for (const configuration of configurations)
+ for (const option of configuration.priorities)
+ if (!values.has(option.value)) values.set(option.value, option);
+ return [...values.values()].sort(
+ (left, right) =>
+ right.weight - left.weight || left.label.localeCompare(right.label),
+ );
+}
+
+export function modelStatuses(
+ options: readonly PlannerStatusOption[],
+): StatusConfig[] {
+ return options.map((option) => ({
+ id: option.value,
+ value: option.value,
+ label: option.label,
+ color: option.color,
+ ...(option.icon ? { icon: option.icon } : {}),
+ isCompleted: option.isCompleted,
+ isSkipped: option.isSkipped,
+ order: option.order,
+ autoArchive: false,
+ autoArchiveDelay: 0,
+ }));
+}
+
+function canonicalValues(
+ raw: Record,
+ fields: Record,
+): Record {
+ return Object.fromEntries(
+ Object.entries(fields).flatMap(([role, field]) =>
+ raw[field] === undefined ? [] : [[role, raw[field]]],
+ ),
+ );
+}
+
+function sortDefinition(field: string | undefined, direction: "ASC" | "DESC") {
+ return field ? { property: basesProperty(field), direction } : undefined;
+}
+
+function noteProperty(field: string | undefined): string {
+ return `note[${literal(field ?? "missing")}]`;
+}
+
+function basesProperty(field: string): string {
+ return /^[A-Za-z_][A-Za-z0-9_-]*$/.test(field)
+ ? `note.${field}`
+ : noteProperty(field);
+}
+
+function literal(value: string): string {
+ return JSON.stringify(value);
+}
+
+function compact>(value: T): T {
+ return Object.fromEntries(
+ Object.entries(value).filter(([, item]) => item !== undefined),
+ ) as T;
+}
+
+function objectList(value: unknown): Array> {
+ return Array.isArray(value) ? value.map(recordValue) : [];
+}
+
+function stringValue(value: unknown): string | undefined {
+ return typeof value === "string" && value.trim() ? value : undefined;
+}
+
+function stringList(value: unknown): string[] {
+ return Array.isArray(value)
+ ? value.filter((entry): entry is string => typeof entry === "string")
+ : typeof value === "string"
+ ? [value]
+ : [];
+}
+
+function recordValue(value: unknown): Record {
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : {};
+}
+
+function numberValue(value: unknown): number | undefined {
+ return typeof value === "number" && Number.isFinite(value)
+ ? value
+ : undefined;
+}
+
+function booleanValue(value: unknown): boolean {
+ return value === true;
+}
+
+function humanize(value: string): string {
+ return value
+ .replace(/[-_]+/g, " ")
+ .replace(/\b\w/g, (letter) => letter.toLocaleUpperCase());
+}
+
+function basename(path: string): string {
+ return path.split("/").at(-1)?.replace(/\.md$/i, "") ?? path;
+}
diff --git a/src/data/sdk-application.test.ts b/src/data/sdk-application.test.ts
index a67d01e..91cfb5b 100644
--- a/src/data/sdk-application.test.ts
+++ b/src/data/sdk-application.test.ts
@@ -1,7 +1,7 @@
import { afterEach, expect, it, vi } from "vitest";
import { createMdbase } from "@mdbase-dev/sdk";
import manifest from "../generated-mdbase-app.json";
-import { plannerDeclaration } from "./sdk-application";
+import { plannerDeclaration, plannerEnvironment } from "./sdk-application";
afterEach(() => vi.restoreAllMocks());
@@ -20,3 +20,11 @@ it("declares the canonical manifest and opens nothing on construction", () => {
expect(fetch).not.toHaveBeenCalled();
expect(open).not.toHaveBeenCalled();
});
+
+it("selects the build environment, defaulting to production", () => {
+ expect(plannerEnvironment(undefined)).toBe("production");
+ expect(plannerEnvironment("")).toBe("production");
+ expect(plannerEnvironment("staging")).toBe("staging");
+ expect(plannerEnvironment("lab")).toBe("lab");
+ expect(() => plannerEnvironment("local")).toThrow(/VITE_MDBASE_ENV/);
+});
diff --git a/src/data/sdk-application.ts b/src/data/sdk-application.ts
index 719cbee..4ff8e48 100644
--- a/src/data/sdk-application.ts
+++ b/src/data/sdk-application.ts
@@ -5,11 +5,23 @@ import {
} from "@mdbase-dev/sdk";
import manifest from "../generated-mdbase-app.json";
+type PlannerEnvironment = MdbaseApplicationDeclaration["environment"];
+
+/** The build's mdbase environment. Unset builds are production; LAB and
+ * staging deployments set `VITE_MDBASE_ENV` explicitly. */
+export function plannerEnvironment(
+ configured: string | undefined = import.meta.env.VITE_MDBASE_ENV,
+): Exclude {
+ if (!configured) return "production";
+ if (configured === "lab" || configured === "staging") return configured;
+ throw new Error(`Unsupported VITE_MDBASE_ENV: ${configured}`);
+}
+
/** Declaration only: the unchanged canonical manifest (logical ID, name, homepage
* origin) and the deployment environment. The SDK registers the manifest and
* rebinds to the actual registered application ID before any storage. */
export function plannerDeclaration(
- environment: MdbaseApplicationDeclaration["environment"],
+ environment: PlannerEnvironment,
): MdbaseApplicationDeclaration {
return {
id: manifest.id,
@@ -22,9 +34,11 @@ export function plannerDeclaration(
let entry: Mdbase | null = null;
-/** The page's one SDK entry (LAB builds only, see root.tsx). Construction
- * performs no IO; the SDK follows page visibility itself. */
+/** The page's one SDK entry. Construction performs no IO; the SDK follows
+ * page visibility itself. */
export function plannerMdbase(): Mdbase {
- entry ??= createMdbase(plannerDeclaration("lab"), { followVisibility: true });
+ entry ??= createMdbase(plannerDeclaration(plannerEnvironment()), {
+ followVisibility: true,
+ });
return entry;
}
diff --git a/src/data/task-creation.ts b/src/data/task-creation.ts
index e77c583..172c8a4 100644
--- a/src/data/task-creation.ts
+++ b/src/data/task-creation.ts
@@ -1,8 +1,8 @@
import { taskInfoToSpecFields } from "@tasknotes/model/operations";
import { validateTask } from "@tasknotes/model/validation";
import { getCurrentDateString } from "@tasknotes/model/date";
-import type { JsonObject } from "@mdbase-dev/connect";
-import type { TaskTypeConfiguration } from "./mdbase-repository";
+import type { PlainValue } from "@mdbase-dev/sdk";
+import type { TaskTypeConfiguration } from "./planner-mapping";
/** The same portable fields TaskNotes writes; the SDK maps contract roles to
* the selected implementing type. Never mint a Planner-specific task type. */
@@ -10,7 +10,7 @@ export function taskCreationFields(
title: string,
id: string,
config: TaskTypeConfiguration,
-): JsonObject {
+): Record {
const now = new Date().toISOString();
const task = {
title: title.trim(),
@@ -34,7 +34,7 @@ export function taskCreationFields(
Object.entries({ id, ...taskInfoToSpecFields(task) }).filter(
([role]) => config.fields[role],
),
- ) as JsonObject;
+ ) as Record;
}
/** Native path patterns belong to mdbase. TaskNotes runtime templates do not:
diff --git a/src/data/tasknotes-handoff.ts b/src/data/tasknotes-handoff.ts
index bc3b27e..46bc647 100644
--- a/src/data/tasknotes-handoff.ts
+++ b/src/data/tasknotes-handoff.ts
@@ -1,17 +1,18 @@
import { mdbaseAppHref } from "@mdbase-dev/ui/apps";
+import { plannerEnvironment } from "./sdk-application";
+
+const taskNotesOrigins = {
+ lab: "https://lab.tasknotes-app.pages.dev",
+ staging: "https://staging.tasknotes-app.pages.dev",
+ production: "https://app.tasknotes.dev",
+} as const;
+
/** Open the loaded collection, not a stale URL selection or a demo/view URL.
* TaskNotes still owns its own sign-in, approval and task creation. */
export function taskNotesHref(collectionId: string): string {
- const origin =
- import.meta.env.VITE_MDBASE_ENV === "lab"
- ? "https://lab.tasknotes-app.pages.dev"
- : import.meta.env.VITE_MDBASE_CONNECT_URL ===
- "https://connect-staging.mdbase.dev"
- ? "https://staging.tasknotes-app.pages.dev"
- : "https://app.tasknotes.dev";
return mdbaseAppHref(
- origin,
+ taskNotesOrigins[plannerEnvironment()],
`${location.origin}/?collection=${encodeURIComponent(collectionId)}`,
);
}
diff --git a/src/domain/task.ts b/src/domain/task.ts
index b2eebef..6dcb4a9 100644
--- a/src/domain/task.ts
+++ b/src/domain/task.ts
@@ -91,12 +91,6 @@ export interface TaskPropertyUpdate {
projects?: string[];
}
-export interface PendingPlannerMutation {
- requestId: string;
- operation: string;
- createdAt: string;
-}
-
/** A write the collection refused after it was saved on this device. */
export interface RejectedPlannerWrite {
id: string;
@@ -121,8 +115,6 @@ export interface PlannerRepository {
): Promise;
toggleCompletion(task: PlannerTask): Promise;
saveView(input: SavePlannerViewInput): Promise;
- pendingMutations?(): readonly PendingPlannerMutation[];
- recoverPendingMutation?(requestId: string): Promise;
rejectedWrites?(): readonly RejectedPlannerWrite[];
dismissRejectedWrite?(id: string): Promise;
/** True while changes are kept on this device because the collection
diff --git a/src/grant-return.test.tsx b/src/grant-return.test.tsx
index 68657d6..bf34ee7 100644
--- a/src/grant-return.test.tsx
+++ b/src/grant-return.test.tsx
@@ -4,8 +4,8 @@ const sdk = vi.hoisted(() => ({ plannerMdbase: vi.fn() }));
vi.mock("./data/sdk-application", () => ({
plannerMdbase: sdk.plannerMdbase,
}));
-vi.mock("./components/root", () => ({
- Root: () => {
+vi.mock("./components/sdk-app", () => ({
+ default: () => {
sdk.plannerMdbase();
return planner
;
},
@@ -41,10 +41,10 @@ describe("Private collection consent popup return", () => {
expect(deliverAppGrantCallback).toHaveBeenCalledWith(callback);
});
- it("in LAB, delivers without self-closing or starting another SDK entry", async () => {
+ it("delivers without self-closing or starting another SDK entry", async () => {
const deliver = vi.fn(async () => {});
const close = vi.spyOn(window, "close");
- render(<>{pageFor(callback, "lab", deliver)}>);
+ render(<>{pageFor(callback, deliver)}>);
expect(screen.getByText("You can close this window.")).toBeTruthy();
expect(deliver).toHaveBeenCalledWith(callback);
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -53,15 +53,10 @@ describe("Private collection consent popup return", () => {
close.mockRestore();
});
- it("renders the app for any other page, and leaves Connect's callback to Connect", () => {
+ it("renders the app for any other page", () => {
const deliver = vi.fn(async () => {});
- render(
- <>
- {pageFor("http://127.0.0.1:48221/", "lab", deliver)}
- {pageFor(callback, "production", deliver)}
- >,
- );
- expect(screen.getAllByText("planner")).toHaveLength(2);
+ render(<>{pageFor("http://127.0.0.1:48221/", deliver)}>);
+ expect(screen.getByText("planner")).toBeTruthy();
expect(deliver).not.toHaveBeenCalled();
});
});
diff --git a/src/grant-return.tsx b/src/grant-return.tsx
index 5c5512b..fa517ca 100644
--- a/src/grant-return.tsx
+++ b/src/grant-return.tsx
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
-import { Root } from "./components/root";
+import SdkApp from "./components/sdk-app";
type AppGrantModule = Pick<
typeof import("@mdbase-dev/sdk/app-grant"),
@@ -31,16 +31,14 @@ export async function deliverAppGrantReturn(
(await load()).deliverAppGrantCallback(href);
}
-/** What the page renders. In LAB builds (the SDK entry) a consent popup return
- * delivers its result without starting a second SDK entry. The original SDK
- * owns close only after Native finish and original custody ACK.
- * Other builds keep Connect, which owns its own callback on the same route. */
+/** What the page renders. A consent popup return delivers its result without
+ * starting a second SDK entry; the original SDK owns close only after Native
+ * finish and original custody ACK. */
export function pageFor(
href: string,
- environment: string | undefined,
deliver: (href: string) => Promise = deliverAppGrantReturn,
): ReactNode {
- if (environment !== "lab" || !isAppGrantCallback(href)) return ;
+ if (!isAppGrantCallback(href)) return ;
void deliver(href).catch(() => {});
return You can close this window.
;
}
diff --git a/src/main.tsx b/src/main.tsx
index 6b2ad26..97896ac 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -9,7 +9,7 @@ import "./styles.css";
// Decided once, before anything renders: a consent popup return never starts
// the SDK entry.
-const page = pageFor(location.href, import.meta.env.VITE_MDBASE_ENV);
+const page = pageFor(location.href);
createRoot(document.getElementById("root")!).render(
{page} ,
diff --git a/vendor/mdbase-connect-sdk.json b/vendor/mdbase-connect-sdk.json
deleted file mode 100644
index b4e1e51..0000000
--- a/vendor/mdbase-connect-sdk.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "schema_version": 1,
- "repository": "https://github.com/mdbase-dev/mdbase-connect",
- "revision": "56ed32ffde055d2ab2b22ff95722df8ef06bdb1d",
- "artifacts": [
- {
- "package": "connect",
- "file": "mdbase-dev-connect-0.1.0-beta.96-56ed32ffde05.tgz",
- "bytes": 188313,
- "sha512": "7k2TL4IVu4IyWSs+1DuSDrY/Vw0vAHlpgL3Zq9R/Bb1I61TMwTTQ2p/aYx400FT69qQGO1CA6SHs7oHNroHh5w=="
- },
- {
- "package": "protocol",
- "file": "mdbase-dev-connect-protocol-0.1.0-beta.96-56ed32ffde05.tgz",
- "bytes": 61051,
- "sha512": "prz8clP0Vpu8HQ4ucr+ss4am2sLQpFTOjXBSRcaU8/RIdDM7tZurOYHP/y0LdDkViJ9pODfD6R82umpdvMQYhQ=="
- },
- {
- "package": "sync",
- "file": "mdbase-dev-connect-sync-0.1.0-beta.96-56ed32ffde05.tgz",
- "bytes": 100340,
- "sha512": "4NsAGAFsWzMx7DzEzQX2vc0KyE01S9bWSSvGkrCB56lew7cOHz50FJebPglfvbs4Z7mIIsu6Cja6A/4Le1320g=="
- }
- ]
-}
diff --git a/vendor/mdbase-dev-connect-0.1.0-beta.96-56ed32ffde05.tgz b/vendor/mdbase-dev-connect-0.1.0-beta.96-56ed32ffde05.tgz
deleted file mode 100644
index 070bdae..0000000
Binary files a/vendor/mdbase-dev-connect-0.1.0-beta.96-56ed32ffde05.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-connect-protocol-0.1.0-beta.129-d765e675c3da.tgz b/vendor/mdbase-dev-connect-protocol-0.1.0-beta.129-d765e675c3da.tgz
deleted file mode 100644
index 04dddac..0000000
Binary files a/vendor/mdbase-dev-connect-protocol-0.1.0-beta.129-d765e675c3da.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-connect-protocol-0.1.0-beta.96-56ed32ffde05.tgz b/vendor/mdbase-dev-connect-protocol-0.1.0-beta.96-56ed32ffde05.tgz
deleted file mode 100644
index c0fec41..0000000
Binary files a/vendor/mdbase-dev-connect-protocol-0.1.0-beta.96-56ed32ffde05.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-connect-sync-0.1.0-beta.96-56ed32ffde05.tgz b/vendor/mdbase-dev-connect-sync-0.1.0-beta.96-56ed32ffde05.tgz
deleted file mode 100644
index f1c06be..0000000
Binary files a/vendor/mdbase-dev-connect-sync-0.1.0-beta.96-56ed32ffde05.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-0.0.0-b9965703.tgz b/vendor/mdbase-dev-sdk-0.0.0-b9965703.tgz
deleted file mode 100644
index f80136e..0000000
Binary files a/vendor/mdbase-dev-sdk-0.0.0-b9965703.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-38001079.tgz b/vendor/mdbase-dev-sdk-38001079.tgz
deleted file mode 100644
index 960b44b..0000000
Binary files a/vendor/mdbase-dev-sdk-38001079.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-013c7e56.tgz b/vendor/mdbase-dev-sdk-dev-013c7e56.tgz
deleted file mode 100644
index 19c9d43..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-013c7e56.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-0aeaada6.tgz b/vendor/mdbase-dev-sdk-dev-0aeaada6.tgz
deleted file mode 100644
index 357a985..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-0aeaada6.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-0ecbef41.tgz b/vendor/mdbase-dev-sdk-dev-0ecbef41.tgz
deleted file mode 100644
index 103629d..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-0ecbef41.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-0f5724ef.tgz b/vendor/mdbase-dev-sdk-dev-0f5724ef.tgz
deleted file mode 100644
index 966cc49..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-0f5724ef.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-0f9a3614.tgz b/vendor/mdbase-dev-sdk-dev-0f9a3614.tgz
deleted file mode 100644
index 967fd5f..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-0f9a3614.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-106d36ff.tgz b/vendor/mdbase-dev-sdk-dev-106d36ff.tgz
deleted file mode 100644
index dd897e2..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-106d36ff.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-12c28ef1.tgz b/vendor/mdbase-dev-sdk-dev-12c28ef1.tgz
deleted file mode 100644
index 2f9e599..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-12c28ef1.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-1629d11e.tgz b/vendor/mdbase-dev-sdk-dev-1629d11e.tgz
deleted file mode 100644
index 8625271..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-1629d11e.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-1dddb04c.tgz b/vendor/mdbase-dev-sdk-dev-1dddb04c.tgz
deleted file mode 100644
index b79eb2c..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-1dddb04c.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-1f7b4f7a.tgz b/vendor/mdbase-dev-sdk-dev-1f7b4f7a.tgz
deleted file mode 100644
index 1ffc11b..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-1f7b4f7a.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-20c347ad.tgz b/vendor/mdbase-dev-sdk-dev-20c347ad.tgz
deleted file mode 100644
index 31f374b..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-20c347ad.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-23a10ee4.tgz b/vendor/mdbase-dev-sdk-dev-23a10ee4.tgz
deleted file mode 100644
index 8d7845c..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-23a10ee4.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-3491a619.tgz b/vendor/mdbase-dev-sdk-dev-3491a619.tgz
deleted file mode 100644
index 43b0ff6..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-3491a619.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-39ba2b8a.tgz b/vendor/mdbase-dev-sdk-dev-39ba2b8a.tgz
deleted file mode 100644
index 6ab8bda..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-39ba2b8a.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-3f417b6d.tgz b/vendor/mdbase-dev-sdk-dev-3f417b6d.tgz
deleted file mode 100644
index 0aa6f0f..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-3f417b6d.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-41c26ca7.tgz b/vendor/mdbase-dev-sdk-dev-41c26ca7.tgz
deleted file mode 100644
index a98028b..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-41c26ca7.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-47225957.tgz b/vendor/mdbase-dev-sdk-dev-47225957.tgz
deleted file mode 100644
index 5925651..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-47225957.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-4728a54c.tgz b/vendor/mdbase-dev-sdk-dev-4728a54c.tgz
deleted file mode 100644
index 35bb2d8..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-4728a54c.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-48b9f094.tgz b/vendor/mdbase-dev-sdk-dev-48b9f094.tgz
deleted file mode 100644
index ea28b23..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-48b9f094.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-4c10b9a5.tgz b/vendor/mdbase-dev-sdk-dev-4c10b9a5.tgz
deleted file mode 100644
index 1d03ae9..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-4c10b9a5.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-4c484d3c.tgz b/vendor/mdbase-dev-sdk-dev-4c484d3c.tgz
deleted file mode 100644
index 13624a7..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-4c484d3c.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-53643b29.tgz b/vendor/mdbase-dev-sdk-dev-53643b29.tgz
deleted file mode 100644
index 98e067f..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-53643b29.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-5710ab13.tgz b/vendor/mdbase-dev-sdk-dev-5710ab13.tgz
deleted file mode 100644
index 1f03646..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-5710ab13.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-5c7b9592.tgz b/vendor/mdbase-dev-sdk-dev-5c7b9592.tgz
deleted file mode 100644
index a28f59e..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-5c7b9592.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-5f2c3d5d.tgz b/vendor/mdbase-dev-sdk-dev-5f2c3d5d.tgz
deleted file mode 100644
index a4d5c04..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-5f2c3d5d.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-608bb0d5.tgz b/vendor/mdbase-dev-sdk-dev-608bb0d5.tgz
deleted file mode 100644
index fe3ccba..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-608bb0d5.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-6a576a5f.tgz b/vendor/mdbase-dev-sdk-dev-6a576a5f.tgz
deleted file mode 100644
index 9ff9507..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-6a576a5f.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-6f14ebe1.tgz b/vendor/mdbase-dev-sdk-dev-6f14ebe1.tgz
deleted file mode 100644
index d9aed32..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-6f14ebe1.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-6f2b1e51.tgz b/vendor/mdbase-dev-sdk-dev-6f2b1e51.tgz
deleted file mode 100644
index 56c5077..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-6f2b1e51.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-8b18a6c7.tgz b/vendor/mdbase-dev-sdk-dev-8b18a6c7.tgz
deleted file mode 100644
index b962f78..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-8b18a6c7.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-8bc12525.tgz b/vendor/mdbase-dev-sdk-dev-8bc12525.tgz
deleted file mode 100644
index 4fcf283..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-8bc12525.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-a8fdbdce.tgz b/vendor/mdbase-dev-sdk-dev-a8fdbdce.tgz
deleted file mode 100644
index cc7b973..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-a8fdbdce.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-a965bd26.tgz b/vendor/mdbase-dev-sdk-dev-a965bd26.tgz
deleted file mode 100644
index 791b073..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-a965bd26.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-abc08f3e.tgz b/vendor/mdbase-dev-sdk-dev-abc08f3e.tgz
deleted file mode 100644
index cf41dac..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-abc08f3e.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-ac3bff98.tgz b/vendor/mdbase-dev-sdk-dev-ac3bff98.tgz
deleted file mode 100644
index 7396ddb..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-ac3bff98.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-af29edc9.tgz b/vendor/mdbase-dev-sdk-dev-af29edc9.tgz
deleted file mode 100644
index a4cf592..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-af29edc9.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-b1c47a32.tgz b/vendor/mdbase-dev-sdk-dev-b1c47a32.tgz
deleted file mode 100644
index 0440e1b..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-b1c47a32.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-bef3fe45.tgz b/vendor/mdbase-dev-sdk-dev-bef3fe45.tgz
deleted file mode 100644
index 9be0c21..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-bef3fe45.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-ca8f0682.tgz b/vendor/mdbase-dev-sdk-dev-ca8f0682.tgz
deleted file mode 100644
index e52ddf5..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-ca8f0682.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-cf294bdb.tgz b/vendor/mdbase-dev-sdk-dev-cf294bdb.tgz
deleted file mode 100644
index 825e955..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-cf294bdb.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-d230034f.tgz b/vendor/mdbase-dev-sdk-dev-d230034f.tgz
deleted file mode 100644
index 7912263..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-d230034f.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-e00c1ff8.tgz b/vendor/mdbase-dev-sdk-dev-e00c1ff8.tgz
deleted file mode 100644
index d4ef93b..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-e00c1ff8.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-e4450c26.tgz b/vendor/mdbase-dev-sdk-dev-e4450c26.tgz
deleted file mode 100644
index c47979b..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-e4450c26.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-f1aa67e3.tgz b/vendor/mdbase-dev-sdk-dev-f1aa67e3.tgz
deleted file mode 100644
index 895da22..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-f1aa67e3.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-fb3308a8.tgz b/vendor/mdbase-dev-sdk-dev-fb3308a8.tgz
deleted file mode 100644
index aad7a91..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-fb3308a8.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-fb9691b0.tgz b/vendor/mdbase-dev-sdk-dev-fb9691b0.tgz
deleted file mode 100644
index dacb2b1..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-fb9691b0.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-fd0c4a67.tgz b/vendor/mdbase-dev-sdk-dev-fd0c4a67.tgz
deleted file mode 100644
index ff885a3..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-fd0c4a67.tgz and /dev/null differ
diff --git a/vendor/mdbase-dev-sdk-dev-fddaa4c1.tgz b/vendor/mdbase-dev-sdk-dev-fddaa4c1.tgz
deleted file mode 100644
index 655551b..0000000
Binary files a/vendor/mdbase-dev-sdk-dev-fddaa4c1.tgz and /dev/null differ
diff --git a/vendor/mdbase-next-sdk.json b/vendor/mdbase-next-sdk.json
deleted file mode 100644
index aef13c5..0000000
--- a/vendor/mdbase-next-sdk.json
+++ /dev/null
@@ -1,45 +0,0 @@
-{
- "package": "@mdbase-dev/sdk",
- "version": "0.0.0",
- "artifact": "mdbase-dev-sdk-dev-0ecbef41.tgz",
- "cutIdentity": "sdk-dev-0ecbef41",
- "sourceCommit": "0ecbef415a8b18cf7eb1bdbe41c2e963cc4de4d2",
- "label": "dev intake, not release-qualified",
- "sha256": "30658b0877383a53577172782a164e5bab3f07552c3c3cc97ec7df8ffa420a76",
- "integrity": "sha512-4OZ2+PnQOIoHOq1M5ctvKt3DNYsdPHGcGcKiP1nPD9v6udkLXnDNN3qc0TQGUfmtazKNIEQpSfogffzcZlHJeA==",
- "bytes": 4425112,
- "native": {
- "available": true,
- "bytes": 3655960,
- "sha256": "7d45ae07741b165b51b39d684c28014fcdf178ceeef05b233c78f7b9fa35137a"
- },
- "releaseQualified": false,
- "localDependencies": [
- {
- "package": "@mdbase-dev/connect-protocol",
- "version": "0.1.0-beta.129",
- "artifact": "mdbase-dev-connect-protocol-0.1.0-beta.129-d765e675c3da.tgz",
- "sourceCommit": "d765e675c3da29fe1693deab53c4c2a922bb86f1",
- "sha256": "2769616d8ba1a9cbb3dd689fa1ca0b1e2b42e5442634635f2cd4a87f5ec9020f",
- "integrity": "sha512-PFG1qmAzmcyJjmZLmGz4eoOOZRDgvTvDZATf6IGBccNHRc1eGZtMm7ZUA7gNAxuO5KerrN6mDIB9qQnpojyOAg=="
- }
- ],
- "bundledOriginalDependencies": [
- {
- "name": "@mdbase-dev/connect-protocol",
- "file": "vendor/mdbase-dev-connect-protocol-0.1.0-beta.129-c280cd194ffd.tgz",
- "bytes": 107322,
- "sha256": "cd1e15236ab7e25c511a4ba13af6a9df23f4b41715161479bba97b0445e1c138",
- "sri": "sha512-c0iOOWEcNJRstdyUJ6aqJ3puVR5C9jXW4MBHmQnHI2c4vgOeKi5VWfiuueWx3ArvtwB3nPuAaXHhll/OKH/+Yw=="
- }
- ],
- "vendorWarnings": [],
- "includesPublicApplicationEntry": true,
- "includesPublicApplicationFixture": true,
- "applicationRuntimeAvailable": true,
- "sourceOnly": false,
- "nativeRuntimeQualified": false,
- "perAppRuntimeTrustOriginAndOperationsQualified": false,
- "nativeAssetsPackaged": true,
- "productionTrustAvailable": false
-}
diff --git a/vendor/tasknotes-model-0.3.0-rc.15.tgz b/vendor/tasknotes-model-0.3.0-rc.15.tgz
deleted file mode 100644
index c6ad7d2..0000000
Binary files a/vendor/tasknotes-model-0.3.0-rc.15.tgz and /dev/null differ
diff --git a/vendor/ui-dev-9553ad66.json b/vendor/ui-dev-9553ad66.json
deleted file mode 100644
index 1f4d73d..0000000
--- a/vendor/ui-dev-9553ad66.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "repository": "mdbase-dev/mdbase-connect",
- "sourceCommit": "9553ad660d0c05e035f3ca53b67cd799d3ece1d5",
- "mergedAs": null,
- "package": "@mdbase-dev/ui",
- "version": "0.1.0-beta.129",
- "artifact": "ui-dev-9553ad66.tgz",
- "bytes": 85693,
- "sha256": "5e388db28365cf6a966017851ece1c32223ea8b55bc6015d2f1b4113d86a3206",
- "integrity": "sha512-PqMWvJ2ZbNaLxWTum2m7vmcCbOGdM5qldnphPwqyGBo/mcpvjfk5tHkil/vXLc+LuG7HEyZOLh+tZyUXtMv7QQ==",
- "consumerEntries": [
- "@mdbase-dev/ui/collections",
- "@mdbase-dev/ui/collections.css"
- ],
- "peers": {
- "@mdbase-dev/sdk": "sdk-dev-1629d11e.tgz (optional peer, \"*\")",
- "react": "^19.2.0",
- "react-dom": "^19.2.0 (optional; needed for collections)"
- },
- "label": "dev intake, not release-qualified",
- "noPublication": true
-}
diff --git a/vendor/ui-dev-9553ad66.tgz b/vendor/ui-dev-9553ad66.tgz
deleted file mode 100644
index e34254e..0000000
Binary files a/vendor/ui-dev-9553ad66.tgz and /dev/null differ
diff --git a/vite.config.ts b/vite.config.ts
index d543ea1..d1d8bf4 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -10,14 +10,6 @@ export default defineConfig({
worker: { format: "es" },
build: {
chunkSizeWarningLimit: 560,
- rollupOptions: {
- output: {
- manualChunks: (id) =>
- id.includes("/node_modules/@mdbase-dev/connect")
- ? "mdbase-connect"
- : undefined,
- },
- },
},
server: {
host: "127.0.0.1",