@saadnvd1/agent-os 0.3.762 → 0.3.765
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/api/shares/route.test.ts +66 -1
- package/app/api/shares/route.ts +15 -0
- package/components/Share/ShareDialog.tsx +45 -0
- package/components/Shares/SharedLinksPanel.tsx +7 -0
- package/lib/artifacts/tools.ts +5 -1
- package/lib/connect/control-api.ts +15 -0
- package/lib/db/migrations/20261029T1300_shared_links_follow.ts +28 -0
- package/lib/share/connect.ts +10 -0
- package/lib/share/follow.ts +76 -0
- package/lib/share/service.ts +6 -0
- package/lib/share/store.ts +34 -2
- package/lib/share/view.ts +6 -0
- package/package.json +1 -1
|
@@ -117,7 +117,7 @@ const one = await import("./[id]/route");
|
|
|
117
117
|
const { saveItem } = await import("@/lib/chat/store");
|
|
118
118
|
const { seedSession } = await import("@/lib/orchestrator/testing");
|
|
119
119
|
const { createProject } = await import("@/lib/projects");
|
|
120
|
-
const { createArtifact } = await import("@/lib/artifacts/store");
|
|
120
|
+
const { createArtifact, saveArtifact } = await import("@/lib/artifacts/store");
|
|
121
121
|
const { getDb } = await import("@/lib/db");
|
|
122
122
|
|
|
123
123
|
const secret = ["gh", "p_", "b".repeat(36)].join("");
|
|
@@ -341,6 +341,71 @@ describe("create", () => {
|
|
|
341
341
|
expect(html).toContain("noindex");
|
|
342
342
|
});
|
|
343
343
|
|
|
344
|
+
describe("follow latest", () => {
|
|
345
|
+
const page = (html: string) =>
|
|
346
|
+
`<html><head></head><body>${html}</body></html>`;
|
|
347
|
+
async function create(artifactId: string, extra: object = {}) {
|
|
348
|
+
const body = { kind: "artifact", artifactId, ...extra };
|
|
349
|
+
const p = await (
|
|
350
|
+
await preview.POST(post("/api/shares/preview", body))
|
|
351
|
+
).json();
|
|
352
|
+
return shares.POST(
|
|
353
|
+
post("/api/shares", {
|
|
354
|
+
...body,
|
|
355
|
+
sharedAt: p.sharedAt,
|
|
356
|
+
hash: p.hash,
|
|
357
|
+
expiresIn: "7d",
|
|
358
|
+
})
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
const stored = () =>
|
|
362
|
+
getDb().prepare(`SELECT follow_latest, version FROM shared_links`).get();
|
|
363
|
+
|
|
364
|
+
it("pins the shown version by default", async () => {
|
|
365
|
+
const sid = chat();
|
|
366
|
+
saveArtifact(sid, "Doc", page("one"), { sameTitle: true });
|
|
367
|
+
const v2 = saveArtifact(sid, "Doc", page("two"), {
|
|
368
|
+
sameTitle: true,
|
|
369
|
+
}).artifact;
|
|
370
|
+
expect((await create(v2.id)).status).toBe(201);
|
|
371
|
+
expect(stored()).toEqual({ follow_latest: 0, version: 2 });
|
|
372
|
+
const listed = await (await shares.GET()).json();
|
|
373
|
+
expect(listed.shares[0]).toMatchObject({ follow: false, version: 2 });
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("keeps follow: latest on the link and lists it", async () => {
|
|
377
|
+
const sid = chat();
|
|
378
|
+
const v1 = saveArtifact(sid, "Doc", page("one"), {
|
|
379
|
+
sameTitle: true,
|
|
380
|
+
}).artifact;
|
|
381
|
+
expect((await create(v1.id, { follow: "latest" })).status).toBe(201);
|
|
382
|
+
expect(stored()).toEqual({ follow_latest: 1, version: 1 });
|
|
383
|
+
const listed = await (await shares.GET()).json();
|
|
384
|
+
expect(listed.shares[0]).toMatchObject({ follow: true, version: 1 });
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("refuses to follow from an older version, and for a chat", async () => {
|
|
388
|
+
const sid = chat();
|
|
389
|
+
const v1 = saveArtifact(sid, "Doc", page("one"), {
|
|
390
|
+
sameTitle: true,
|
|
391
|
+
}).artifact;
|
|
392
|
+
saveArtifact(sid, "Doc", page("two"), { sameTitle: true });
|
|
393
|
+
const old = await create(v1.id, { follow: "latest" });
|
|
394
|
+
expect(old.status).toBe(409);
|
|
395
|
+
expect((await old.json()).error).toMatch(/share v2/);
|
|
396
|
+
const res = await shares.POST(
|
|
397
|
+
post("/api/shares", {
|
|
398
|
+
kind: "chat",
|
|
399
|
+
sessionId: sid,
|
|
400
|
+
follow: "latest",
|
|
401
|
+
hash: "x",
|
|
402
|
+
})
|
|
403
|
+
);
|
|
404
|
+
expect(res.status).toBe(400);
|
|
405
|
+
expect(seen).toHaveLength(0);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
344
409
|
it("says so when Connect isn't linked, and for a bad expiry or hash", async () => {
|
|
345
410
|
const id = chat();
|
|
346
411
|
const p = await previewOf(id);
|
package/app/api/shares/route.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
buildPage,
|
|
13
13
|
parseRequest,
|
|
14
14
|
} from "@/lib/share/service";
|
|
15
|
+
import { getArtifact, latestVersion } from "@/lib/artifacts/store";
|
|
15
16
|
import { insertSharedLink, listSharedLinks } from "@/lib/share/store";
|
|
16
17
|
import type { ShareExpiry } from "@/lib/connect/control-api";
|
|
17
18
|
|
|
@@ -31,6 +32,16 @@ export async function POST(request: Request) {
|
|
|
31
32
|
if (typeof body?.hash !== "string") return fail("hash is required.", 400);
|
|
32
33
|
if (!linked()) return fail(NOT_LINKED, 409);
|
|
33
34
|
|
|
35
|
+
const artifact = req.artifactId ? getArtifact(req.artifactId) : undefined;
|
|
36
|
+
if (req.follow && artifact) {
|
|
37
|
+
const latest = latestVersion(artifact.page_id);
|
|
38
|
+
if (latest && latest.id !== artifact.id)
|
|
39
|
+
return fail(
|
|
40
|
+
`v${artifact.version} isn't the latest; share v${latest.version} to follow the page.`,
|
|
41
|
+
409
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
34
45
|
const page = await buildPage(req);
|
|
35
46
|
if (page.tooBig) return fail(TOO_BIG, 413);
|
|
36
47
|
if (page.hash !== body.hash)
|
|
@@ -53,6 +64,8 @@ export async function POST(request: Request) {
|
|
|
53
64
|
expires_at: share.expires_at,
|
|
54
65
|
created_at: share.created_at,
|
|
55
66
|
revoked_at: null,
|
|
67
|
+
follow_latest: req.follow ? 1 : 0,
|
|
68
|
+
version: artifact?.version ?? null,
|
|
56
69
|
});
|
|
57
70
|
return NextResponse.json(
|
|
58
71
|
{ id: share.id, url: share.url, expiresAt: share.expires_at },
|
|
@@ -96,6 +109,8 @@ export async function GET() {
|
|
|
96
109
|
status,
|
|
97
110
|
views: r ? r.view_count : null,
|
|
98
111
|
lastViewedAt: r?.last_viewed_at ?? null,
|
|
112
|
+
follow: l.follow_latest === 1,
|
|
113
|
+
version: l.version,
|
|
99
114
|
};
|
|
100
115
|
}),
|
|
101
116
|
});
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
SelectValue,
|
|
20
20
|
} from "@/components/ui/select";
|
|
21
21
|
import { DownloadMenu } from "./DownloadMenu";
|
|
22
|
+
import { cn } from "@/lib/utils";
|
|
22
23
|
import { Switch } from "@/components/ui/switch";
|
|
23
24
|
import { writeClipboard } from "@/hooks/useCopyToClipboard";
|
|
24
25
|
import {
|
|
@@ -76,6 +77,7 @@ function ShareBody({
|
|
|
76
77
|
const [preview, setPreview] = useState<SharePreview | null>(null);
|
|
77
78
|
const [error, setError] = useState<string | null>(null);
|
|
78
79
|
const [created, setCreated] = useState<string | null>(null);
|
|
80
|
+
const [follow, setFollow] = useState(false);
|
|
79
81
|
const [copied, setCopied] = useState(false);
|
|
80
82
|
const [tab, setTab] = useState<"page" | "markdown">("page");
|
|
81
83
|
const previewReq = useSharePreview();
|
|
@@ -121,6 +123,7 @@ function ShareBody({
|
|
|
121
123
|
sharedAt: preview.sharedAt,
|
|
122
124
|
hash: preview.hash,
|
|
123
125
|
expiresIn,
|
|
126
|
+
follow: follow ? "latest" : undefined,
|
|
124
127
|
});
|
|
125
128
|
setCreated(res.url);
|
|
126
129
|
try {
|
|
@@ -235,6 +238,48 @@ function ShareBody({
|
|
|
235
238
|
</>
|
|
236
239
|
)}
|
|
237
240
|
|
|
241
|
+
{linked && kind === "artifact" && (
|
|
242
|
+
<fieldset className="space-y-2">
|
|
243
|
+
<legend className="text-muted-foreground mb-1 text-xs font-medium">
|
|
244
|
+
What the link shows
|
|
245
|
+
</legend>
|
|
246
|
+
{[
|
|
247
|
+
{
|
|
248
|
+
value: false,
|
|
249
|
+
title: `This version${target.version ? ` (v${target.version})` : ""}`,
|
|
250
|
+
hint: "Never changes. You review it above before it goes out.",
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
value: true,
|
|
254
|
+
title: "Always the latest",
|
|
255
|
+
hint: "Updates when the agent makes a new version. New versions are redacted the same way but publish without a fresh preview.",
|
|
256
|
+
},
|
|
257
|
+
].map((o) => (
|
|
258
|
+
<label
|
|
259
|
+
key={o.title}
|
|
260
|
+
className={cn(
|
|
261
|
+
"flex min-h-11 cursor-pointer items-start gap-3 rounded-lg border px-3 py-2 text-sm",
|
|
262
|
+
follow === o.value && "border-primary bg-primary/5"
|
|
263
|
+
)}
|
|
264
|
+
>
|
|
265
|
+
<input
|
|
266
|
+
type="radio"
|
|
267
|
+
name="share-follow"
|
|
268
|
+
className="mt-1 h-4 w-4 shrink-0"
|
|
269
|
+
checked={follow === o.value}
|
|
270
|
+
onChange={() => setFollow(o.value)}
|
|
271
|
+
/>
|
|
272
|
+
<span className="min-w-0">
|
|
273
|
+
<span className="block font-medium">{o.title}</span>
|
|
274
|
+
<span className="text-muted-foreground block text-xs">
|
|
275
|
+
{o.hint}
|
|
276
|
+
</span>
|
|
277
|
+
</span>
|
|
278
|
+
</label>
|
|
279
|
+
))}
|
|
280
|
+
</fieldset>
|
|
281
|
+
)}
|
|
282
|
+
|
|
238
283
|
{linked && (
|
|
239
284
|
<label className="flex min-h-11 items-center justify-between gap-3 text-sm">
|
|
240
285
|
<span>Link expires after</span>
|
|
@@ -60,6 +60,13 @@ function Row({ share }: { share: SharedLinkRow }) {
|
|
|
60
60
|
<div className="truncate text-sm font-medium">{title}</div>
|
|
61
61
|
<div className="text-muted-foreground flex flex-wrap gap-x-3 text-xs">
|
|
62
62
|
<span>{share.kind === "chat" ? "Chat" : "Page"}</span>
|
|
63
|
+
{share.kind === "artifact" && (
|
|
64
|
+
<span>
|
|
65
|
+
{share.follow
|
|
66
|
+
? `Follows latest${share.version ? ` · now v${share.version}` : ""}`
|
|
67
|
+
: `pinned${share.version ? ` v${share.version}` : ""}`}
|
|
68
|
+
</span>
|
|
69
|
+
)}
|
|
63
70
|
<span>Created {when(share.createdAt)}</span>
|
|
64
71
|
<span>
|
|
65
72
|
{share.status === "revoked"
|
package/lib/artifacts/tools.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
MAX_TITLE_CHARS,
|
|
20
20
|
type Artifact,
|
|
21
21
|
} from "./store";
|
|
22
|
+
import { followLatest } from "../share/follow";
|
|
22
23
|
import { previewHtml } from "./preview";
|
|
23
24
|
|
|
24
25
|
export const VISUALS_SERVER = "visuals";
|
|
@@ -101,7 +102,10 @@ export async function renderPage(
|
|
|
101
102
|
readSource(args, ctx.cwd),
|
|
102
103
|
{ update: args.update, note: args.note, sameTitle: true }
|
|
103
104
|
);
|
|
104
|
-
if (created)
|
|
105
|
+
if (created) {
|
|
106
|
+
ctx.onRender(artifact, args.height);
|
|
107
|
+
if (artifact.version > 1) followLatest(artifact.page_id);
|
|
108
|
+
}
|
|
105
109
|
const name = `"${artifact.title}"${artifact.version > 1 ? ` v${artifact.version}` : ""}`;
|
|
106
110
|
return {
|
|
107
111
|
content: [
|
|
@@ -428,6 +428,21 @@ export async function uploadShare(
|
|
|
428
428
|
return { ...shareFrom(r), url };
|
|
429
429
|
}
|
|
430
430
|
|
|
431
|
+
/** Replaces a live share's content under the same link. */
|
|
432
|
+
export async function replaceShare(
|
|
433
|
+
config: ConnectConfig,
|
|
434
|
+
privateKey: string,
|
|
435
|
+
id: string,
|
|
436
|
+
share: { title: string; html: string; markdown: string }
|
|
437
|
+
): Promise<void> {
|
|
438
|
+
if (!/^[a-z0-9]{12}$/.test(id)) throw new Error("not a share id");
|
|
439
|
+
await signed(config, privateKey)(
|
|
440
|
+
"PUT",
|
|
441
|
+
`/api/v1/machines/${config.machineId}/shares/${id}`,
|
|
442
|
+
{ title: share.title, html: share.html, markdown: share.markdown }
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
431
446
|
export async function listShares(
|
|
432
447
|
config: ConnectConfig,
|
|
433
448
|
privateKey: string
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261029T1300_shared_links_follow",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// follow_latest: the link is re-uploaded whenever its page gets a new
|
|
7
|
+
// version. version: the page version the link shows now (pinned or not).
|
|
8
|
+
const cols = new Set(
|
|
9
|
+
(
|
|
10
|
+
db.prepare(`PRAGMA table_info(shared_links)`).all() as {
|
|
11
|
+
name: string;
|
|
12
|
+
}[]
|
|
13
|
+
).map((c) => c.name)
|
|
14
|
+
);
|
|
15
|
+
if (!cols.has("follow_latest"))
|
|
16
|
+
db.exec(
|
|
17
|
+
`ALTER TABLE shared_links ADD COLUMN follow_latest INTEGER NOT NULL DEFAULT 0`
|
|
18
|
+
);
|
|
19
|
+
if (!cols.has("version"))
|
|
20
|
+
db.exec(`ALTER TABLE shared_links ADD COLUMN version INTEGER`);
|
|
21
|
+
// Links made before this show the version their artifact row is.
|
|
22
|
+
db.exec(
|
|
23
|
+
`UPDATE shared_links SET version = (
|
|
24
|
+
SELECT a.version FROM artifacts a WHERE a.id = shared_links.artifact_id
|
|
25
|
+
) WHERE version IS NULL AND artifact_id IS NOT NULL`
|
|
26
|
+
);
|
|
27
|
+
},
|
|
28
|
+
} satisfies Migration;
|
package/lib/share/connect.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
ApiError,
|
|
6
6
|
clean,
|
|
7
7
|
listShares,
|
|
8
|
+
replaceShare,
|
|
8
9
|
revokeShare,
|
|
9
10
|
uploadShare,
|
|
10
11
|
type RemoteShare,
|
|
@@ -70,6 +71,15 @@ export async function createRemote(share: {
|
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/** Puts new content under a live link. Throws the raw ApiError, so the caller can tell a dead link from a failed try. */
|
|
75
|
+
export async function replaceRemote(
|
|
76
|
+
id: string,
|
|
77
|
+
content: { title: string; html: string; markdown: string }
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
const { config, machineKey } = files();
|
|
80
|
+
await replaceShare(config, machineKey, id, content);
|
|
81
|
+
}
|
|
82
|
+
|
|
73
83
|
export async function listRemote(): Promise<RemoteShare[]> {
|
|
74
84
|
const { config, machineKey } = files();
|
|
75
85
|
return listShares(config, machineKey);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Follow-latest share links: when a page gets a new version, every live link
|
|
3
|
+
* that follows it is re-uploaded through Connect's PUT, built the same way as
|
|
4
|
+
* a normal share (same snapshot, same redaction). Nothing here throws into
|
|
5
|
+
* the caller: a failed try is logged and the next version tries again, and
|
|
6
|
+
* the content already on the link stays live meanwhile.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ApiError } from "@/lib/connect/control-api";
|
|
10
|
+
import { latestVersion } from "@/lib/artifacts/store";
|
|
11
|
+
import { linked, replaceRemote } from "./connect";
|
|
12
|
+
import { BadShare, buildPage } from "./service";
|
|
13
|
+
import { listFollowing, setLinkVersion, stopFollowing } from "./store";
|
|
14
|
+
|
|
15
|
+
// One run per page at a time, so two quick versions can't land out of order.
|
|
16
|
+
const running = new Map<string, Promise<void>>();
|
|
17
|
+
|
|
18
|
+
export function followLatest(pageId: string): Promise<void> {
|
|
19
|
+
const next = (running.get(pageId) ?? Promise.resolve())
|
|
20
|
+
.then(() => run(pageId))
|
|
21
|
+
.catch((err) => console.error("[share-follow]", pageId, err))
|
|
22
|
+
.finally(() => {
|
|
23
|
+
if (running.get(pageId) === next) running.delete(pageId);
|
|
24
|
+
});
|
|
25
|
+
running.set(pageId, next);
|
|
26
|
+
return next;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function run(pageId: string): Promise<void> {
|
|
30
|
+
const latest = latestVersion(pageId);
|
|
31
|
+
if (!latest) return;
|
|
32
|
+
// A link that missed an earlier version is behind too, so this is also the retry.
|
|
33
|
+
const behind = listFollowing(pageId).filter(
|
|
34
|
+
(l) => (l.version ?? 0) < latest.version
|
|
35
|
+
);
|
|
36
|
+
if (!behind.length || !linked()) return;
|
|
37
|
+
let page;
|
|
38
|
+
try {
|
|
39
|
+
page = await buildPage({
|
|
40
|
+
kind: "artifact",
|
|
41
|
+
artifactId: latest.id,
|
|
42
|
+
includeTools: false,
|
|
43
|
+
sharedAt: new Date().toISOString(),
|
|
44
|
+
});
|
|
45
|
+
} catch (err) {
|
|
46
|
+
console.error(
|
|
47
|
+
"[share-follow] could not build",
|
|
48
|
+
latest.id,
|
|
49
|
+
err instanceof BadShare ? err.message : err
|
|
50
|
+
);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (page.tooBig) {
|
|
54
|
+
console.error("[share-follow] v%d is too big to share", latest.version);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
for (const link of behind) {
|
|
58
|
+
try {
|
|
59
|
+
await replaceRemote(link.id, {
|
|
60
|
+
title: page.title,
|
|
61
|
+
html: page.html,
|
|
62
|
+
markdown: page.markdown,
|
|
63
|
+
});
|
|
64
|
+
setLinkVersion(link.id, latest.version, page.title);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err instanceof ApiError && (err.status === 404 || err.status === 410))
|
|
67
|
+
stopFollowing(link.id);
|
|
68
|
+
console.error(
|
|
69
|
+
"[share-follow] %s stays on v%s:",
|
|
70
|
+
link.id,
|
|
71
|
+
link.version,
|
|
72
|
+
err instanceof Error ? err.message : err
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
package/lib/share/service.ts
CHANGED
|
@@ -25,6 +25,7 @@ export interface ShareRequest {
|
|
|
25
25
|
includeTools: boolean;
|
|
26
26
|
/** Messages the reader left out in the preview. */
|
|
27
27
|
dropIds?: string[];
|
|
28
|
+
follow?: "latest";
|
|
28
29
|
sharedAt: string;
|
|
29
30
|
}
|
|
30
31
|
|
|
@@ -86,6 +87,10 @@ export function parseRequest(raw: unknown, sharedAt?: unknown): ShareRequest {
|
|
|
86
87
|
throw new BadShare("dropIds must be a list of ids", 400);
|
|
87
88
|
dropIds = b.dropIds as string[];
|
|
88
89
|
}
|
|
90
|
+
if (b.follow !== undefined && b.follow !== "latest")
|
|
91
|
+
throw new BadShare("follow must be latest", 400);
|
|
92
|
+
if (b.follow && kind !== "artifact")
|
|
93
|
+
throw new BadShare("Only a page can follow its latest version", 400);
|
|
89
94
|
const at = typeof sharedAt === "string" ? sharedAt : new Date().toISOString();
|
|
90
95
|
if (Number.isNaN(Date.parse(at)))
|
|
91
96
|
throw new BadShare("sharedAt is not a date", 400);
|
|
@@ -95,6 +100,7 @@ export function parseRequest(raw: unknown, sharedAt?: unknown): ShareRequest {
|
|
|
95
100
|
artifactId,
|
|
96
101
|
includeTools: b.includeTools === true,
|
|
97
102
|
dropIds,
|
|
103
|
+
follow: b.follow === "latest" ? "latest" : undefined,
|
|
98
104
|
sharedAt: at,
|
|
99
105
|
};
|
|
100
106
|
}
|
package/lib/share/store.ts
CHANGED
|
@@ -10,13 +10,16 @@ export interface SharedLink {
|
|
|
10
10
|
expires_at: string | null;
|
|
11
11
|
created_at: string;
|
|
12
12
|
revoked_at: string | null;
|
|
13
|
+
follow_latest: number;
|
|
14
|
+
// The page version the link shows now.
|
|
15
|
+
version: number | null;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
export function insertSharedLink(link: SharedLink): void {
|
|
16
19
|
db.prepare(
|
|
17
20
|
`INSERT INTO shared_links
|
|
18
|
-
(id, url, kind, title, session_id, artifact_id, expires_at, created_at, revoked_at)
|
|
19
|
-
VALUES (@id, @url, @kind, @title, @session_id, @artifact_id, @expires_at, @created_at, @revoked_at)`
|
|
21
|
+
(id, url, kind, title, session_id, artifact_id, expires_at, created_at, revoked_at, follow_latest, version)
|
|
22
|
+
VALUES (@id, @url, @kind, @title, @session_id, @artifact_id, @expires_at, @created_at, @revoked_at, @follow_latest, @version)`
|
|
20
23
|
).run(link);
|
|
21
24
|
}
|
|
22
25
|
|
|
@@ -36,3 +39,32 @@ export function markRevoked(id: string): void {
|
|
|
36
39
|
`UPDATE shared_links SET revoked_at = COALESCE(revoked_at, ?) WHERE id = ?`
|
|
37
40
|
).run(new Date().toISOString(), id);
|
|
38
41
|
}
|
|
42
|
+
|
|
43
|
+
/** Live follow-latest links to any version of a page. */
|
|
44
|
+
export function listFollowing(pageId: string): SharedLink[] {
|
|
45
|
+
return db
|
|
46
|
+
.prepare(
|
|
47
|
+
`SELECT l.* FROM shared_links l JOIN artifacts a ON a.id = l.artifact_id
|
|
48
|
+
WHERE a.page_id = ? AND l.follow_latest = 1 AND l.revoked_at IS NULL
|
|
49
|
+
AND (l.expires_at IS NULL OR l.expires_at > ?)
|
|
50
|
+
ORDER BY l.created_at, l.rowid`
|
|
51
|
+
)
|
|
52
|
+
.all(pageId, new Date().toISOString()) as SharedLink[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function setLinkVersion(
|
|
56
|
+
id: string,
|
|
57
|
+
version: number,
|
|
58
|
+
title: string
|
|
59
|
+
): void {
|
|
60
|
+
db.prepare(`UPDATE shared_links SET version = ?, title = ? WHERE id = ?`).run(
|
|
61
|
+
version,
|
|
62
|
+
title,
|
|
63
|
+
id
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Connect no longer has it: stop trying. */
|
|
68
|
+
export function stopFollowing(id: string): void {
|
|
69
|
+
db.prepare(`UPDATE shared_links SET follow_latest = 0 WHERE id = ?`).run(id);
|
|
70
|
+
}
|
package/lib/share/view.ts
CHANGED
|
@@ -7,6 +7,8 @@ export interface ShareTarget {
|
|
|
7
7
|
kind: "chat" | "artifact";
|
|
8
8
|
sessionId?: string;
|
|
9
9
|
artifactId?: string;
|
|
10
|
+
/** A page link that is re-uploaded when the page gets a new version. */
|
|
11
|
+
follow?: "latest";
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export interface PreviewRequest extends ShareTarget {
|
|
@@ -52,6 +54,10 @@ export interface SharedLinkRow {
|
|
|
52
54
|
status: "active" | "expired" | "revoked";
|
|
53
55
|
views: number | null;
|
|
54
56
|
lastViewedAt: string | null;
|
|
57
|
+
/** True when the link follows the page's latest version. */
|
|
58
|
+
follow: boolean;
|
|
59
|
+
/** The page version the link shows now. */
|
|
60
|
+
version: number | null;
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
export interface SharesResponse {
|