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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions web/src/clips/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { ClipDuplicateError, ClipError } from "./contracts";
import type { ClipActionInput, ClipReceipt, ClipState } from "./contracts";
import { CLIP_LIMITS, clipUrl, validateClipState } from "./validation";

export function applyClipAction(
state: ClipState,
action: ClipActionInput,
operationId: string,
clipId: string,
digest: string,
preparedAt: string,
): { clips: ClipState; receipt: ClipReceipt } {
const next = structuredClone(state);
let changed = true,
resultRevision: number | null = null;
if (action.kind === "create") {
const url = clipUrl(action.url);
const duplicate = next.items.find(
(item) => item.canonicalUrl === url.canonicalUrl,
);
if (!action.allowDuplicate && duplicate)
throw new ClipDuplicateError(duplicate);
if (next.items.length >= CLIP_LIMITS.items)
throw new ClipError(
"clip_limit",
"The local clip limit has been reached.",
);
if (next.items.some((item) => item.id === clipId))
throw new ClipError(
"clip_operation_reused",
"This clip identity is already in use.",
);
// Timestamp admission follows all target and duplicate checks.
const at = new Date().toISOString();
next.items.push({
id: clipId,
revision: 1,
originalUrl: url.originalUrl,
canonicalUrl: url.canonicalUrl,
title: action.title,
text: action.text || null,
createdAt: preparedAt,
updatedAt: at,
readAt: null,
readLater: action.readLater,
archivedAt: null,
});
resultRevision = 1;
return finish(at);
}
const item = next.items.find((item) => item.id === clipId);
if (!item || item.revision !== action.targetRevision)
throw new ClipError(
"clip_stale",
"This clip changed or is no longer available.",
);
if (action.kind === "undo-create") {
const original = next.receipts.find(
(receipt) => receipt.operationId === action.receiptOperationId,
);
const now = Date.now();
if (
!original ||
original.kind !== "create" ||
original.clipId !== clipId ||
original.resultRevision !== 1 ||
item.revision !== 1 ||
now < Date.parse(original.at) ||
now > Date.parse(original.at) + 5000
)
throw new ClipError(
"clip_undo_unavailable",
"Undo is no longer available for this clip.",
);
next.items = next.items.filter((item) => item.id !== clipId);
return finish(new Date(now).toISOString());
}
changed =
action.kind === "set-read"
? Boolean(item.readAt) !== action.read
: action.kind === "set-read-later"
? item.readLater !== action.readLater
: Boolean(item.archivedAt) !== action.archived;
if (changed && item.revision === Number.MAX_SAFE_INTEGER)
throw new ClipError("clip_limit", "Clip revisions are exhausted.");
const at = new Date().toISOString();
if (changed) {
if (action.kind === "set-read") item.readAt = action.read ? at : null;
else if (action.kind === "set-read-later")
item.readLater = action.readLater;
else item.archivedAt = action.archived ? at : null;
item.updatedAt = at;
item.revision++;
}
resultRevision = item.revision;
return finish(at);
function finish(at: string) {
const receipt: ClipReceipt = {
operationId,
digest,
kind: action.kind,
clipId,
resultRevision,
at,
changed,
};
next.receipts.push(receipt);
next.receipts = next.receipts.slice(-CLIP_LIMITS.receipts);
return { clips: validateClipState(next), receipt };
}
}
262 changes: 262 additions & 0 deletions web/src/clips/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { lockVersion, onLock } from "../account/session";
import { onCollectionsEpochChange } from "../collections/store";
import { CollectionsRevisionConflictError } from "../collections/contracts";
import type {
CollectionsRevision,
CollectionsState,
CollectionsStore,
} from "../collections/contracts";
import { ClipConflictError, ClipError } from "./contracts";
import type {
ClipActionInput,
ClipIntent,
ClipMutationResult,
ClipReceipt,
ClipSnapshot,
ClipsStore,
} from "./contracts";
import { applyClipAction } from "./model";
import { id, integer, object, validateClipAction } from "./validation";

