Add trust features: history, trash, export, and collection sharing
release gate / check (push) Failing after 6m36s

Implement Notion-like upgrade phase 4: append-only page revisions with restore-as-a-new-version; recoverable bookmark/page/collection deletions whose restores never resurrect public flags or shares; owner-scoped JSON export; and explicit, revocable read-only collection sharing with owner-only mutations and no transitive third-party bookmark leaks. Add migration 009, API/docs, integration coverage, and page/collections/trash UI.
This commit is contained in:
Andrew S Erwin
2026-10-19 23:07:38 -05:00
parent 9db69efe4b
commit 5eab58f99c
22 changed files with 1496 additions and 76 deletions
+40 -11
View File
@@ -116,8 +116,11 @@ test-only). CardDAV keeps the configured HTTPS port.
| PUT | `/api/pages` | `{ok:true}`; complete `{order:[id,...]}` |
| GET | `/api/pages/{id}` | Page plus ordered `bookmarks:[{bookmark}]`, owner-scoped `backlinks:[{id,title,updatedAt}]`, resolved outgoing `links:[{id,title}]`, and `embeds:[{collectionId,viewId,position,collection,view}]` |
| PATCH | `/api/pages/{id}` | Updated page; `{title?,content?,pinned?,icon?,parentId?,version?,addBookmarkId?,removeBookmarkId?}`. `version` (the base revision from GET) is REQUIRED with `content`: 400 when missing, 409 when stale. Metadata-only patches (including moving a page) stay version-free and do not bump the revision. Moving under itself or a descendant is rejected 400 (no cycles) |
| DELETE | `/api/pages/{id}` | `{ok:true}`; keeps bookmarks and promotes child pages to the top level |
| GET | `/api/pages/{id}/backlinks` | Owner-scoped `[{id,title,updatedAt}]` from `PageLink` — the pages whose Markdown links here |
| GET | `/api/pages/{id}/revisions` | Version history metadata `[{id,version,title,icon,createdAt,length}]`, newest first; bodies fetched separately |
| GET | `/api/pages/{id}/revisions/{revisionId}` | Full immutable revision snapshot `{...,content}` |
| POST | `/api/pages/{id}/revisions/{revisionId}/restore` | Restores a snapshot as a **new** version; body `{version}` (current base) — 400 missing, 409 stale. The restore itself is recorded in history |
| DELETE | `/api/pages/{id}` | Moves the page to Trash; keeps bookmarks and promotes child pages. Restoring also restores its revisions and creates a new revision |
| GET | `/api/pages/{id}/markdown` | Markdown attachment |
### Connected pages (phase 3)
@@ -139,15 +142,39 @@ in the same transaction as every content save:
its bookmarks. Deleting a collection removes its embeds; deleting a view
falls the embed back to the collection's default view.
### Trust (phase 4)
Deleting a bookmark, page or collection now moves it to the owner's Trash.
The snapshot is taken in the same transaction as the hard delete and includes
intrinsic content plus owner-scoped links (tags, page links, collection
memberships, page revisions). It deliberately excludes access grants:
bookmark public flags, bookmark/tag shares and collection shares are never
restored, so revoking access while an item is in the Trash cannot be undone by
restoring it. Deleted bookmarks keep their existing delete endpoints; the UI
and docs now call this “move to trash”.
| Method | Path | Returns / behavior |
|---|---|---|
| GET | `/api/trash` | Owner's deleted items `[{id,type,originalId,title,deletedAt}]`, newest first |
| POST | `/api/trash/{id}/restore` | Restores the item; 404 for another owner. Page restores recreate their history and append a new revision. Neither bookmark `isPublic` nor any share is restored |
| DELETE | `/api/trash/{id}` | Permanently removes one Trash entry (explicit purge) |
| GET | `/api/export` | Owner-scoped structured JSON archive: user, bookmarks, tags, pages (content + revisions), collections (properties, views, items, shares) and contacts. Credentials are never exported |
## Collections
Contextual workflows over existing bookmarks (phase 2 of the Notion-like
upgrades). Collections are owner-scoped; membership references bookmarks
without owning them — removing an item or a whole collection never touches
the bookmark. Membership never broadens access: items whose bookmark the
caller cannot see are omitted from lists/counts and behave as not-found for
mutations; re-sharing restores the membership. Every mutating PATCH requires
the expected `version` (400 missing, 409 stale). Indirect mutations advance
upgrades). Collections are owner-scoped unless explicitly shared; membership
references bookmarks without owning them — removing an item or a whole
collection never touches the bookmark. An explicit `CollectionShare` gives a
recipient read-only access to the collection and the **narrowed** bookmark
projection for member bookmarks **owned by the collection owner**. A bookmark
that the collection owner can only see through someone else's share is
omitted, so collection sharing never passes a third-party grant onwards.
Revoking the share or removing the item removes that access immediately, and
mutations remain owner-only. Items whose
bookmark the caller cannot see are omitted from lists/counts and behave as
not-found for mutations; re-sharing restores the membership. Every mutating
PATCH requires the expected `version` (400 missing, 409 stale). Indirect mutations advance
revisions too (review CR-09): item reorder bumps every affected item's
version, and property deletion bumps the versions of items whose value it
dropped and views whose filter/sort definition it changed. Deletions are
@@ -156,11 +183,13 @@ from a client snapshot.
| Method | Path | Returns / behavior |
|---|---|---|
| GET | `/api/collections` | Owned collections with `_count.{items,properties,views}`; item counts include only bookmarks the caller can see |
| GET | `/api/collections` | Owned **and explicitly shared** collections with `shared`, `owner`, `_count.{items,properties,views}`; item counts include only bookmarks the caller can see |
| POST | `/api/collections` | 201 detail `{...collection,properties:[],views:[default Table view]}`; body `{name,description?,icon?}` |
| GET | `/api/collections/{id}` | Collection plus ordered `properties`, `views` and authorized `_count.{items,properties,views}` |
| PATCH | `/api/collections/{id}` | Updated collection; `{version,name?,description?,icon?}`, bumps `version` |
| DELETE | `/api/collections/{id}` | `{ok:true}`; memberships/properties/views cascade, bookmarks kept |
| GET | `/api/collections/{id}` | Readable collection detail (owner or share recipient) plus ordered `properties`, `views`, authorized `_count`, `shared`; owner also gets `sharedWith` |
| PATCH | `/api/collections/{id}` | Updated collection; `{version,name?,description?,icon?}`, bumps `version`; owner only |
| DELETE | `/api/collections/{id}` | Moves the collection to Trash; memberships/properties/views cascade, bookmarks kept; owner only |
| POST | `/api/collections/{id}/share` | Owner only; `{userIds:[...]}` grants read-only access, idempotent |
| DELETE | `/api/collections/{id}/share/{userId}` | Owner only; revokes read-only access immediately |
| POST | `/api/collections/{id}/properties` | 201 property; `{name,type,config?}`. Types: `TEXT,NUMBER,SELECT,MULTI_SELECT,DATE,CHECKBOX,URL` (`RELATION` reserved, rejected). SELECT config `{options:[{label,color?}]}` — the server assigns stable option ids; DATE config `{mode:"date"\|"datetime"}`. Duplicate name → 409 |
| PATCH | `/api/collections/{id}/properties/{propertyId}` | Updated property; `{version,name?,position?,config?}`. Type and date-mode changes are rejected (400) — conversion must never destroy values. Option updates send the full list: existing ids rename/recolor (identity preserved), omitted ids are removed but refused (400 with the using-item count) while any item still carries them |
| DELETE | `/api/collections/{id}/properties/{propertyId}` | `{ok:true}`; strips the property's values from all items and its clauses/sorts/grouping from saved views |
+9 -9
View File
@@ -80,9 +80,6 @@ keeping its own passwords.*
**Product features**
- Collections can't be shared — they're private to their owner.
- No page history (can't see or restore older versions).
- No trash — deletes are permanent.
- Calendar and list views for collections are stored but still display as
tables.
- Alerts only show inside the app — nothing is emailed or pushed to you.
@@ -168,15 +165,18 @@ maintenance rather than a separate milestone.
### 3–6 months: grow the product
- **Page history and trash** — see old versions, restore them; deleted
things are recoverable.
- **Page history and trash** — done: versioned history with restore, and
recoverable bookmark/page/collection deletes. Restores never resurrect a
revoked share or public link.
- **Nested pages and backlinks** — done: a real page tree, "what links here"
that never shows titles you're not allowed to see, and live collection
views embedded in pages.
- **Collection sharing** — the big one. Explicit, shown in the UI, and
revocable. Design the permission rules first (a project must never
accidentally widen who can see a bookmark), then build it. Once it
exists, cross-project "relation" columns can follow.
- **Collection sharing** — done: explicit, disclosed, revocable read-only
shares; recipients see the narrowed bookmark view, and removing an item or
revoking access removes it immediately. Cross-project "relation" columns
can follow.
- **Structured export** — done: an owner-scoped JSON archive richer than the
per-item Markdown downloads.
- **Alerts that reach you** — email or webhook when a due date hits or an
email watcher matches. Turns the app from a dashboard into a notifier.
- **RSS watchers** — "tell me when this feed mentions X." Reuses everything
+48
View File
@@ -0,0 +1,48 @@
-- 009_trust.sql — Notion-like upgrades phase 4 (trust).
--
-- Server-side page revision history, recoverable deletions, and explicit
-- collection sharing.
--
-- Trust rules:
-- * Every committed page content version is snapshotted. Restoring a
-- revision writes a NEW version (history is append-only) and the restore
-- itself is recorded.
-- * Trash is a per-owner snapshot taken in the same transaction as the
-- delete. It restores intrinsic data and owner-scoped relationships, but
-- never access grants (bookmark public flags, bookmark/tag shares,
-- collection shares). A granted access revoked while an item was in the
-- trash cannot come back with the item.
-- * Collection sharing is read-only and revocable. Recipients see member
-- bookmarks through the narrowed shared projection; mutations stay
-- owner-only. BookmarkVisible gains the explicit collection-share edge.
CREATE TABLE IF NOT EXISTS "PageRevision" (
id text PRIMARY KEY,
"pageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE,
version integer NOT NULL,
title text NOT NULL,
content text NOT NULL,
icon text,
"createdAt" timestamptz NOT NULL DEFAULT now(),
UNIQUE ("pageId", version)
);
CREATE INDEX IF NOT EXISTS "PageRevision_pageId_version_idx" ON "PageRevision"("pageId", version DESC);
CREATE TABLE IF NOT EXISTS "Trash" (
id text PRIMARY KEY,
"ownerId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE,
type text NOT NULL CHECK (type IN ('bookmark','page','collection')),
"originalId" text NOT NULL,
data jsonb NOT NULL,
"deletedAt" timestamptz NOT NULL DEFAULT now(),
UNIQUE (type, "originalId")
);
CREATE INDEX IF NOT EXISTS "Trash_ownerId_deletedAt_idx" ON "Trash"("ownerId", "deletedAt" DESC);
CREATE TABLE IF NOT EXISTS "CollectionShare" (
"collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE,
"userId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE,
"createdAt" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("collectionId", "userId")
);
CREATE INDEX IF NOT EXISTS "CollectionShare_userId_idx" ON "CollectionShare"("userId");
+45
View File
@@ -236,6 +236,22 @@ CREATE TABLE "PageLink" (
);
CREATE INDEX "PageLink_targetPageId_idx" ON "PageLink"("targetPageId");
-- 2.13c PageRevision: server-side content history. A snapshot is recorded for
-- every committed content version (including restore, which is a new version)
-- so an old draft can be inspected and restored without ever rewriting the
-- past. Revision rows cascade with their page.
CREATE TABLE "PageRevision" (
id text PRIMARY KEY,
"pageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE,
version integer NOT NULL,
title text NOT NULL,
content text NOT NULL,
icon text,
"createdAt" timestamptz NOT NULL DEFAULT now(),
UNIQUE ("pageId", version)
);
CREATE INDEX "PageRevision_pageId_version_idx" ON "PageRevision"("pageId", version DESC);
-- 2.14 SignalSource
CREATE TABLE "SignalSource" (
id text PRIMARY KEY,
@@ -294,6 +310,22 @@ CREATE TABLE "AppSetting" (
"updatedAt" timestamptz NOT NULL DEFAULT now()
);
-- 2.16b Trash: recoverable deletions (trust phase). The row is a private
-- per-owner snapshot taken in the same transaction as the hard delete. It
-- keeps intrinsic data and owner-scoped relationships, but deliberately not
-- access grants (bookmark shares, public flags, collection shares): restoring
-- an item never resurrects a grant that was revoked while it sat in the bin.
CREATE TABLE "Trash" (
id text PRIMARY KEY,
"ownerId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE,
type text NOT NULL CHECK (type IN ('bookmark','page','collection')),
"originalId" text NOT NULL,
data jsonb NOT NULL,
"deletedAt" timestamptz NOT NULL DEFAULT now(),
UNIQUE (type, "originalId")
);
CREATE INDEX "Trash_ownerId_deletedAt_idx" ON "Trash"("ownerId", "deletedAt" DESC);
-- 2.16 SharedDate
CREATE TABLE "SharedDate" (
id text PRIMARY KEY,
@@ -325,6 +357,19 @@ CREATE TABLE "Collection" (
);
CREATE INDEX "Collection_ownerId_idx" ON "Collection"("ownerId");
-- 2.17b CollectionShare: explicit, revocable read access to a collection.
-- Recipients get the narrowed bookmark projection for member bookmarks (via
-- BookmarkVisible); sharing therefore widens access only by the owner's
-- deliberate action, is disclosed in the share UI, and revocation removes
-- access immediately. Mutations remain owner-only.
CREATE TABLE "CollectionShare" (
"collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE,
"userId" text NOT NULL REFERENCES "User"(id) ON DELETE CASCADE,
"createdAt" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("collectionId", "userId")
);
CREATE INDEX "CollectionShare_userId_idx" ON "CollectionShare"("userId");
CREATE TABLE "CollectionProperty" (
id text PRIMARY KEY,
"collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE,
+7
View File
@@ -41,4 +41,11 @@ const BookmarkViewerView = `CASE WHEN b."ownerId"=$2 THEN ` + BookmarkOwnerView
// it, including bookmarks other recipients added. The share UI says so.
const BookmarkVisible = `(b."ownerId"=$2 OR EXISTS(SELECT 1 FROM "BookmarkShare" s WHERE s."bookmarkId"=b.id AND s."userId"=$2)
OR EXISTS(SELECT 1 FROM "BookmarkTag" bt JOIN "TagShare" ts ON ts."tagId"=bt."tagId" WHERE bt."bookmarkId"=b.id AND ts."userId"=$2)
-- Explicit collection sharing (trust phase): membership in a collection the
-- bookmark's OWNER deliberately shared grants the narrowed bookmark
-- projection. The owner match is essential: a collection can contain
-- bookmarks merely shared with its owner, and sharing that collection must
-- not widen a third party's grant. Removing the item or revoking the share
-- removes this edge immediately.
OR EXISTS(SELECT 1 FROM "CollectionItem" ci JOIN "Collection" c ON c.id=ci."collectionId" JOIN "CollectionShare" cs ON cs."collectionId"=ci."collectionId" WHERE ci."bookmarkId"=b.id AND cs."userId"=$2 AND c."ownerId"=b."ownerId")
OR EXISTS(SELECT 1 FROM "SharedDate" sd WHERE sd."creatorId"=b."ownerId" AND sd."recipientId"=$2 AND (b."dueDate" AT TIME ZONE 'UTC')::date=sd.date))`
+9 -1
View File
@@ -363,7 +363,15 @@ func HandleBookmarksUpdate(w http.ResponseWriter, r *http.Request) {
respond(w, 200, v, e)
}
func HandleBookmarksDelete(w http.ResponseWriter, r *http.Request) {
e := remove(r, "Bookmark", r.PathValue("id"), "ownerId")
// Recoverable delete (trust phase): snapshot intrinsic fields and
// owner-scoped links in the same transaction as the hard delete. Shares
// and the public flag are deliberately not restored.
e := db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := lockUser(r, tx); e != nil {
return e
}
return trashBookmark(r, tx, r.PathValue("id"))
})
respond(w, 200, OK{true}, e)
}
+81 -8
View File
@@ -63,6 +63,15 @@ var (
func collectionOwned(r *http.Request, q db.Queryer, id string) (object, error) {
return db.One(r.Context(), q, `SELECT to_jsonb(c) FROM "Collection" c WHERE c.id=$1 AND c."ownerId"=$2`, id, user(r))
}
// collectionReadable admits the owner and explicit recipients of a
// CollectionShare. Mutating surfaces keep using collectionOwned, so a shared
// reader can never rename, delete, reorder, or edit collection content.
func collectionReadable(r *http.Request, q db.Queryer, id string) (object, error) {
return db.One(r.Context(), q, `SELECT to_jsonb(c)||jsonb_build_object('shared',c."ownerId"<>$2)
FROM "Collection" c WHERE c.id=$1 AND (c."ownerId"=$2 OR EXISTS(
SELECT 1 FROM "CollectionShare" s WHERE s."collectionId"=c.id AND s."userId"=$2))`, id, user(r))
}
func collectionProperties(r *http.Request, q db.Queryer, collectionID string) ([]object, error) {
return db.Many(r.Context(), q, `SELECT to_jsonb(p) FROM "CollectionProperty" p WHERE p."collectionId"=$1 ORDER BY p.position,p.id`, collectionID)
}
@@ -99,6 +108,19 @@ func collectionDetail(r *http.Request, q db.Queryer, col object) (object, error)
out[k] = v
}
out["properties"], out["views"], out["_count"] = props, views, counts
shared := str(col, "ownerId") != user(r)
out["shared"] = shared
if shared {
// Recipients see who shared it, but never the other recipients.
out["owner"], _ = db.One(r.Context(), q, `SELECT jsonb_build_object('id',u.id,'name',u.name,'email',u.email,'image',u.image) FROM "User" u WHERE u.id=$1`, str(col, "ownerId"))
out["sharedWith"] = []object{}
} else {
shares, e := db.Many(r.Context(), q, `SELECT jsonb_build_object('userId',s."userId",'email',u.email,'name',u.name,'createdAt',s."createdAt") FROM "CollectionShare" s JOIN "User" u ON u.id=s."userId" WHERE s."collectionId"=$1 ORDER BY s."createdAt"`, str(col, "id"))
if e != nil {
return nil, e
}
out["sharedWith"] = shares
}
return out, nil
}
@@ -159,11 +181,18 @@ func HandleCollectionsList(w http.ResponseWriter, r *http.Request) {
// Item counts are authorized counts (N-10 edge case): a bookmark the
// caller can no longer see never inflates them. Collections are
// owner-scoped in phase 2, so $1 (owner) and $2 (BookmarkVisible) match.
v, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(c)||jsonb_build_object('_count',jsonb_build_object(
// Owner collections and collections explicitly shared with this user.
// `shared` is disclosed by the UI; item counts are authorized counts and
// therefore only include bookmarks the caller may see.
v, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(c)||jsonb_build_object(
'shared',c."ownerId"<>$1,
'owner',(SELECT jsonb_build_object('id',u.id,'name',u.name,'email',u.email,'image',u.image) FROM "User" u WHERE u.id=c."ownerId"),
'_count',jsonb_build_object(
'items',(SELECT count(*) FROM "CollectionItem" ci JOIN "Bookmark" b ON b.id=ci."bookmarkId" WHERE ci."collectionId"=c.id AND `+db.BookmarkVisible+`),
'properties',(SELECT count(*) FROM "CollectionProperty" p WHERE p."collectionId"=c.id),
'views',(SELECT count(*) FROM "CollectionView" v WHERE v."collectionId"=c.id)))
FROM "Collection" c WHERE c."ownerId"=$1 ORDER BY c."createdAt",c.id`, user(r), user(r))
FROM "Collection" c WHERE c."ownerId"=$1 OR EXISTS(SELECT 1 FROM "CollectionShare" s WHERE s."collectionId"=c.id AND s."userId"=$1)
ORDER BY (c."ownerId"<>$1),c."createdAt",c.id`, user(r), user(r))
respond(w, 200, v, e)
}
@@ -204,7 +233,7 @@ func HandleCollectionsCreate(w http.ResponseWriter, r *http.Request) {
}
func HandleCollectionsGet(w http.ResponseWriter, r *http.Request) {
col, e := collectionOwned(r, database(r), r.PathValue("id"))
col, e := collectionReadable(r, database(r), r.PathValue("id"))
if e != nil {
respond(w, 0, nil, e)
return
@@ -252,8 +281,51 @@ func HandleCollectionsUpdate(w http.ResponseWriter, r *http.Request) {
func HandleCollectionsDelete(w http.ResponseWriter, r *http.Request) {
// Memberships, properties and views cascade; bookmarks are never touched
// (N-01 acceptance criterion).
respond(w, 200, OK{true}, remove(r, "Collection", r.PathValue("id"), "ownerId"))
// (N-01 acceptance criterion). The collection structure is snapshotted for
// restore; shares are not, so a revoked grant never comes back.
e := db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := lockUser(r, tx); e != nil {
return e
}
return trashCollection(r, tx, r.PathValue("id"))
})
respond(w, 200, OK{true}, e)
}
// HandleCollectionShare grants read-only access to explicitly named users.
// The share is disclosed in the collection UI and revocable; recipients get
// the narrowed bookmark projection for member bookmarks and cannot mutate the
// collection or its items. Sharing an already-shared user is idempotent.
func HandleCollectionShare(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, e := collectionOwned(r, database(r), id); e != nil {
respond(w, 0, nil, e)
return
}
b, ok := read(w, r)
if !ok {
return
}
list, e := ids(b, "userIds")
if e != nil {
respond(w, 0, nil, e)
return
}
// Sharing with yourself is meaningless; skip it rather than creating an
// ownership-adjacent row that would confuse the recipient list.
filtered := make([]string, 0, len(list))
for _, uid := range list {
if uid != user(r) {
filtered = append(filtered, uid)
}
}
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := lockUser(r, tx); e != nil {
return e
}
return shareRows(r, tx, "CollectionShare", "collectionId", id, filtered)
})
respond(w, 200, OK{true}, e)
}
// --- properties -----------------------------------------------------------
@@ -1203,8 +1275,9 @@ func HandleCollectionItemsList(w http.ResponseWriter, r *http.Request) {
}
limit = n
}
// Ownership is the gate: a foreign collection is a 404, not an empty list.
if _, e := collectionOwned(r, database(r), cid); e != nil {
// Owner or explicit share is the read gate: a foreign collection is a
// 404, not an empty list. Mutations below still require ownership.
if _, e := collectionReadable(r, database(r), cid); e != nil {
respond(w, 0, nil, e)
return
}
@@ -1217,7 +1290,7 @@ func HandleCollectionItemsList(w http.ResponseWriter, r *http.Request) {
// $1 stays a permanently-nil placeholder (BookmarkVisible binds $2), the
// same port artifact the bookmarks list documents.
args := []any{nil, user(r), cid}
where := `c."ownerId"=$2 AND ci."collectionId"=$3 AND ` + db.BookmarkVisible + ` AND $1::text IS NULL`
where := `(c."ownerId"=$2 OR EXISTS(SELECT 1 FROM "CollectionShare" cs WHERE cs."collectionId"=c.id AND cs."userId"=$2)) AND ci."collectionId"=$3 AND ` + db.BookmarkVisible + ` AND $1::text IS NULL`
var sorts []any
if viewID := sp.Get("viewId"); viewID != "" {
view, e2 := db.One(r.Context(), database(r), `SELECT to_jsonb(v) FROM "CollectionView" v WHERE v.id=$1 AND v."collectionId"=$2`, viewID, cid)
+83
View File
@@ -0,0 +1,83 @@
package handlers
import (
"net/http"
"time"
"github.com/andrew/go-computer/internal/db"
)
// Structured export (trust phase): a single owner-scoped JSON archive that is
// richer than the per-item Markdown downloads. It deliberately excludes
// credentials (IMAP config, CardDAV passwords) and includes only data the
// caller owns or may read through the owner projection.
func HandleExport(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid := user(r)
me, e := db.One(ctx, database(r), `SELECT jsonb_build_object('id',u.id,'name',u.name,'email',u.email,'role',u.role,'createdAt',u."createdAt") FROM "User" u WHERE u.id=$1`, uid)
if e != nil {
respond(w, 0, nil, e)
return
}
bookmarks, e := db.Many(ctx, database(r), `SELECT `+db.BookmarkOwnerView+` FROM "Bookmark" b WHERE b."ownerId"=$1 ORDER BY b."createdAt",b.id`, uid)
if e != nil {
respond(w, 0, nil, e)
return
}
tags, e := db.Many(ctx, database(r), `SELECT to_jsonb(t)||jsonb_build_object('sharedWith',COALESCE((SELECT jsonb_agg(jsonb_build_object('userId',s."userId",'email',u.email,'name',u.name)) FROM "TagShare" s JOIN "User" u ON u.id=s."userId" WHERE s."tagId"=t.id),'[]'::jsonb)) FROM "Tag" t WHERE t."ownerId"=$1 ORDER BY t."createdAt",t.id`, uid)
if e != nil {
respond(w, 0, nil, e)
return
}
pages, e := db.Many(ctx, database(r), `SELECT to_jsonb(p)||jsonb_build_object(
'bookmarks',COALESCE((SELECT jsonb_agg(to_jsonb(pb)||jsonb_build_object('bookmark',`+db.BookmarkOwnerView+`) ORDER BY pb.position) FROM "PageBookmark" pb JOIN "Bookmark" b ON b.id=pb."bookmarkId" WHERE pb."pageId"=p.id AND b."ownerId"=$1),'[]'::jsonb),
'revisions',COALESCE((SELECT jsonb_agg(to_jsonb(r) ORDER BY r.version) FROM "PageRevision" r WHERE r."pageId"=p.id),'[]'::jsonb))
FROM "Page" p WHERE p."ownerId"=$1 ORDER BY p."createdAt",p.id`, uid)
if e != nil {
respond(w, 0, nil, e)
return
}
contacts, e := db.Many(ctx, database(r), `SELECT to_jsonb(c) FROM "Contact" c WHERE c."userId"=$1 ORDER BY c."displayName",c.id`, uid)
if e != nil {
respond(w, 0, nil, e)
return
}
collections, e := db.Many(ctx, database(r), `SELECT to_jsonb(c) FROM "Collection" c WHERE c."ownerId"=$1 ORDER BY c."createdAt",c.id`, uid)
if e != nil {
respond(w, 0, nil, e)
return
}
exported := make([]object, 0, len(collections))
for _, col := range collections {
detail, e := collectionDetail(r, database(r), col)
if e != nil {
respond(w, 0, nil, e)
return
}
cid := str(col, "id")
items, e := db.Many(ctx, database(r), `SELECT to_jsonb(ci)||jsonb_build_object('bookmark',`+db.BookmarkOwnerView+`) FROM "CollectionItem" ci JOIN "Bookmark" b ON b.id=ci."bookmarkId" WHERE ci."collectionId"=$1 ORDER BY ci.position,ci.id`, cid)
if e != nil {
respond(w, 0, nil, e)
return
}
shares, e := db.Many(ctx, database(r), `SELECT jsonb_build_object('userId',s."userId",'email',u.email,'name',u.name,'createdAt',s."createdAt") FROM "CollectionShare" s JOIN "User" u ON u.id=s."userId" WHERE s."collectionId"=$1 ORDER BY s."createdAt"`, cid)
if e != nil {
respond(w, 0, nil, e)
return
}
detail["items"], detail["sharedWith"] = items, shares
exported = append(exported, detail)
}
w.Header().Set("Content-Disposition", `attachment; filename="computer-export.json"`)
respond(w, 200, object{
"format": "computer-export",
"version": 1,
"exportedAt": time.Now().UTC(),
"user": me,
"bookmarks": bookmarks,
"tags": tags,
"pages": pages,
"collections": exported,
"contacts": contacts,
}, nil)
}
+95
View File
@@ -0,0 +1,95 @@
package handlers
import (
"database/sql"
"net/http"
"time"
"github.com/andrew/go-computer/internal/db"
)
// Page revision history (trust phase). Every committed content version gets
// an immutable snapshot; restoring an old snapshot writes a new version rather
// than rewriting history. Snapshots are owner-scoped through their page.
func capturePageRevision(r *http.Request, tx *sql.Tx, pageID string) error {
_, e := tx.ExecContext(r.Context(), `INSERT INTO "PageRevision"(id,"pageId",version,title,content,icon)
SELECT $1,p.id,p.version,p.title,p.content,p.icon FROM "Page" p WHERE p.id=$2
ON CONFLICT ("pageId",version) DO NOTHING`, db.NewID(), pageID)
return e
}
func revisionOwned(r *http.Request, q db.Queryer, pageID, revisionID string) (object, error) {
if _, e := owned(r, q, "Page", pageID, "ownerId"); e != nil {
return nil, e
}
return db.One(r.Context(), q, `SELECT to_jsonb(r) FROM "PageRevision" r WHERE r.id=$1 AND r."pageId"=$2`, revisionID, pageID)
}
// HandlePageRevisionsList returns metadata only — the editor fetches a body on
// demand (a page can have a long history and each body is up to 512 KiB).
func HandlePageRevisionsList(w http.ResponseWriter, r *http.Request) {
pageID := r.PathValue("id")
if _, e := owned(r, database(r), "Page", pageID, "ownerId"); e != nil {
respond(w, 0, nil, e)
return
}
v, e := db.Many(r.Context(), database(r), `SELECT jsonb_build_object(
'id',r.id,'version',r.version,'title',r.title,'icon',r.icon,
'createdAt',r."createdAt",'length',length(r.content))
FROM "PageRevision" r WHERE r."pageId"=$1 ORDER BY r.version DESC`, pageID)
respond(w, 200, v, e)
}
func HandlePageRevisionsGet(w http.ResponseWriter, r *http.Request) {
v, e := revisionOwned(r, database(r), r.PathValue("id"), r.PathValue("revisionId"))
respond(w, 200, v, e)
}
// HandlePageRevisionsRestore copies a snapshot into the live page as a new
// version. It requires the current base version (the same DATA-05 rule as a
// content save), so restoring while another tab writes is a 409, not a silent
// overwrite. The restored state itself becomes a history entry.
func HandlePageRevisionsRestore(w http.ResponseWriter, r *http.Request) {
pageID, revisionID := r.PathValue("id"), r.PathValue("revisionId")
b, ok := read(w, r)
if !ok {
return
}
baseVersion, e := patchVersion(b)
if e != nil {
respond(w, 0, nil, e)
return
}
var v object
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := lockUser(r, tx); e != nil {
return e
}
current, e := owned(r, tx, "Page", pageID, "ownerId")
if e != nil {
return e
}
if stored, _ := current["version"].(float64); int(stored) != baseVersion {
return conflict("Page was modified elsewhere; reload to see the latest version")
}
rev, e := db.One(r.Context(), tx, `SELECT to_jsonb(r) FROM "PageRevision" r WHERE r.id=$1 AND r."pageId"=$2`, revisionID, pageID)
if e != nil {
return e
}
if _, e := tx.ExecContext(r.Context(), `UPDATE "Page" SET title=$1,content=$2,icon=$3,version=version+1,"updatedAt"=$4 WHERE id=$5 AND "ownerId"=$6`,
str(rev, "title"), str(rev, "content"), rev["icon"], time.Now().UTC(), pageID, user(r)); e != nil {
return e
}
// Restoring content also restores its derived graph. Targets that no
// longer exist (or are no longer owned) are ignored by syncPageGraph.
if e := syncPageGraph(r.Context(), tx, pageID, user(r), str(rev, "content")); e != nil {
return e
}
if e := capturePageRevision(r, tx, pageID); e != nil {
return e
}
v, e = pageView(r.Context(), tx, pageID, user(r))
return e
})
respond(w, 200, v, e)
}
+15 -2
View File
@@ -121,6 +121,9 @@ func HandlePagesCreate(w http.ResponseWriter, r *http.Request) {
return e
}
}
if e := capturePageRevision(r, tx, d["id"].(string)); e != nil {
return e
}
// Acknowledge the same snapshot the write produced (the update path's
// CR-18 discipline), so the 201 already carries links/embeds.
v, e = pageView(r.Context(), tx, d["id"].(string), user(r))
@@ -218,6 +221,9 @@ func HandlePagesUpdate(w http.ResponseWriter, r *http.Request) {
if e := syncPageGraph(r.Context(), tx, id, user(r), content); e != nil {
return e
}
if e := capturePageRevision(r, tx, id); e != nil {
return e
}
}
if bid := str(b, "addBookmarkId"); bid != "" {
if _, e := db.One(r.Context(), tx, `SELECT to_jsonb(b) FROM "Bookmark" b WHERE b.id=$1 AND `+db.BookmarkVisible, bid, user(r)); e != nil {
@@ -254,8 +260,15 @@ func HandlePagesBacklinks(w http.ResponseWriter, r *http.Request) {
}
func HandlePagesDelete(w http.ResponseWriter, r *http.Request) {
// Children are promoted to the top level by the self-FK's SET NULL; a
// delete never silently takes nested pages with it.
respond(w, 200, OK{true}, remove(r, "Page", r.PathValue("id"), "ownerId"))
// delete never silently takes nested pages with it. The page, its history
// and its bookmark links are snapshotted for restore.
e := db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := lockUser(r, tx); e != nil {
return e
}
return trashPage(r, tx, r.PathValue("id"))
})
respond(w, 200, OK{true}, e)
}
func HandlePagesMarkdown(w http.ResponseWriter, r *http.Request) {
v, e := page(r, r.PathValue("id"))
+364
View File
@@ -0,0 +1,364 @@
package handlers
import (
"database/sql"
"encoding/json"
"net/http"
"github.com/andrew/go-computer/internal/db"
)
// Recoverable deletions (trust phase).
//
// Trash stores a private, per-owner JSON snapshot taken in the same
// transaction as the hard delete. Restoring inserts the intrinsic record and
// owner-scoped relationships, but deliberately NOT access grants:
// - bookmark isPublic is forced false and bookmark/tag shares are not
// snapshotted, so a restored bookmark never resurrects a public link or a
// share removed while it was in the trash;
// - collection shares are not snapshotted;
// - server-driven watcher rows are not snapshotted.
// Contextual collection memberships and page links are owner-scoped data and
// are restored when their parent still exists.
func jsonSnapshot(v any) (string, error) {
raw, e := json.Marshal(v)
return string(raw), e
}
func addTrash(r *http.Request, tx *sql.Tx, kind, originalID string, data object) error {
raw, e := jsonSnapshot(data)
if e != nil {
return e
}
_, e = db.Insert(r.Context(), tx, "Trash", object{
"id": db.NewID(), "ownerId": user(r), "type": kind,
"originalId": originalID, "data": raw,
})
return e
}
func deleteOwnedRow(r *http.Request, tx *sql.Tx, table, id, ownerCol string, deleted *int64) error {
res, e := tx.ExecContext(r.Context(), `DELETE FROM `+db.Quote(table)+` WHERE id=$1 AND `+db.Quote(ownerCol)+`=$2`, id, user(r))
if e != nil {
return e
}
n, _ := res.RowsAffected()
if n == 0 {
return db.ErrNotFound
}
if deleted != nil {
*deleted = n
}
return nil
}
// --- delete snapshots -----------------------------------------------------
func trashBookmark(r *http.Request, tx *sql.Tx, id string) error {
row, e := owned(r, tx, "Bookmark", id, "ownerId")
if e != nil {
return e
}
tags, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('tagId',bt."tagId") FROM "BookmarkTag" bt JOIN "Tag" t ON t.id=bt."tagId" WHERE bt."bookmarkId"=$1 AND (t."ownerId"=$2 OR EXISTS(SELECT 1 FROM "TagShare" s WHERE s."tagId"=t.id AND s."userId"=$2))`, id, user(r))
if e != nil {
return e
}
pageLinks, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('pageId',pb."pageId",'position',pb.position) FROM "PageBookmark" pb JOIN "Page" p ON p.id=pb."pageId" WHERE pb."bookmarkId"=$1 AND p."ownerId"=$2`, id, user(r))
if e != nil {
return e
}
collectionItems, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('id',ci.id,'collectionId',ci."collectionId",'bookmarkId',ci."bookmarkId",'propertyValues',ci."propertyValues",'position',ci.position,'version',ci.version,'addedAt',ci."addedAt",'updatedAt',ci."updatedAt") FROM "CollectionItem" ci JOIN "Collection" c ON c.id=ci."collectionId" WHERE ci."bookmarkId"=$1 AND c."ownerId"=$2`, id, user(r))
if e != nil {
return e
}
if e := addTrash(r, tx, "bookmark", id, object{
"bookmark": row, "tags": tags, "pageLinks": pageLinks, "collectionItems": collectionItems,
}); e != nil {
return e
}
return deleteOwnedRow(r, tx, "Bookmark", id, "ownerId", nil)
}
func trashPage(r *http.Request, tx *sql.Tx, id string) error {
row, e := owned(r, tx, "Page", id, "ownerId")
if e != nil {
return e
}
bookmarks, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('bookmarkId',pb."bookmarkId",'position',pb.position) FROM "PageBookmark" pb WHERE pb."pageId"=$1`, id)
if e != nil {
return e
}
revisions, e := db.Many(r.Context(), tx, `SELECT to_jsonb(r) FROM "PageRevision" r WHERE r."pageId"=$1 ORDER BY r.version`, id)
if e != nil {
return e
}
if e := addTrash(r, tx, "page", id, object{"page": row, "bookmarks": bookmarks, "revisions": revisions}); e != nil {
return e
}
return deleteOwnedRow(r, tx, "Page", id, "ownerId", nil)
}
func trashCollection(r *http.Request, tx *sql.Tx, id string) error {
row, e := owned(r, tx, "Collection", id, "ownerId")
if e != nil {
return e
}
properties, e := db.Many(r.Context(), tx, `SELECT to_jsonb(p) FROM "CollectionProperty" p WHERE p."collectionId"=$1 ORDER BY p.position,p.id`, id)
if e != nil {
return e
}
views, e := db.Many(r.Context(), tx, `SELECT to_jsonb(v) FROM "CollectionView" v WHERE v."collectionId"=$1 ORDER BY v.position,v.id`, id)
if e != nil {
return e
}
items, e := db.Many(r.Context(), tx, `SELECT to_jsonb(ci) FROM "CollectionItem" ci WHERE ci."collectionId"=$1 ORDER BY ci.position,ci.id`, id)
if e != nil {
return e
}
if e := addTrash(r, tx, "collection", id, object{
"collection": row, "properties": properties, "views": views, "items": items,
}); e != nil {
return e
}
return deleteOwnedRow(r, tx, "Collection", id, "ownerId", nil)
}
// --- restore --------------------------------------------------------------
func objectValue(v any) object {
if m, ok := v.(map[string]any); ok {
return m
}
return nil
}
func objectList(v any) []object {
arr, _ := v.([]any)
out := make([]object, 0, len(arr))
for _, x := range arr {
if m, ok := x.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
func insertSnapshot(r *http.Request, tx *sql.Tx, table, kind string, row object) error {
raw, e := jsonSnapshot(row)
if e != nil {
return e
}
_, e = tx.ExecContext(r.Context(), `INSERT INTO `+db.Quote(table)+` SELECT * FROM jsonb_populate_record(NULL::`+db.Quote(table)+`, $1::jsonb)`, raw)
return e
}
func restoreBookmark(r *http.Request, tx *sql.Tx, data object) (string, error) {
row := objectValue(data["bookmark"])
if row == nil {
return "", bad("Trash entry is damaged")
}
id := str(row, "id")
// Restore access decisions to their safe default. A public link or a
// direct share is a grant, not intrinsic content.
row["isPublic"] = false
for _, pair := range [][2]string{{"contactId", "Contact"}, {"kanbanColumnId", "KanbanColumn"}} {
if ref := str(row, pair[0]); ref != "" {
col := "userId"
var exists bool
if e := tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM `+db.Quote(pair[1])+` WHERE id=$1 AND `+db.Quote(col)+`=$2)`, ref, user(r)).Scan(&exists); e != nil {
return "", e
}
if !exists {
row[pair[0]] = nil
}
}
}
if e := insertSnapshot(r, tx, "Bookmark", "bookmark", row); e != nil {
return "", e
}
for _, t := range objectList(data["tags"]) {
tagID := str(t, "tagId")
var exists bool
if e := tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM "Tag" t WHERE t.id=$1 AND (t."ownerId"=$2 OR EXISTS(SELECT 1 FROM "TagShare" s WHERE s."tagId"=t.id AND s."userId"=$2)))`, tagID, user(r)).Scan(&exists); e != nil {
return "", e
}
if exists {
if _, e := tx.ExecContext(r.Context(), `INSERT INTO "BookmarkTag"("bookmarkId","tagId") VALUES($1,$2) ON CONFLICT DO NOTHING`, id, tagID); e != nil {
return "", e
}
}
}
for _, l := range objectList(data["pageLinks"]) {
var exists bool
if e := tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM "Page" WHERE id=$1 AND "ownerId"=$2)`, str(l, "pageId"), user(r)).Scan(&exists); e != nil {
return "", e
}
if !exists {
continue
}
if _, e := tx.ExecContext(r.Context(), `INSERT INTO "PageBookmark"("pageId","bookmarkId",position) VALUES($1,$2,$3) ON CONFLICT DO NOTHING`, str(l, "pageId"), id, intFieldValue(l, "position")); e != nil {
return "", e
}
}
for _, item := range objectList(data["collectionItems"]) {
var exists bool
if e := tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM "Collection" WHERE id=$1 AND "ownerId"=$2)`, str(item, "collectionId"), user(r)).Scan(&exists); e != nil {
return "", e
}
if !exists {
continue
}
if e := insertSnapshot(r, tx, "CollectionItem", "collectionItem", item); e != nil {
return "", e
}
}
return id, nil
}
func intFieldValue(b object, key string) int {
if f, ok := b[key].(float64); ok {
return int(f)
}
return 0
}
func restorePage(r *http.Request, tx *sql.Tx, data object) (string, error) {
row := objectValue(data["page"])
if row == nil {
return "", bad("Trash entry is damaged")
}
id := str(row, "id")
parent, e := pageParent(r.Context(), tx, id, user(r), row["parentId"])
if e != nil {
// A parent deleted while this page sat in the trash simply promotes
// the restored page to the top level.
if _, ok := e.(clientError); !ok {
return "", e
}
parent = nil
}
row["parentId"] = parent
if e := insertSnapshot(r, tx, "Page", "page", row); e != nil {
return "", e
}
for _, l := range objectList(data["bookmarks"]) {
var visible bool
if e := tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM "Bookmark" b WHERE b.id=$1 AND `+db.BookmarkVisible+`)`, str(l, "bookmarkId"), user(r)).Scan(&visible); e != nil {
return "", e
}
if !visible {
continue
}
if _, e := tx.ExecContext(r.Context(), `INSERT INTO "PageBookmark"("pageId","bookmarkId",position) VALUES($1,$2,$3) ON CONFLICT DO NOTHING`, id, str(l, "bookmarkId"), intFieldValue(l, "position")); e != nil {
return "", e
}
}
for _, rev := range objectList(data["revisions"]) {
if e := insertSnapshot(r, tx, "PageRevision", "pageRevision", rev); e != nil {
return "", e
}
}
if e := syncPageGraph(r.Context(), tx, id, user(r), str(row, "content")); e != nil {
return "", e
}
// A restore is a new trust event: bump the revision and record it, so the
// state that came back from the trash is itself auditable.
if _, e := tx.ExecContext(r.Context(), `UPDATE "Page" SET version=version+1,"updatedAt"=now() WHERE id=$1 AND "ownerId"=$2`, id, user(r)); e != nil {
return "", e
}
if e := capturePageRevision(r, tx, id); e != nil {
return "", e
}
return id, nil
}
func restoreCollection(r *http.Request, tx *sql.Tx, data object) (string, error) {
row := objectValue(data["collection"])
if row == nil {
return "", bad("Trash entry is damaged")
}
id := str(row, "id")
if e := insertSnapshot(r, tx, "Collection", "collection", row); e != nil {
return "", e
}
for _, prop := range objectList(data["properties"]) {
if e := insertSnapshot(r, tx, "CollectionProperty", "collectionProperty", prop); e != nil {
return "", e
}
}
for _, view := range objectList(data["views"]) {
if e := insertSnapshot(r, tx, "CollectionView", "collectionView", view); e != nil {
return "", e
}
}
for _, item := range objectList(data["items"]) {
var visible bool
if e := tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM "Bookmark" b WHERE b.id=$1 AND `+db.BookmarkVisible+`)`, str(item, "bookmarkId"), user(r)).Scan(&visible); e != nil {
return "", e
}
if !visible {
continue
}
if e := insertSnapshot(r, tx, "CollectionItem", "collectionItem", item); e != nil {
return "", e
}
}
return id, nil
}
// --- HTTP surfaces --------------------------------------------------------
func HandleTrashList(w http.ResponseWriter, r *http.Request) {
v, e := db.Many(r.Context(), database(r), `SELECT jsonb_build_object(
'id',t.id,'type',t.type,'originalId',t."originalId",'deletedAt',t."deletedAt",
'title',COALESCE(
NULLIF(t.data->'bookmark'->>'title',''),
t.data->'bookmark'->>'url',
NULLIF(t.data->'page'->>'title',''),
t.data->'collection'->>'name',
'Untitled'))
FROM "Trash" t WHERE t."ownerId"=$1 ORDER BY t."deletedAt" DESC,t.id`, user(r))
respond(w, 200, v, e)
}
func HandleTrashRestore(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var kind, originalID string
e := db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := lockUser(r, tx); e != nil {
return e
}
entry, e := db.One(r.Context(), tx, `SELECT to_jsonb(t) FROM "Trash" t WHERE t.id=$1 AND t."ownerId"=$2 FOR UPDATE`, id, user(r))
if e != nil {
return e
}
kind, originalID = str(entry, "type"), str(entry, "originalId")
data := objectValue(entry["data"])
if data == nil {
return bad("Trash entry is damaged")
}
switch kind {
case "bookmark":
_, e = restoreBookmark(r, tx, data)
case "page":
_, e = restorePage(r, tx, data)
case "collection":
_, e = restoreCollection(r, tx, data)
default:
return bad("Unknown trash entry type")
}
if e != nil {
return e
}
_, e = tx.ExecContext(r.Context(), `DELETE FROM "Trash" WHERE id=$1 AND "ownerId"=$2`, id, user(r))
return e
})
respond(w, 200, object{"ok": true, "type": kind, "id": originalID}, e)
}
func HandleTrashPurge(w http.ResponseWriter, r *http.Request) {
// Permanent removal is explicit (the default delete moves to the trash).
_, e := database(r).ExecContext(r.Context(), `DELETE FROM "Trash" WHERE id=$1 AND "ownerId"=$2`, r.PathValue("id"), user(r))
respond(w, 200, OK{true}, e)
}
+3
View File
@@ -20,6 +20,9 @@ func HandleBookmarkUnshare(w http.ResponseWriter, r *http.Request) {
func HandleTagUnshare(w http.ResponseWriter, r *http.Request) {
unshare(w, r, "Tag", "TagShare", "tagId")
}
func HandleCollectionUnshare(w http.ResponseWriter, r *http.Request) {
unshare(w, r, "Collection", "CollectionShare", "collectionId")
}
func HandleSharedDateDelete(w http.ResponseWriter, r *http.Request) {
respond(w, 200, OK{true}, remove(r, "SharedDate", r.PathValue("id"), "creatorId"))
}
+11
View File
@@ -89,8 +89,17 @@ func NewServer(cfg *config.Config, database *db.DB) *http.Server {
mux.HandleFunc("PATCH /api/pages/{id}", authed(handlers.HandlePagesUpdate))
mux.HandleFunc("DELETE /api/pages/{id}", authed(handlers.HandlePagesDelete))
mux.HandleFunc("GET /api/pages/{id}/backlinks", authed(handlers.HandlePagesBacklinks))
mux.HandleFunc("GET /api/pages/{id}/revisions", authed(handlers.HandlePageRevisionsList))
mux.HandleFunc("GET /api/pages/{id}/revisions/{revisionId}", authed(handlers.HandlePageRevisionsGet))
mux.HandleFunc("POST /api/pages/{id}/revisions/{revisionId}/restore", authed(handlers.HandlePageRevisionsRestore))
mux.HandleFunc("GET /api/pages/{id}/markdown", authed(handlers.HandlePagesMarkdown))
// Trust phase: recoverable deletions and structured export.
mux.HandleFunc("GET /api/trash", authed(handlers.HandleTrashList))
mux.HandleFunc("POST /api/trash/{id}/restore", authed(handlers.HandleTrashRestore))
mux.HandleFunc("DELETE /api/trash/{id}", authed(handlers.HandleTrashPurge))
mux.HandleFunc("GET /api/export", authed(handlers.HandleExport))
// 4.7 Signals
mux.HandleFunc("GET /api/bookmarks/{id}/signals", authed(handlers.HandleSignalsList))
mux.HandleFunc("POST /api/bookmarks/{id}/signals", authed(handlers.HandleSignalsCreate))
@@ -118,6 +127,8 @@ func NewServer(cfg *config.Config, database *db.DB) *http.Server {
mux.HandleFunc("GET /api/collections/{id}", authed(handlers.HandleCollectionsGet))
mux.HandleFunc("PATCH /api/collections/{id}", authed(handlers.HandleCollectionsUpdate))
mux.HandleFunc("DELETE /api/collections/{id}", authed(handlers.HandleCollectionsDelete))
mux.HandleFunc("POST /api/collections/{id}/share", authed(handlers.HandleCollectionShare))
mux.HandleFunc("DELETE /api/collections/{id}/share/{userId}", authed(handlers.HandleCollectionUnshare))
mux.HandleFunc("POST /api/collections/{id}/properties", authed(handlers.HandleCollectionPropertiesCreate))
mux.HandleFunc("PATCH /api/collections/{id}/properties/{propertyId}", authed(handlers.HandleCollectionPropertiesUpdate))
mux.HandleFunc("DELETE /api/collections/{id}/properties/{propertyId}", authed(handlers.HandleCollectionPropertiesDelete))
+281
View File
@@ -0,0 +1,281 @@
package http
import (
"testing"
)
// Trust phase: page revision history, recoverable deletions that never
// resurrect access grants, structured export, and explicit collection sharing.
func TestPageRevisionHistory(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "trust-history@computer.test")
bob := newAPIUser(t, srv, "trust-history-other@computer.test")
page := alice.request("POST", "/api/pages", map[string]any{"title": "History", "content": "version one"}, 201)["id"].(string)
revisions := alice.getJSON("/api/pages/"+page+"/revisions", 200)
if len(revisions) != 1 || revisions[0]["version"].(float64) != 1 || revisions[0]["content"] != nil {
t.Fatalf("initial revision metadata: %v", revisions)
}
first := revisions[0]["id"].(string)
body := alice.request("GET", "/api/pages/"+page+"/revisions/"+first, nil, 200)
if body["content"] != "version one" {
t.Fatalf("revision body: %v", body)
}
alice.request("PATCH", "/api/pages/"+page, map[string]any{"content": "version two", "version": 1}, 200)
revisions = alice.getJSON("/api/pages/"+page+"/revisions", 200)
if len(revisions) != 2 || revisions[0]["version"].(float64) != 2 {
t.Fatalf("history did not append: %v", revisions)
}
// Restore writes a NEW version rather than rewriting history.
restored := alice.request("POST", "/api/pages/"+page+"/revisions/"+first+"/restore", map[string]any{"version": 2}, 200)
if restored["content"] != "version one" || restored["version"].(float64) != 3 {
t.Fatalf("restore did not create a new version: %v", restored)
}
revisions = alice.getJSON("/api/pages/"+page+"/revisions", 200)
if len(revisions) != 3 || revisions[0]["version"].(float64) != 3 {
t.Fatalf("restore was not recorded as history: %v", revisions)
}
if got := alice.request("GET", "/api/pages/"+page+"/revisions/"+revisions[0]["id"].(string), nil, 200); got["content"] != "version one" {
t.Fatalf("restored revision body: %v", got)
}
// A stale base is a 409, not a silent overwrite.
alice.request("POST", "/api/pages/"+page+"/revisions/"+first+"/restore", map[string]any{"version": 2}, 409)
alice.request("POST", "/api/pages/"+page+"/revisions/"+first+"/restore", map[string]any{}, 400)
bob.request("GET", "/api/pages/"+page+"/revisions", nil, 404)
bob.request("GET", "/api/pages/"+page+"/revisions/"+first, nil, 404)
bob.request("POST", "/api/pages/"+page+"/revisions/"+first+"/restore", map[string]any{"version": 3}, 404)
}
func TestTrashRestoreNeverResurrectsGrants(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "trust-trash@computer.test")
bob := newAPIUser(t, srv, "trust-trash-other@computer.test")
bobID := meID(bob)
tag := alice.request("POST", "/api/tags", map[string]any{"name": "Restored tag"}, 201)["id"].(string)
page := alice.request("POST", "/api/pages", map[string]any{"title": "Host page"}, 201)["id"].(string)
col := alice.request("POST", "/api/collections", map[string]any{"name": "Restored project"}, 201)
cid := col["id"].(string)
prop := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Status", "type": "TEXT"}, 201)["id"].(string)
bm := alice.request("POST", "/api/bookmarks", map[string]any{
"url": "https://127.0.0.1/restore-me", "title": "Restore me", "tagIds": []string{tag}, "isPublic": true,
}, 201)
bid := bm["id"].(string)
alice.request("PATCH", "/api/pages/"+page, map[string]any{"addBookmarkId": bid}, 200)
alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bid, "propertyValues": map[string]any{prop: "kept"}}, 201)
alice.request("POST", "/api/bookmarks/"+bid+"/share", map[string]any{"userIds": []string{bobID}}, 200)
bob.request("GET", "/api/bookmarks/"+bid, nil, 200)
alice.request("GET", "/api/public/bookmarks/"+bid, nil, 200)
alice.request("DELETE", "/api/bookmarks/"+bid, nil, 200)
bob.request("GET", "/api/bookmarks/"+bid, nil, 404)
alice.request("GET", "/api/public/bookmarks/"+bid, nil, 404)
trash := alice.getJSON("/api/trash", 200)
if len(trash) != 1 || trash[0]["type"] != "bookmark" || trash[0]["title"] != "Restore me" {
t.Fatalf("bookmark trash entry: %v", trash)
}
entry := trash[0]["id"].(string)
bob.request("POST", "/api/trash/"+entry+"/restore", map[string]any{}, 404)
restored := alice.request("POST", "/api/trash/"+entry+"/restore", map[string]any{}, 200)
if restored["ok"] != true || restored["id"] != bid {
t.Fatalf("restore response: %v", restored)
}
// Intrinsic links return; access grants do not.
got := alice.request("GET", "/api/bookmarks/"+bid, nil, 200)
if got["title"] != "Restore me" || len(got["tags"].([]any)) != 1 || got["isPublic"] != false {
t.Fatalf("restored bookmark: %v", got)
}
if len(got["pageLinks"].([]any)) != 1 {
t.Fatalf("page link was not restored: %v", got["pageLinks"])
}
bob.request("GET", "/api/bookmarks/"+bid, nil, 404)
alice.request("GET", "/api/public/bookmarks/"+bid, nil, 404)
items := alice.getJSON("/api/collections/"+cid+"/items", 200)
if len(items) != 1 || items[0]["propertyValues"].(map[string]any)[prop] != "kept" {
t.Fatalf("collection membership was not restored: %v", items)
}
if trash := alice.getJSON("/api/trash", 200); len(trash) != 0 {
t.Fatalf("restored entry stayed in trash: %v", trash)
}
// Pages restore with their history, and the restore is itself a revision.
alice.request("PATCH", "/api/pages/"+page, map[string]any{"content": "page body", "version": 1}, 200)
alice.request("DELETE", "/api/pages/"+page, nil, 200)
alice.request("GET", "/api/pages/"+page, nil, 404)
trash = alice.getJSON("/api/trash", 200)
var pageEntry string
for _, row := range trash {
if row["type"] == "page" {
pageEntry = row["id"].(string)
}
}
if pageEntry == "" {
t.Fatalf("page missing from trash: %v", trash)
}
alice.request("POST", "/api/trash/"+pageEntry+"/restore", map[string]any{}, 200)
restoredPage := alice.request("GET", "/api/pages/"+page, nil, 200)
if restoredPage["content"] != "page body" || len(restoredPage["bookmarks"].([]any)) != 1 {
t.Fatalf("restored page: %v", restoredPage)
}
revs := alice.getJSON("/api/pages/"+page+"/revisions", 200)
if len(revs) < 3 {
t.Fatalf("page history was not restored/extended: %v", revs)
}
if revs[0]["version"].(float64) != restoredPage["version"].(float64) {
t.Fatalf("restore revision is not the current version: revs=%v page=%v", revs[0], restoredPage["version"])
}
// Collections restore structure and items; prior shares do not come back.
alice.request("POST", "/api/collections/"+cid+"/share", map[string]any{"userIds": []string{bobID}}, 200)
bob.request("GET", "/api/collections/"+cid, nil, 200)
alice.request("DELETE", "/api/collections/"+cid, nil, 200)
bob.request("GET", "/api/collections/"+cid, nil, 404)
trash = alice.getJSON("/api/trash", 200)
var collectionEntry string
for _, row := range trash {
if row["type"] == "collection" {
collectionEntry = row["id"].(string)
}
}
if collectionEntry == "" {
t.Fatalf("collection missing from trash: %v", trash)
}
alice.request("POST", "/api/trash/"+collectionEntry+"/restore", map[string]any{}, 200)
detail := alice.request("GET", "/api/collections/"+cid, nil, 200)
if detail["name"] != "Restored project" || len(detail["properties"].([]any)) != 1 {
t.Fatalf("restored collection: %v", detail)
}
if items := alice.getJSON("/api/collections/"+cid+"/items", 200); len(items) != 1 {
t.Fatalf("restored collection items: %v", items)
}
bob.request("GET", "/api/collections/"+cid, nil, 404)
// Permanent purge is explicit and owner-scoped.
alice.request("POST", "/api/trash/"+entry+"/restore", map[string]any{}, 404)
}
func TestCollectionSharingPermissionBoundary(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "trust-coll-share@computer.test")
bob := newAPIUser(t, srv, "trust-coll-recipient@computer.test")
carol := newAPIUser(t, srv, "trust-coll-third@computer.test")
bobID, carolID := meID(bob), meID(carol)
bm := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/collection-share", "title": "Shared via project", "notes": "secret owner notes", "isPublic": true}, 201)
bid := bm["id"].(string)
col := alice.request("POST", "/api/collections", map[string]any{"name": "Team project"}, 201)
cid := col["id"].(string)
prop := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Status", "type": "TEXT"}, 201)["id"].(string)
alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bid, "propertyValues": map[string]any{prop: "Doing"}}, 201)
bob.request("GET", "/api/collections/"+cid, nil, 404)
bob.request("GET", "/api/bookmarks/"+bid, nil, 404)
alice.request("POST", "/api/collections/"+cid+"/share", map[string]any{"userIds": []string{bobID}}, 200)
// Owner sees the disclosure.
detail := alice.request("GET", "/api/collections/"+cid, nil, 200)
if detail["shared"] != false || len(detail["sharedWith"].([]any)) != 1 {
t.Fatalf("owner share disclosure: %v", detail)
}
// Recipient sees the collection, its items, and the narrowed bookmark
// projection — never the owner's contact/share/recipient metadata.
listed := bob.getJSON("/api/collections", 200)
found := false
for _, c := range listed {
if c["id"] == cid {
found = c["shared"] == true
}
}
if !found {
t.Fatalf("shared collection missing from recipient list: %v", listed)
}
bobDetail := bob.request("GET", "/api/collections/"+cid, nil, 200)
if bobDetail["shared"] != true || len(bobDetail["sharedWith"].([]any)) != 0 {
t.Fatalf("recipient detail disclosure: %v", bobDetail)
}
items := bob.getJSON("/api/collections/"+cid+"/items", 200)
if len(items) != 1 || items[0]["propertyValues"].(map[string]any)[prop] != "Doing" {
t.Fatalf("recipient items: %v", items)
}
sharedBookmark := bob.request("GET", "/api/bookmarks/"+bid, nil, 200)
if sharedBookmark["title"] != "Shared via project" || sharedBookmark["notes"] != "secret owner notes" {
t.Fatalf("recipient bookmark projection: %v", sharedBookmark)
}
if _, leaked := sharedBookmark["sharedWith"]; leaked {
t.Fatalf("recipient saw the owner's recipient list: %v", sharedBookmark)
}
// Read-only: mutations remain owner-only, including membership changes.
bob.request("PATCH", "/api/collections/"+cid, map[string]any{"version": 1, "name": "hijack"}, 404)
bob.request("DELETE", "/api/collections/"+cid, nil, 404)
bob.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bid}, 404)
bob.request("PATCH", "/api/collections/"+cid+"/items/"+items[0]["id"].(string), map[string]any{"version": 1, "propertyValues": map[string]any{prop: "Hijacked"}}, 404)
bob.request("POST", "/api/collections/"+cid+"/share", map[string]any{"userIds": []string{carolID}}, 404)
bob.request("DELETE", "/api/collections/"+cid+"/share/"+bobID, nil, 404)
// Revocation removes collection and bookmark access immediately.
alice.request("DELETE", "/api/collections/"+cid+"/share/"+bobID, nil, 200)
bob.request("GET", "/api/collections/"+cid, nil, 404)
bob.request("GET", "/api/collections/"+cid+"/items", nil, 404)
bob.request("GET", "/api/bookmarks/"+bid, nil, 404)
// Removing an item also removes the implicit bookmark grant.
alice.request("POST", "/api/collections/"+cid+"/share", map[string]any{"userIds": []string{bobID}}, 200)
bob.request("GET", "/api/bookmarks/"+bid, nil, 200)
alice.request("DELETE", "/api/collections/"+cid+"/items/"+items[0]["id"].(string), nil, 200)
bob.request("GET", "/api/bookmarks/"+bid, nil, 404)
// Transitive leaks are refused: Carol can see Alice's bookmark only because
// Alice shared it with Carol. Carol's collection share must not pass that
// grant on to Bob.
thirdParty := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/third-party", "title": "Alice's private link"}, 201)["id"].(string)
alice.request("POST", "/api/bookmarks/"+thirdParty+"/share", map[string]any{"userIds": []string{carolID}}, 200)
carol.request("GET", "/api/bookmarks/"+thirdParty, nil, 200)
carolCol := carol.request("POST", "/api/collections", map[string]any{"name": "Carol's project"}, 201)["id"].(string)
carol.request("POST", "/api/collections/"+carolCol+"/items", map[string]any{"bookmarkId": thirdParty}, 201)
carol.request("POST", "/api/collections/"+carolCol+"/share", map[string]any{"userIds": []string{bobID}}, 200)
bob.request("GET", "/api/collections/"+carolCol, nil, 200)
if leakedItems := bob.getJSON("/api/collections/"+carolCol+"/items", 200); len(leakedItems) != 0 {
t.Fatalf("collection share leaked a third-party bookmark: %v", leakedItems)
}
bob.request("GET", "/api/bookmarks/"+thirdParty, nil, 404)
}
func TestStructuredExport(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "trust-export@computer.test")
tag := alice.request("POST", "/api/tags", map[string]any{"name": "Export tag"}, 201)["id"].(string)
bm := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/export", "title": "Exported bookmark", "tagIds": []string{tag}}, 201)["id"].(string)
page := alice.request("POST", "/api/pages", map[string]any{"title": "Exported page", "content": "exported body"}, 201)["id"].(string)
col := alice.request("POST", "/api/collections", map[string]any{"name": "Exported collection"}, 201)["id"].(string)
alice.request("POST", "/api/collections/"+col+"/items", map[string]any{"bookmarkId": bm}, 201)
v := alice.request("GET", "/api/export", nil, 200)
if v["format"] != "computer-export" || v["version"].(float64) != 1 {
t.Fatalf("export envelope: %v", v)
}
if len(v["bookmarks"].([]any)) != 1 || len(v["pages"].([]any)) != 1 || len(v["tags"].([]any)) != 1 || len(v["collections"].([]any)) != 1 {
t.Fatalf("export sections: %v", v)
}
exPage := v["pages"].([]any)[0].(map[string]any)
if exPage["content"] != "exported body" || len(exPage["revisions"].([]any)) == 0 {
t.Fatalf("export page content/history: %v", exPage)
}
exCol := v["collections"].([]any)[0].(map[string]any)
if len(exCol["items"].([]any)) != 1 {
t.Fatalf("export collection items: %v", exCol)
}
if _, ok := v["user"].(map[string]any)["id"]; !ok {
t.Fatalf("export user identity: %v", v["user"])
}
if page == "" {
t.Fatal("page id missing")
}
}
+1 -1
View File
@@ -176,7 +176,7 @@ export function BookmarksClient({ initialSearch = null, initialOpen = null }: {
<div className="flex gap-1" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<a href={b.url} target="_blank" rel="noopener noreferrer"><ExternalLink className="h-4 w-4 text-muted-foreground hover:text-primary" /></a>
{tab === 'mine' && (
<button onClick={async () => { if (!confirm('Delete?')) return; try { const res = await fetch(`/api/bookmarks/${b.id}`, { method: 'DELETE' }); if (res.ok) { toast.success('Deleted'); load() } else toast.error('Could not delete bookmark') } catch { toast.error('Could not delete bookmark') } }}><Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" /></button>
<button onClick={async () => { if (!confirm('Move this bookmark to the trash? It can be restored.')) return; try { const res = await fetch(`/api/bookmarks/${b.id}`, { method: 'DELETE' }); if (res.ok) { toast.success('Moved to trash'); load() } else toast.error('Could not move bookmark to trash') } catch { toast.error('Could not move bookmark to trash') } }}><Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" /></button>
)}
</div>
</td>
+142 -21
View File
@@ -11,7 +11,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { CalendarDays, Columns3, Filter, GripVertical, Kanban, Layers, List, Plus, Table2, Trash2, X } from 'lucide-react'
import { CalendarDays, Columns3, Filter, GripVertical, Kanban, Layers, List, Plus, Share2, Table2, Trash2, X } from 'lucide-react'
import { toast } from 'sonner'
import type { Bookmark } from '@/components/app-shell'
import { cn } from '@/lib/utils'
@@ -40,9 +40,13 @@ type CollectionItem = {
propertyValues: Record<string, unknown>; position: number; version: number
addedAt: string; updatedAt: string; bookmark: Bookmark
}
type CollectionShare = { userId: string; name: string | null; email: string | null; createdAt?: string }
type CollectionSummary = {
id: string; name: string; description: string | null; icon: string | null
version: number; createdAt: string; updatedAt: string
shared: boolean
owner?: { id: string; name: string | null; email: string | null; image: string | null }
sharedWith?: CollectionShare[]
_count: { items: number; properties: number; views: number }
}
type CollectionDetail = CollectionSummary & { properties: CollectionProperty[]; views: CollectionView[] }
@@ -95,10 +99,27 @@ function BookmarkCell({ bookmark }: { bookmark: Bookmark }) {
const asText = (v: unknown) => (typeof v === 'string' || typeof v === 'number' ? String(v) : '')
function plainValue(prop: CollectionProperty, value: unknown): string {
if (prop.type === 'CHECKBOX') return value === true ? 'Yes' : 'No'
if (prop.type === 'SELECT') return prop.config.options?.find((o) => o.id === value)?.label ?? '—'
if (prop.type === 'MULTI_SELECT') {
if (!Array.isArray(value)) return '—'
return value.map((id) => prop.config.options?.find((o) => o.id === id)?.label).filter(Boolean).join(', ') || '—'
}
if (prop.type === 'DATE' && typeof value === 'string' && value) {
if (prop.config.mode === 'date') return value.slice(0, 10)
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
}
if (value === null || value === undefined || value === '') return '—'
return String(value)
}
// Per-type inline value editor. Saves immediately on commit gestures
// (change/blur/Enter); failures toast and the prop value re-renders the
// server state, so a rejected edit never sticks locally.
function ValueEditor({ prop, value, onSave }: { prop: CollectionProperty; value: unknown; onSave: (v: unknown) => Promise<void> }) {
// server state, so a rejected edit never sticks locally. Shared collections
// are read-only: the same cell renders plain text.
function ValueEditor({ prop, value, onSave, readOnly = false }: { prop: CollectionProperty; value: unknown; onSave: (v: unknown) => Promise<void>; readOnly?: boolean }) {
const [text, setText] = useState(() => asText(value))
const [seen, setSeen] = useState(value)
// Adjusting state when a prop changes: the React-documented render-time
@@ -108,6 +129,7 @@ function ValueEditor({ prop, value, onSave }: { prop: CollectionProperty; value:
setText(asText(value))
}
const [open, setOpen] = useState(false)
if (readOnly) return <span className="text-sm text-muted-foreground">{plainValue(prop, value)}</span>
const commitText = async () => {
const next = text.trim()
const current = asText(value)
@@ -220,6 +242,9 @@ export function CollectionsClient() {
const [fProp, setFProp] = useState('')
const [fOp, setFOp] = useState('eq')
const [fValue, setFValue] = useState('')
const [shareOpen, setShareOpen] = useState(false)
const [shareQuery, setShareQuery] = useState('')
const [shareResults, setShareResults] = useState<{ id: string; name: string | null; email: string }[]>([])
const loadCollections = useCallback(async () => {
try {
@@ -293,6 +318,41 @@ export function CollectionsClient() {
const activeView = detail?.views.find((v) => v.id === viewId) ?? null
const groupProp = activeView?.groupByPropertyId ? detail?.properties.find((p) => p.id === activeView.groupByPropertyId) : null
// Shared collections are explicitly read-only: hiding mutation controls is
// the UI half of the server's owner-only mutation gate.
const readOnly = detail?.shared === true
const shareCollection = async (userId: string) => {
if (!detail) return
const res = await fetch(`/api/collections/${detail.id}/share`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userIds: [userId] }),
})
if (!res.ok) { toast.error('Could not share collection'); return }
toast.success('Collection shared')
setShareQuery(''); setShareResults([])
loadDetail(detail.id); loadCollections()
}
const unshareCollection = async (userId: string) => {
if (!detail) return
const res = await fetch(`/api/collections/${detail.id}/share/${userId}`, { method: 'DELETE' })
if (!res.ok) { toast.error('Could not revoke collection access'); return }
toast.success('Access revoked')
loadDetail(detail.id); loadCollections()
}
useEffect(() => {
if (!shareOpen) return
const q = shareQuery.trim()
if (q.length < 2) return
const t = setTimeout(async () => {
try {
const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`)
if (res.ok) setShareResults(await res.json())
} catch { /* transient */ }
}, 250)
return () => clearTimeout(t)
}, [shareOpen, shareQuery])
const createCollection = async () => {
const name = newName.trim()
@@ -307,7 +367,7 @@ export function CollectionsClient() {
}
const saveName = async () => {
if (!detail) return
if (!detail || detail.shared) return
const name = nameDraft.trim()
if (!name || name === detail.name) { setNameDraft(detail.name); return }
const res = await patch(`/api/collections/${detail.id}`, { version: detail.version, name })
@@ -318,7 +378,7 @@ export function CollectionsClient() {
}
const deleteCollection = async () => {
if (!detail || !confirm(`Delete collection "${detail.name}"? Bookmarks are kept.`)) return
if (!detail || detail.shared || !confirm(`Move collection "${detail.name}" to the trash? Bookmarks are kept and it can be restored.`)) return
const res = await fetch(`/api/collections/${detail.id}`, { method: 'DELETE' })
if (await mutate(res, loadCollections, 'Collection deleted')) {
setActiveId(null)
@@ -494,9 +554,9 @@ export function CollectionsClient() {
<TableHead key={p.id} className="min-w-[11rem]">
<span className="flex items-center gap-1.5">
{p.name}
<button type="button" aria-label={`Delete property ${p.name}`} className="text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100" onClick={() => deleteProperty(p)}>
{!readOnly && <button type="button" aria-label={`Delete property ${p.name}`} className="text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100" onClick={() => deleteProperty(p)}>
<Trash2 className="h-3 w-3" />
</button>
</button>}
</span>
</TableHead>
))}
@@ -508,12 +568,12 @@ export function CollectionsClient() {
<TableRow key={item.id} className="group">
<TableCell><BookmarkCell bookmark={item.bookmark} /></TableCell>
{(detail?.properties ?? []).map((p) => (
<TableCell key={p.id}><ValueEditor prop={p} value={item.propertyValues?.[p.id]} onSave={(v) => saveValue(item, p.id, v)} /></TableCell>
<TableCell key={p.id}><ValueEditor prop={p} value={item.propertyValues?.[p.id]} onSave={(v) => saveValue(item, p.id, v)} readOnly={readOnly} /></TableCell>
))}
<TableCell>
<button type="button" aria-label="Remove from collection" className="text-muted-foreground hover:text-destructive" onClick={() => removeItem(item)}>
{!readOnly && <button type="button" aria-label="Remove from collection" className="text-muted-foreground hover:text-destructive" onClick={() => removeItem(item)}>
<Trash2 className="h-4 w-4" />
</button>
</button>}
</TableCell>
</TableRow>
))}
@@ -535,7 +595,7 @@ export function CollectionsClient() {
<div className="rounded-lg border border-dashed border-border p-8 text-center">
<Kanban className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Board views group by a select property.</p>
<div className="mx-auto mt-3 w-56">
{!readOnly && <div className="mx-auto mt-3 w-56">
<Select
value={NONE}
onValueChange={(id) => { if (id !== NONE) patchView({ groupByPropertyId: id }) }}
@@ -547,7 +607,7 @@ export function CollectionsClient() {
))}
</SelectContent>
</Select>
</div>
</div>}
</div>
)
}
@@ -602,7 +662,7 @@ export function CollectionsClient() {
)}
>
<div className="flex items-start gap-1">
<span
{!readOnly && <span
data-drag-handle
draggable
onDragStart={(e) => {
@@ -619,12 +679,15 @@ export function CollectionsClient() {
aria-label={`Drag ${item.bookmark.title ?? 'bookmark'}`}
>
<GripVertical className="h-3.5 w-3.5" />
</span>
</span>}
<div className="min-w-0 flex-1">
<BookmarkCell bookmark={item.bookmark} />
</div>
</div>
<div className="mt-1.5 flex items-center justify-between gap-1">
{readOnly ? (
<span className="text-xs text-muted-foreground">{plainValue(groupProp, item.propertyValues?.[groupProp.id])}</span>
) : (
<Select
value={typeof item.propertyValues?.[groupProp.id] === 'string' ? (item.propertyValues[groupProp.id] as string) : NONE}
onValueChange={(id) => { void moveItemToGroup(item, id === NONE ? null : id) }}
@@ -635,9 +698,10 @@ export function CollectionsClient() {
{options.map((o) => <SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<button type="button" aria-label="Remove from collection" className="text-muted-foreground hover:text-destructive" onClick={() => removeItem(item)}>
)}
{!readOnly && <button type="button" aria-label="Remove from collection" className="text-muted-foreground hover:text-destructive" onClick={() => removeItem(item)}>
<Trash2 className="h-3.5 w-3.5" />
</button>
</button>}
</div>
</div>
))}
@@ -674,7 +738,10 @@ export function CollectionsClient() {
className={cn('flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors', c.id === activeId ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground')}
>
<Layers className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">{c.name}</span>
<span className="min-w-0 flex-1 truncate">
{c.name}
{c.shared && <span className="ml-1 text-[10px] uppercase tracking-wide text-muted-foreground">shared</span>}
</span>
<span className="shrink-0 text-xs text-muted-foreground">{c._count.items}</span>
</button>
))}
@@ -699,13 +766,25 @@ export function CollectionsClient() {
) : (
<>
<div className="mb-4 flex flex-wrap items-center gap-2">
{readOnly ? (
<span className="w-72 truncate font-display text-2xl font-bold tracking-tight">{detail.name}</span>
) : (
<Input value={nameDraft} onChange={(e) => setNameDraft(e.target.value)} onBlur={saveName} onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }} aria-label="Collection name" className="w-72 bg-transparent font-display text-2xl font-bold tracking-tight" />
)}
<span className="text-xs text-muted-foreground">{detail._count?.items ?? 0} item{(detail._count?.items ?? 0) === 1 ? '' : 's'}</span>
{readOnly ? (
<Badge variant="secondary" className="gap-1">
<Share2 className="h-3 w-3" />
Shared by {detail.owner?.name || detail.owner?.email || 'another user'} · read-only
</Badge>
) : (
<div className="ml-auto flex items-center gap-1.5">
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}><Plus className="mr-1 h-4 w-4" />Add bookmarks</Button>
<Button size="sm" variant="outline" onClick={() => setPropOpen(true)}><Plus className="mr-1 h-4 w-4" />Property</Button>
<Button size="sm" variant="ghost" onClick={deleteCollection} aria-label="Delete collection" title="Delete collection"><Trash2 className="h-4 w-4 text-destructive" /></Button>
<Button size="sm" variant="outline" onClick={() => { setShareOpen(true); setShareResults([]); setShareQuery('') }}><Share2 className="mr-1 h-4 w-4" />Share</Button>
<Button size="sm" variant="ghost" onClick={deleteCollection} aria-label="Move collection to trash" title="Move collection to trash"><Trash2 className="h-4 w-4 text-destructive" /></Button>
</div>
)}
</div>
{/* View tabs */}
@@ -722,8 +801,8 @@ export function CollectionsClient() {
</button>
)
})}
<Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => setViewOpen(true)} aria-label="Add view"><Plus className="h-4 w-4" /></Button>
{activeView && (
{!readOnly && <Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => setViewOpen(true)} aria-label="Add view"><Plus className="h-4 w-4" /></Button>}
{activeView && !readOnly && (
<div className="ml-auto flex items-center gap-1.5">
<Popover open={filterOpen} onOpenChange={(o) => { setFilterOpen(o); if (o) { setFProp(detail.properties[0]?.id ?? ''); setFOp(FILTER_OPS[detail.properties[0]?.type ?? 'TEXT']?.[0] ?? 'eq') } }}>
<PopoverTrigger asChild>
@@ -774,7 +853,7 @@ export function CollectionsClient() {
{filterClauses.map((c, i) => (
<Badge key={i} variant="secondary" className="gap-1">
{clauseLabel(c)}
<button type="button" aria-label="Remove filter" onClick={() => setClause(filterClauses.filter((_, j) => j !== i))}><X className="h-3 w-3" /></button>
{!readOnly && <button type="button" aria-label="Remove filter" onClick={() => setClause(filterClauses.filter((_, j) => j !== i))}><X className="h-3 w-3" /></button>}
</Badge>
))}
</div>
@@ -878,6 +957,48 @@ export function CollectionsClient() {
</div>
</DialogContent>
</Dialog>
{/* Share dialog */}
<Dialog open={shareOpen} onOpenChange={(o) => { setShareOpen(o); if (!o) { setShareQuery(''); setShareResults([]) } }}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle>Share collection</DialogTitle></DialogHeader>
<p className="text-xs text-muted-foreground">
Recipients get read-only access to this collection and the links you own inside it. Links you can
only see through someone else&apos;s share are omitted, so sharing never passes a third-party grant on.
They receive the same trimmed link view as other shared bookmarks; contacts, alerts and mailbox
settings stay private. Revoking access takes effect immediately.
</p>
{detail && (
<div className="space-y-1">
{(detail.sharedWith ?? []).map((s) => (
<div key={s.userId} className="flex items-center gap-2 rounded-md border px-2 py-1.5 text-sm">
<span className="min-w-0 flex-1 truncate">{s.name || s.email || s.userId}</span>
<Button size="sm" variant="ghost" onClick={() => void unshareCollection(s.userId)}>Revoke</Button>
</div>
))}
{(detail.sharedWith?.length ?? 0) === 0 && <p className="py-2 text-xs text-muted-foreground">Not shared with anyone yet.</p>}
</div>
)}
<Input autoFocus value={shareQuery} onChange={(e) => setShareQuery(e.target.value)} placeholder="Search people by name or email" aria-label="Search people to share with" />
<div className="max-h-56 space-y-1 overflow-y-auto">
{shareResults.map((u) => {
const already = detail?.sharedWith?.some((s) => s.userId === u.id)
return (
<div key={u.id} className="flex items-center gap-2 rounded-md border px-2 py-1.5 text-sm">
<div className="min-w-0 flex-1">
<p className="truncate">{u.name || u.email}</p>
{u.name && <p className="truncate text-xs text-muted-foreground">{u.email}</p>}
</div>
<Button size="sm" variant="outline" disabled={already} onClick={() => void shareCollection(u.id)}>
{already ? 'Shared' : 'Share'}
</Button>
</div>
)
})}
{shareQuery.trim().length >= 2 && shareResults.length === 0 && <p className="py-4 text-center text-xs text-muted-foreground">No matching people</p>}
</div>
</DialogContent>
</Dialog>
</div>
)
}
+109 -4
View File
@@ -8,12 +8,13 @@ import remarkGfm from 'remark-gfm'
import { toast } from 'sonner'
import {
ChevronLeft, ChevronRight, Code, CornerUpLeft, Download, Eye, FileText, GripVertical,
Heading1, Heading2, Heading3, Layers, Link2, List, ListOrdered, ListTodo, Minus, Pencil, Pin, PinOff,
Plus, Quote, Search, Trash2, Type, X,
Heading1, Heading2, Heading3, History, Layers, Link2, List, ListOrdered, ListTodo, Minus, Pencil,
Pin, PinOff, Plus, Quote, RotateCcw, Search, Trash2, Type, X,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { PageAutosave, PageConflictError, type PageSaveState } from '@/lib/page-autosave'
@@ -65,6 +66,8 @@ type PageDetail = PageSummary & {
links: { id: string; title: string }[]
embeds: PageEmbed[]
}
type PageRevisionSummary = { id: string; version: number; title: string; createdAt: string; length: number }
type PageRevisionDetail = PageRevisionSummary & { content: string; icon: string | null }
const notify = notifyPagesChanged
const host = (u: string) => { try { return new URL(u).hostname.replace(/^www\./, '') } catch { return u } }
@@ -192,6 +195,10 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
const [pageLinkQuery, setPageLinkQuery] = useState('')
const [embedOpen, setEmbedOpen] = useState(false)
const [collections, setCollections] = useState<{ id: string; name: string; icon: string | null; views?: { id: string; name: string }[] }[]>([])
const [historyOpen, setHistoryOpen] = useState(false)
const [revisions, setRevisions] = useState<PageRevisionSummary[]>([])
const [revision, setRevision] = useState<PageRevisionDetail | null>(null)
const [historyBusy, setHistoryBusy] = useState<string | null>(null)
const [slash, setSlash] = useState<{ query: string; start: number } | null>(null)
const [slashIndex, setSlashIndex] = useState(0)
const [tabDrop, setTabDrop] = useState<{ id: string; after: boolean } | null>(null)
@@ -388,6 +395,60 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
}
}
const openHistory = async () => {
if (!page) return
setHistoryOpen(true)
setRevision(null)
try {
const res = await fetch(`/api/pages/${page.id}/revisions`, { cache: 'no-store' })
setRevisions(res.ok ? await res.json() : [])
if (!res.ok) toast.error('Could not load page history')
} catch { toast.error('Could not load page history') }
}
const viewRevision = async (id: string) => {
if (!page) return
setHistoryBusy(id)
try {
const res = await fetch(`/api/pages/${page.id}/revisions/${id}`, { cache: 'no-store' })
if (res.ok) setRevision(await res.json())
else toast.error('Could not load this version')
} catch { toast.error('Could not load this version') }
finally { setHistoryBusy(null) }
}
const restoreRevision = async (rev: PageRevisionSummary) => {
if (!page) return
if (!confirm(`Restore version ${rev.version}? The current content is kept in history.`)) return
setHistoryBusy(rev.id)
try {
if (!await autosave.flush(page.id)) { toast.error('Resolve the unsaved draft before restoring history'); return }
const latest = await fetch(`/api/pages/${page.id}`, { cache: 'no-store' })
if (!latest.ok) { toast.error('Could not reload the page'); return }
const current: PageDetail = await latest.json()
const res = await fetch(`/api/pages/${page.id}/revisions/${rev.id}/restore`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version: current.version }),
})
if (res.status === 409) {
toast.error('The page changed elsewhere. Reload it before restoring history.')
await loadPage(page.id)
return
}
if (!res.ok) { toast.error((await res.json().catch(() => null))?.error ?? 'Could not restore this version'); return }
const restored: PageDetail = await res.json()
autosave.discard(page.id)
autosave.setVersion(page.id, restored.version)
setPage(restored)
setTitle(restored.title)
setContent(restored.content)
setMode('preview')
notifyActivePage(restored.title)
setHistoryOpen(false)
toast.success(`Restored version ${rev.version} as version ${restored.version}`)
await loadPages(); notify()
} catch { toast.error('Could not restore this version') }
finally { setHistoryBusy(null) }
}
// Show the block menu only while the caret sits on a bare "/token" line.
const updateSlash = (text: string, caret: number) => {
const lineStart = text.lastIndexOf('\n', caret - 1) + 1
@@ -504,7 +565,7 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
}
const remove = async () => {
if (!page || !confirm(`Delete page "${page.title}"? Bookmarks are kept.`)) return
if (!page || !confirm(`Move page "${page.title}" to the trash? Bookmarks are kept and it can be restored.`)) return
if (!await autosave.flush(page.id)) return
const res = await fetch(`/api/pages/${page.id}`, { method: 'DELETE' })
if (!res.ok) { toast.error('Could not delete page'); return }
@@ -694,7 +755,8 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
<Button variant="ghost" size="icon-sm" title="Move tab left" onClick={() => move(-1)}><ChevronLeft className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon-sm" title="Move tab right" onClick={() => move(1)}><ChevronRight className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon-sm" title="Download .md" onClick={download}><Download className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon-sm" title="Delete page" onClick={remove}><Trash2 className="h-4 w-4 text-destructive" /></Button>
<Button variant="ghost" size="icon-sm" title="Version history" onClick={() => void openHistory()}><History className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon-sm" title="Move to trash" onClick={remove}><Trash2 className="h-4 w-4 text-destructive" /></Button>
</div>
<div className="mb-4 flex flex-wrap items-center gap-2 text-sm">
@@ -934,6 +996,49 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
)}
</div>
)}
<Dialog open={historyOpen} onOpenChange={(open) => { setHistoryOpen(open); if (!open) setRevision(null) }}>
<DialogContent className="max-w-3xl">
<DialogHeader><DialogTitle>Version history</DialogTitle></DialogHeader>
<p className="text-xs text-muted-foreground">
Every saved content version is kept. Restoring writes a new version, so nothing in history is ever rewritten.
</p>
<div className="grid gap-3 md:grid-cols-[15rem_1fr]">
<div className="max-h-[24rem] space-y-1 overflow-y-auto rounded-md border p-1">
{revisions.map((rev) => (
<div key={rev.id} className={cn('rounded-md px-2 py-1.5', revision?.id === rev.id ? 'bg-accent' : 'hover:bg-muted')}>
<button type="button" className="flex w-full items-center justify-between gap-2 text-left" onClick={() => void viewRevision(rev.id)}>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">Version {rev.version}</span>
<span className="block text-xs text-muted-foreground">{new Date(rev.createdAt).toLocaleString()}</span>
</span>
{revisions[0]?.id === rev.id && <span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground">Current</span>}
</button>
<button
type="button"
disabled={historyBusy === rev.id}
onClick={() => void restoreRevision(rev)}
className="mt-1 flex items-center gap-1 text-xs text-primary hover:underline disabled:opacity-50"
>
<RotateCcw className="h-3 w-3" /> Restore
</button>
</div>
))}
{revisions.length === 0 && <p className="px-2 py-6 text-center text-xs text-muted-foreground">No saved versions yet.</p>}
</div>
<div className="max-h-[24rem] overflow-y-auto rounded-md border bg-muted/20 p-3">
{revision ? (
<>
<p className="mb-2 text-sm font-medium">{revision.title}</p>
<pre className="whitespace-pre-wrap break-words font-mono text-xs text-muted-foreground">{revision.content || '(empty page)'}</pre>
</>
) : (
<p className="py-10 text-center text-sm text-muted-foreground">Select a version to preview it.</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
}
+120
View File
@@ -0,0 +1,120 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Download, FileText, Layers, Link2, RotateCcw, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { notifyPagesChanged } from '@/lib/pages'
type TrashEntry = { id: string; type: 'bookmark' | 'page' | 'collection'; originalId: string; title: string; deletedAt: string }
const TYPE_ICON = { bookmark: Link2, page: FileText, collection: Layers } as const
export function TrashClient() {
const [entries, setEntries] = useState<TrashEntry[]>([])
const [busy, setBusy] = useState<string | null>(null)
const [exporting, setExporting] = useState(false)
const load = useCallback(async () => {
try {
const res = await fetch('/api/trash', { cache: 'no-store' })
if (!res.ok) { toast.error('Could not load the trash'); return }
setEntries(await res.json())
} catch { toast.error('Could not load the trash') }
}, [])
useEffect(() => {
const timer = setTimeout(() => { void load() }, 0)
return () => clearTimeout(timer)
}, [load])
const restore = async (entry: TrashEntry) => {
setBusy(entry.id)
try {
const res = await fetch(`/api/trash/${entry.id}/restore`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
if (!res.ok) { toast.error((await res.json().catch(() => null))?.error ?? 'Could not restore'); return }
toast.success(`Restored “${entry.title}”`)
if (entry.type === 'page') notifyPagesChanged()
await load()
} catch { toast.error('Could not restore') }
finally { setBusy(null) }
}
const purge = async (entry: TrashEntry) => {
if (!confirm(`Delete “${entry.title}” permanently? This cannot be undone.`)) return
setBusy(entry.id)
try {
const res = await fetch(`/api/trash/${entry.id}`, { method: 'DELETE' })
if (!res.ok) { toast.error('Could not delete permanently'); return }
toast.success('Deleted permanently')
await load()
} catch { toast.error('Could not delete permanently') }
finally { setBusy(null) }
}
const exportData = async () => {
setExporting(true)
try {
const res = await fetch('/api/export')
if (!res.ok) { toast.error('Could not export your data'); return }
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `computer-export-${new Date().toISOString().slice(0, 10)}.json`
document.body.appendChild(a); a.click(); a.remove()
URL.revokeObjectURL(url)
toast.success('Export downloaded')
} catch { toast.error('Could not export your data') }
finally { setExporting(false) }
}
return (
<div className="mx-auto w-full max-w-3xl px-6 py-8">
<div className="mb-6 flex flex-wrap items-center gap-2">
<div className="min-w-0 flex-1">
<h1 className="font-display text-2xl font-bold tracking-tight">Trash</h1>
<p className="mt-1 text-sm text-muted-foreground">
Deleted bookmarks, pages and collections stay recoverable here. Restoring brings back content and
owner-scoped links, but never a share or public link that was revoked in the meantime.
</p>
</div>
<Button variant="outline" onClick={() => void exportData()} disabled={exporting}>
<Download className="mr-1 h-4 w-4" />{exporting ? 'Exporting…' : 'Export all data'}
</Button>
</div>
{entries.length === 0 ? (
<div className="rounded-lg border border-dashed p-12 text-center">
<Trash2 className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">The trash is empty.</p>
</div>
) : (
<div className="space-y-2">
{entries.map((entry) => {
const Icon = TYPE_ICON[entry.type]
return (
<div key={entry.id} className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-3 py-2.5">
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{entry.title}</p>
<p className="text-xs text-muted-foreground">
{new Date(entry.deletedAt).toLocaleString()}
</p>
</div>
<Badge variant="secondary" className="capitalize">{entry.type}</Badge>
<Button size="sm" variant="outline" disabled={busy === entry.id} onClick={() => void restore(entry)}>
<RotateCcw className="mr-1 h-3.5 w-3.5" />Restore
</Button>
<Button size="sm" variant="ghost" disabled={busy === entry.id} onClick={() => void purge(entry)} aria-label={`Delete ${entry.title} permanently`}>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
)
})}
</div>
)}
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
import { TrashClient } from './client'
export const dynamic = 'force-dynamic'
export default function TrashPage() {
return <TrashClient />
}
+3 -3
View File
@@ -141,10 +141,10 @@ export function BookmarkDetailSheet({ bookmark, onClose, onUpdate }: Props) {
}, [bookmark, notes, title, url, description, tagIds, columnId, contactId, kanban, dueDate, alertAt, isPublic, imapEnabled, imapQuery, onUpdate])
const handleDelete = useCallback(async () => {
if (!bookmark || !confirm('Delete this bookmark?')) return
if (!bookmark || !confirm('Move this bookmark to the trash? It can be restored.')) return
const response = await fetch(`/api/bookmarks/${bookmark.id}`, { method: 'DELETE' })
if (!response.ok) { toast.error('Could not delete bookmark'); return }
toast.success('Bookmark deleted')
if (!response.ok) { toast.error('Could not move bookmark to trash'); return }
toast.success('Bookmark moved to trash')
onClose()
onUpdate()
}, [bookmark, onClose, onUpdate])
+2 -1
View File
@@ -26,6 +26,7 @@ const viewItems = [
{ href: '/kanban', label: 'Kanban', icon: Kanban },
{ href: '/collections', label: 'Collections', icon: Layers },
{ href: '/contacts', label: 'Contacts', icon: Users },
{ href: '/trash', label: 'Trash', icon: Trash2 },
]
// Section collapse state persists per browser. useSyncExternalStore keeps it
@@ -205,7 +206,7 @@ function SidebarNav({ open, pathname, isAdmin = false }: { open: boolean; pathna
}
const removePage = async (p: PageSummary) => {
if (!confirm(`Delete page "${p.title}"? Bookmarks are kept.`)) return
if (!confirm(`Move page "${p.title}" to the trash? Bookmarks are kept and it can be restored.`)) return
try {
const res = await fetch(`/api/pages/${p.id}`, { method: 'DELETE' })
if (!res.ok) { toast.error('Could not delete page'); return }
+6
View File
@@ -96,6 +96,12 @@ Composite PK (`sourcePageId`, `targetPageId`), `createdAt`; both Cascade, with a
### 2.13c `PageEmbed` (live collection view inside a page)
Composite PK (`pageId`, `collectionId`), `viewId?` → CollectionView (SetNull), `position` int, `createdAt`; page and collection Cascade. Rebuilt from `{{collection:<id>|view=<viewId>}}` on every content save; only owned collections are stored. Item reads still go through the collection endpoint, so an embed displays, never publishes.
### 2.13d `PageRevision` (server-side page history)
`id`, `pageId` → Page (Cascade), `version` int, `title`, `content`, `icon?`, `createdAt`; unique (`pageId`, `version`). Every committed content version is snapshotted. Restoring a revision copies it into the live page as a **new** version and records that restore.
### 2.13e `Trash` (recoverable deletions)
`id`, `ownerId` → User (Cascade), `type` (`bookmark`/`page`/`collection`), `originalId`, `data` jsonb snapshot, `deletedAt`; unique (`type`, `originalId`). Snapshots restore intrinsic content and owner-scoped links, but never access grants (public flags, bookmark/tag/collection shares).
### 2.14 `SignalSource` (a watcher attached to a bookmark)
`id`, `type` (e.g. `email`), `config` (JSON, default `{}`), `enabled` (default true), `lastCheckedAt?`, `lastStatus?`, `bookmarkId` → Bookmark (Cascade), `ownerId` → User (Cascade).