type Prepared = {
expected: CollectionsRevision;
action: ClipActionInput;
operationId: string;
clipId: string;
preparedAt: string;
digest: string;
generation: number;
};
function snapshot(state: CollectionsState): ClipSnapshot {
return structuredClone({
epoch: state.epoch,
revision: state.revision,
clips: state.clips,
});
}
export function createClipsStore(collections: CollectionsStore): ClipsStore {
let closed = false,
generation = 0;
const openedLock = lockVersion();
let intents = new WeakMap<ClipIntent, Prepared>();
const listeners = new Set<() => void>(),
invalidations = new Set<(reason: "epoch" | "closed") => void>();
const stopLock = onLock(close);
let stopEpoch: () => void, stopChange: () => void;
try {
stopEpoch = onCollectionsEpochChange(collections, () =>
invalidate("epoch"),
);
stopChange = collections.subscribe(notify);
} catch (error) {
stopLock();
throw error;
}
function notify() {
if (!closed)
for (const listener of listeners) {
try {
listener();
} catch {
/* Observer isolation. */
}
}
}
function invalidate(reason: "epoch" | "closed") {
generation++;
intents = new WeakMap();
for (const listener of invalidations) {
try {
listener(reason);
} catch {
/* Revocation always completes. */
}
}
}
function close() {
if (closed) return;
closed = true;
invalidate("closed");
stopLock();
stopEpoch?.();
stopChange?.();
listeners.clear();
invalidations.clear();
}
function guard(captured = generation) {
if (closed || openedLock !== lockVersion() || captured !== generation)
throw new ClipError(
"clip_intent_revoked",
"This clip operation is no longer authorized.",
);
}
async function read() {
guard();
const captured = generation;
const state = await collections.read();
guard(captured);
return snapshot(state);
}
function reconcile(
state: CollectionsState,
prepared: Prepared,
): ClipMutationResult | null {
guard(prepared.generation);
if (state.epoch !== prepared.expected.epoch)
throw new ClipError(
"clip_intent_revoked",
"The workspace was erased. Reload Clips.",
);
const receipt = state.clips.receipts.find(
(row) => row.operationId === prepared.operationId,
);
if (!receipt) return null;
if (receipt.digest !== prepared.digest)
throw new ClipError(
"clip_operation_reused",
"This operation identity has different saved content.",
);
return {
snapshot: snapshot(state),
receipt: structuredClone(receipt),
changed: receipt.changed,
replayed: true,
};
}
return {
read,
async prepare(expectedInput, input) {
guard();
const token = object(
{ epoch: expectedInput?.epoch, revision: expectedInput?.revision },
["epoch", "revision"],
);
id(token.epoch);
integer(token.revision);
const expected = { epoch: token.epoch, revision: token.revision };
// Validation returns a fresh primitive-only action, captured before awaits.
const action = validateClipAction(input),
captured = generation;
const operationId = crypto.randomUUID(),
preparedAt = new Date().toISOString();
const state = await collections.read();
guard(captured);
if (state.epoch !== expected.epoch)
throw new ClipError(
"clip_intent_revoked",
"The workspace changed. Reload Clips.",
);
const clipId =
action.kind === "create"
? operationId
: action.kind === "undo-create"
? state.clips.receipts.find(
(row) =>
row.operationId === action.receiptOperationId &&
row.kind === "create",
)?.clipId
: action.clipId;
if (!clipId)
throw new ClipError(
"clip_undo_unavailable",
"The original save receipt is no longer available.",
);
const canonicalAction = Object.fromEntries(
Object.entries(action).sort(([a], [b]) => a.localeCompare(b)),
);
const bytes = new TextEncoder().encode(
JSON.stringify({
expected,
action: canonicalAction,
operationId,
clipId,
preparedAt,
}),
);
let digest: string;
try {
digest = Array.from(
new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)),
(byte) => byte.toString(16).padStart(2, "0"),
).join("");
} finally {
bytes.fill(0);
}
guard(captured);
const latest = await collections.read();
guard(captured);
if (latest.epoch !== expected.epoch)
throw new ClipError(
"clip_intent_revoked",
"The workspace changed. Reload Clips.",
);
const intent = Object.freeze({ operationId, clipId }) as ClipIntent;
intents.set(intent, {
expected,
action,
operationId,
clipId,
preparedAt,
digest,
generation: captured,
});
return intent;
},
async commit(intent) {
guard();
const prepared = intent && intents.get(intent);
if (!prepared)
throw new ClipError(
"clip_intent_revoked",
"Prepare a new authorized clip operation.",
);
guard(prepared.generation);
const current = await collections.read();
const replay = reconcile(current, prepared);
if (replay) return replay;
let receipt: ClipReceipt | undefined;
try {
const saved = await collections.update(prepared.expected, (current) => {
guard(prepared.generation);
const result = applyClipAction(
current.clips,
prepared.action,
prepared.operationId,
prepared.clipId,
prepared.digest,
prepared.preparedAt,
);
receipt = result.receipt;
return { ...current, clips: result.clips };
});
guard(prepared.generation);
return {
snapshot: snapshot(saved),
receipt: structuredClone(receipt!),
changed: receipt!.changed,
replayed: false,
};
} catch (error) {
guard(prepared.generation);
if (error instanceof CollectionsRevisionConflictError) {
const replay = reconcile(error.current, prepared);
if (replay) return replay;
throw new ClipConflictError(snapshot(error.current));
}
throw error;
}
},
subscribe(listener) {
guard();
listeners.add(listener);
return () => listeners.delete(listener);
},
onInvalidate(listener) {
guard();
invalidations.add(listener);
return () => invalidations.delete(listener);
},
persistence: () => collections.persistence(),
close,
};
}
22 changes: 22 additions & 0 deletions web/src/clips/transfer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { ClipExportFile, ClipState } from "./contracts";
import { validateClipState } from "./validation";

export function exportClipsFiles(input: ClipState): ClipExportFile[] {
const clips = validateClipState(input);
if (!clips.items.length) return [];
return [
{
filename: "clips.json",
content: JSON.stringify({ version: 1, ...clips }, null, 2),
},
{
filename: "README.txt",
content:
"Clips plaintext export\n\nThis export contains private links and pasted text. Store it somewhere safe. clips.json contains all saved clip fields and operation receipts. Each item also has a readable text file. Text is user-pasted content, not fetched from the source. Recovery is manual; this version has no automatic import or restore. No account keys, passphrases or engine snapshots are included.\n",
},
...clips.items.map((item) => ({
filename: `clip-${item.id}.txt`,
content: `${item.title}\nSource: ${item.originalUrl}\nCanonical URL: ${item.canonicalUrl}\nProvenance: ${item.text === null ? "Link only" : "Pasted text"}\nSaved: ${item.createdAt}\nUpdated: ${item.updatedAt}\nRead: ${item.readAt ?? "Unread"}\nRead later: ${item.readLater ? "Yes" : "No"}\nArchived: ${item.archivedAt ?? "No"}\nID: ${item.id}\nRevision: ${item.revision}\n\n---\n${item.text ?? "Only the link is saved."}`,
})),
];
}
Loading
Loading