@skillre/dsh-plugin-sticky-wall 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +154 -0
- package/README.md +164 -2
- package/UNINSTALL.md +26 -0
- package/compatibility.json +83 -0
- package/cordis.patch.yml +10 -0
- package/docs/DETAIL.md +1 -0
- package/docs/DETAIL.zh.md +1 -0
- package/docs/PREVIEW.md +39 -0
- package/docs/preview.svg +170 -0
- package/docs/preview.zh.svg +170 -0
- package/icon.svg +15 -0
- package/lib/client.js +1694 -0
- package/lib/config.d.ts +22 -0
- package/lib/config.js +21 -0
- package/lib/index.d.ts +23 -0
- package/lib/index.js +184 -0
- package/lib/layout.d.ts +76 -0
- package/lib/layout.js +259 -0
- package/lib/store.d.ts +182 -0
- package/lib/store.js +452 -0
- package/lib/transport.d.ts +48 -0
- package/lib/transport.js +174 -0
- package/lib/types.d.ts +172 -0
- package/lib/types.js +64 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +116 -4
package/lib/store.d.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable note store over the DSH storage domain.
|
|
3
|
+
*
|
|
4
|
+
* The Host owns all data: the Client renders snapshots and submits intents, and
|
|
5
|
+
* every write goes through {@link StickyWallStore} — validate, persist, then
|
|
6
|
+
* hand back the changed records. The storage domain provides a single
|
|
7
|
+
* per-domain write chain, so concurrent writes cannot interleave, and its
|
|
8
|
+
* `per-record` layout keeps one file per note: one damaged record cannot take
|
|
9
|
+
* the whole wall down.
|
|
10
|
+
*
|
|
11
|
+
* The domain spec is mirrored structurally instead of imported: importing
|
|
12
|
+
* `@deepseek-ai/dsh-storage-domain` would install a second copy of the domain
|
|
13
|
+
* package (and its zod) inside the plugin instead of resolving the host's. The
|
|
14
|
+
* mirrored shape is the contract the live Host validates at `open()`:
|
|
15
|
+
* `{ name, version, layout, invalidRecords, tables: { <name>: { valueSchema } } }`
|
|
16
|
+
* with zod record schemas.
|
|
17
|
+
*/
|
|
18
|
+
import { z } from 'zod';
|
|
19
|
+
import { type Note, type NoteInput, type NoteLayout, type NotePatch } from './types.js';
|
|
20
|
+
/** Failure carrying a stable code the transport maps onto the RPC envelope. */
|
|
21
|
+
export declare class WallError extends Error {
|
|
22
|
+
readonly code: string;
|
|
23
|
+
readonly details: Record<string, unknown>;
|
|
24
|
+
constructor(code: string, message: string, details?: Record<string, unknown>);
|
|
25
|
+
}
|
|
26
|
+
/** The domain declaration handed to `ctx.storageDomain.open()`. */
|
|
27
|
+
export declare const DOMAIN_SPEC: {
|
|
28
|
+
name: string;
|
|
29
|
+
version: number;
|
|
30
|
+
layout: "per-record";
|
|
31
|
+
invalidRecords: "backup-and-skip";
|
|
32
|
+
tables: {
|
|
33
|
+
notes: {
|
|
34
|
+
valueSchema: z.ZodObject<{
|
|
35
|
+
id: z.ZodString;
|
|
36
|
+
title: z.ZodDefault<z.ZodString>;
|
|
37
|
+
body: z.ZodString;
|
|
38
|
+
tags: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
39
|
+
color: z.ZodDefault<z.ZodEnum<{
|
|
40
|
+
yellow: "yellow";
|
|
41
|
+
pink: "pink";
|
|
42
|
+
green: "green";
|
|
43
|
+
blue: "blue";
|
|
44
|
+
purple: "purple";
|
|
45
|
+
orange: "orange";
|
|
46
|
+
}>>;
|
|
47
|
+
pinned: z.ZodDefault<z.ZodBoolean>;
|
|
48
|
+
archived: z.ZodDefault<z.ZodBoolean>;
|
|
49
|
+
createdAt: z.ZodString;
|
|
50
|
+
updatedAt: z.ZodString;
|
|
51
|
+
layout: z.ZodDefault<z.ZodNullable<z.ZodObject<{
|
|
52
|
+
x: z.ZodNumber;
|
|
53
|
+
y: z.ZodNumber;
|
|
54
|
+
rotate: z.ZodNumber;
|
|
55
|
+
width: z.ZodEnum<{
|
|
56
|
+
sm: "sm";
|
|
57
|
+
md: "md";
|
|
58
|
+
lg: "lg";
|
|
59
|
+
}>;
|
|
60
|
+
}, z.core.$strip>>>;
|
|
61
|
+
}, z.core.$loose>;
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
/** The minimal domain table face this store uses; the rest is unused here. */
|
|
66
|
+
export interface DomainTable {
|
|
67
|
+
get(key: string): unknown;
|
|
68
|
+
entries(): Iterable<[string, unknown]>;
|
|
69
|
+
keys(): Iterable<string>;
|
|
70
|
+
readonly size: number;
|
|
71
|
+
put(key: string, value: Note): Promise<void>;
|
|
72
|
+
update(key: string, transform: (value: Note) => Note): Promise<Note>;
|
|
73
|
+
delete(key: string): Promise<boolean>;
|
|
74
|
+
}
|
|
75
|
+
/** The opened-domain handle the facility returns. */
|
|
76
|
+
export interface DomainHandle {
|
|
77
|
+
table(name: string): DomainTable;
|
|
78
|
+
close(): Promise<void>;
|
|
79
|
+
}
|
|
80
|
+
/** The store facade the Host endpoint and the agent tool share. */
|
|
81
|
+
export declare class StickyWallStore {
|
|
82
|
+
private readonly domain;
|
|
83
|
+
private readonly maxNotes;
|
|
84
|
+
private readonly now;
|
|
85
|
+
private readonly notes;
|
|
86
|
+
private closed;
|
|
87
|
+
/**
|
|
88
|
+
* Store-level mutation chain.
|
|
89
|
+
*
|
|
90
|
+
* The storage domain serializes its own writes, but a read-then-write pair
|
|
91
|
+
* (the `maxNotes` check followed by the `put`) is two steps: two concurrent
|
|
92
|
+
* creates at the boundary would both read the pre-write count and both
|
|
93
|
+
* succeed. Every mutation therefore runs on this chain, so each check and its
|
|
94
|
+
* write are one indivisible step.
|
|
95
|
+
*/
|
|
96
|
+
private chain;
|
|
97
|
+
constructor(domain: DomainHandle, maxNotes: number, now?: () => Date);
|
|
98
|
+
/** Open one domain and wrap it in a store. */
|
|
99
|
+
static open(open: (spec: typeof DOMAIN_SPEC) => Promise<DomainHandle>, options: {
|
|
100
|
+
maxNotes: number;
|
|
101
|
+
seedWelcomeNote: boolean;
|
|
102
|
+
now?: () => Date;
|
|
103
|
+
}): Promise<StickyWallStore>;
|
|
104
|
+
/** Release the domain handle; later writes fail instead of silently dropping. */
|
|
105
|
+
close(): Promise<void>;
|
|
106
|
+
private assertOpen;
|
|
107
|
+
/** Queue one mutation so its checks and writes cannot interleave with another. */
|
|
108
|
+
private mutate;
|
|
109
|
+
private liveCount;
|
|
110
|
+
/**
|
|
111
|
+
* Reject a write that would take the live (non-archived) note count past
|
|
112
|
+
* `maxNotes`.
|
|
113
|
+
*
|
|
114
|
+
* Every path that can make a note live goes through this — `create`, an
|
|
115
|
+
* `update` that unarchives, and a batch restore — because the documented cap
|
|
116
|
+
* is an invariant of the wall, not a hint on the create form.
|
|
117
|
+
*/
|
|
118
|
+
private assertRoom;
|
|
119
|
+
/** Write the one-time welcome note on a brand-new wall. */
|
|
120
|
+
private seedWelcomeNote;
|
|
121
|
+
/** Every stored note, newest-relevant first; archived ones only on request. */
|
|
122
|
+
list(includeArchived?: boolean, sort?: 'pinned' | 'created' | 'updated'): Note[];
|
|
123
|
+
/** Create one note from a Client or agent submission. */
|
|
124
|
+
create(input: NoteInput, id?: string): Promise<Note>;
|
|
125
|
+
private createNow;
|
|
126
|
+
/** Apply one patch; `updatedAt` moves only when a field actually changed. */
|
|
127
|
+
update(id: string, patch: NotePatch): Promise<Note>;
|
|
128
|
+
private updateNow;
|
|
129
|
+
/**
|
|
130
|
+
* Flip the archived flag on a batch; returns the notes that actually moved.
|
|
131
|
+
*
|
|
132
|
+
* Every id is resolved BEFORE the first write: a missing id rejects the whole
|
|
133
|
+
* request, so a bad batch cannot leave half the selection archived while the
|
|
134
|
+
* Client still believes the mutation failed.
|
|
135
|
+
*/
|
|
136
|
+
setArchived(ids: string[], archived: boolean): Promise<Note[]>;
|
|
137
|
+
private setArchivedNow;
|
|
138
|
+
/**
|
|
139
|
+
* Delete a batch; returns the ids that were removed.
|
|
140
|
+
*
|
|
141
|
+
* The whole selection is resolved before the first delete, so a request
|
|
142
|
+
* naming an absent note changes nothing: a caller is never told the batch
|
|
143
|
+
* failed while part of it was already applied. Batch semantics match
|
|
144
|
+
* {@link setArchived}.
|
|
145
|
+
*/
|
|
146
|
+
remove(ids: string[]): Promise<string[]>;
|
|
147
|
+
private removeNow;
|
|
148
|
+
/**
|
|
149
|
+
* Move one card on the free canvas.
|
|
150
|
+
*
|
|
151
|
+
* Moving is its own operation rather than an `update` patch: it deliberately
|
|
152
|
+
* does NOT bump `updatedAt`, because rearranging a wall is not editing a
|
|
153
|
+
* note's content and must not reshuffle a "by updated" sort. The write is
|
|
154
|
+
* still serialised on the mutation chain so two drags cannot race.
|
|
155
|
+
*/
|
|
156
|
+
move(id: string, layout: NoteLayout): Promise<Note>;
|
|
157
|
+
private moveNow;
|
|
158
|
+
/**
|
|
159
|
+
* Tidy the wall: repack every note into a deterministic shelf arrangement.
|
|
160
|
+
*
|
|
161
|
+
* Returns only the notes that actually moved, so the Client can reconcile
|
|
162
|
+
* without refetching the whole wall, and pressing tidy on an already-tidy
|
|
163
|
+
* wall is a no-op instead of a write storm.
|
|
164
|
+
*/
|
|
165
|
+
arrange(): Promise<Note[]>;
|
|
166
|
+
private arrangeNow;
|
|
167
|
+
/**
|
|
168
|
+
* Permanently remove every archived note.
|
|
169
|
+
*
|
|
170
|
+
* The archived set is read once and deleted in order; a delete failure stops
|
|
171
|
+
* the sweep and surfaces, leaving the remaining archived notes intact rather
|
|
172
|
+
* than reporting a clean sweep that did not happen.
|
|
173
|
+
*/
|
|
174
|
+
clearArchived(): Promise<string[]>;
|
|
175
|
+
private clearArchivedNow;
|
|
176
|
+
/** Compact plain-text digest of the wall for the model-facing tool. */
|
|
177
|
+
summary(limit?: number): {
|
|
178
|
+
total: number;
|
|
179
|
+
archived: number;
|
|
180
|
+
notes: Note[];
|
|
181
|
+
};
|
|
182
|
+
}
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable note store over the DSH storage domain.
|
|
3
|
+
*
|
|
4
|
+
* The Host owns all data: the Client renders snapshots and submits intents, and
|
|
5
|
+
* every write goes through {@link StickyWallStore} — validate, persist, then
|
|
6
|
+
* hand back the changed records. The storage domain provides a single
|
|
7
|
+
* per-domain write chain, so concurrent writes cannot interleave, and its
|
|
8
|
+
* `per-record` layout keeps one file per note: one damaged record cannot take
|
|
9
|
+
* the whole wall down.
|
|
10
|
+
*
|
|
11
|
+
* The domain spec is mirrored structurally instead of imported: importing
|
|
12
|
+
* `@deepseek-ai/dsh-storage-domain` would install a second copy of the domain
|
|
13
|
+
* package (and its zod) inside the plugin instead of resolving the host's. The
|
|
14
|
+
* mirrored shape is the contract the live Host validates at `open()`:
|
|
15
|
+
* `{ name, version, layout, invalidRecords, tables: { <name>: { valueSchema } } }`
|
|
16
|
+
* with zod record schemas.
|
|
17
|
+
*/
|
|
18
|
+
import { z } from 'zod';
|
|
19
|
+
import { arrangeLayouts, clampLayout, normalizeWidth, sameLayout } from './layout.js';
|
|
20
|
+
import { COLORS, WIDTHS, DOMAIN_NAME, DOMAIN_VERSION, LIMITS, } from './types.js';
|
|
21
|
+
/** Failure carrying a stable code the transport maps onto the RPC envelope. */
|
|
22
|
+
export class WallError extends Error {
|
|
23
|
+
code;
|
|
24
|
+
details;
|
|
25
|
+
constructor(code, message, details = {}) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.details = details;
|
|
29
|
+
this.name = 'WallError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Record schema; every stored note is validated at domain open and on write. */
|
|
33
|
+
const NoteSchema = z
|
|
34
|
+
.object({
|
|
35
|
+
id: z.string().min(1).max(128),
|
|
36
|
+
title: z.string().max(LIMITS.title).default(''),
|
|
37
|
+
body: z.string().min(1).max(LIMITS.body),
|
|
38
|
+
tags: z.array(z.string().min(1).max(LIMITS.tag)).max(LIMITS.tags).default([]),
|
|
39
|
+
color: z.enum(COLORS).default('yellow'),
|
|
40
|
+
pinned: z.boolean().default(false),
|
|
41
|
+
archived: z.boolean().default(false),
|
|
42
|
+
createdAt: z.string().min(1),
|
|
43
|
+
updatedAt: z.string().min(1),
|
|
44
|
+
// `null` means "never placed": the Client derives a preview position from
|
|
45
|
+
// the note id. A stored layout is owned by the Host, so a wall opened in
|
|
46
|
+
// two windows shows the same arrangement.
|
|
47
|
+
layout: z
|
|
48
|
+
.object({
|
|
49
|
+
x: z.number(),
|
|
50
|
+
y: z.number(),
|
|
51
|
+
rotate: z.number(),
|
|
52
|
+
width: z.enum(WIDTHS),
|
|
53
|
+
})
|
|
54
|
+
.nullable()
|
|
55
|
+
.default(null),
|
|
56
|
+
})
|
|
57
|
+
.passthrough();
|
|
58
|
+
/** The domain declaration handed to `ctx.storageDomain.open()`. */
|
|
59
|
+
export const DOMAIN_SPEC = {
|
|
60
|
+
name: DOMAIN_NAME,
|
|
61
|
+
version: DOMAIN_VERSION,
|
|
62
|
+
layout: 'per-record',
|
|
63
|
+
invalidRecords: 'backup-and-skip',
|
|
64
|
+
tables: { notes: { valueSchema: NoteSchema } },
|
|
65
|
+
};
|
|
66
|
+
/** Normalize one tag: trim, lowercase, collapse inner whitespace, bound length. */
|
|
67
|
+
function normalizeTag(raw) {
|
|
68
|
+
const value = String(raw ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
|
69
|
+
return value.slice(0, LIMITS.tag);
|
|
70
|
+
}
|
|
71
|
+
/** Normalize a submitted tag list: drop empties, deduplicate, cap the count. */
|
|
72
|
+
function normalizeTags(raw) {
|
|
73
|
+
if (!Array.isArray(raw))
|
|
74
|
+
return [];
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
for (const entry of raw) {
|
|
77
|
+
const tag = normalizeTag(entry);
|
|
78
|
+
if (tag === '' || seen.has(tag))
|
|
79
|
+
continue;
|
|
80
|
+
seen.add(tag);
|
|
81
|
+
if (seen.size >= LIMITS.tags)
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
return [...seen];
|
|
85
|
+
}
|
|
86
|
+
/** Validate and trim a note body; a live note may not be blank. */
|
|
87
|
+
function normalizeBody(raw) {
|
|
88
|
+
const value = String(raw ?? '').replace(/\r\n?/g, '\n').trim();
|
|
89
|
+
if (value === '')
|
|
90
|
+
throw new WallError('empty-body', 'A note needs some text.');
|
|
91
|
+
if (value.length > LIMITS.body)
|
|
92
|
+
throw new WallError('body-too-long', `A note is limited to ${LIMITS.body} characters.`, { limit: LIMITS.body, length: value.length });
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
/** Validate and trim a title; an absent title is the empty string. */
|
|
96
|
+
function normalizeTitle(raw) {
|
|
97
|
+
const value = String(raw ?? '').replace(/\r\n?/g, ' ').trim();
|
|
98
|
+
if (value.length > LIMITS.title)
|
|
99
|
+
throw new WallError('title-too-long', `A note title is limited to ${LIMITS.title} characters.`, { limit: LIMITS.title, length: value.length });
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
/** Accept only a declared pastel key. */
|
|
103
|
+
function normalizeColor(raw) {
|
|
104
|
+
const value = String(raw ?? '');
|
|
105
|
+
const match = COLORS.find((color) => color === value);
|
|
106
|
+
if (match === undefined)
|
|
107
|
+
throw new WallError('invalid-color', `Unknown note color ${JSON.stringify(value)}.`, { colors: [...COLORS] });
|
|
108
|
+
return match;
|
|
109
|
+
}
|
|
110
|
+
/** Validate and clamp a submitted placement; `null` clears it back to derived. */
|
|
111
|
+
function normalizeLayout(raw) {
|
|
112
|
+
if (raw === null || raw === undefined)
|
|
113
|
+
return null;
|
|
114
|
+
if (typeof raw !== 'object')
|
|
115
|
+
throw new WallError('invalid-layout', 'A note placement must be an object.');
|
|
116
|
+
const value = raw;
|
|
117
|
+
const x = Number(value.x);
|
|
118
|
+
const y = Number(value.y);
|
|
119
|
+
const rotate = Number(value.rotate ?? 0);
|
|
120
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(rotate)) {
|
|
121
|
+
throw new WallError('invalid-layout', 'A note placement needs finite x, y and rotate values.');
|
|
122
|
+
}
|
|
123
|
+
return clampLayout({ x, y, rotate, width: normalizeWidth(value.width) });
|
|
124
|
+
}
|
|
125
|
+
/** Reject ids that are not plausible note keys before touching storage. */
|
|
126
|
+
function assertIds(raw) {
|
|
127
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
128
|
+
throw new WallError('invalid-ids', 'Select at least one note.');
|
|
129
|
+
if (raw.length > LIMITS.bulk)
|
|
130
|
+
throw new WallError('too-many-ids', `At most ${LIMITS.bulk} notes per request.`, { limit: LIMITS.bulk });
|
|
131
|
+
const ids = [...new Set(raw.map((id) => String(id ?? '')))];
|
|
132
|
+
if (ids.some((id) => id === '' || id.length > 128))
|
|
133
|
+
throw new WallError('invalid-ids', 'One of the selected note ids is invalid.');
|
|
134
|
+
return ids;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* One record's sort weight: pinned first, then the requested ordering, with the
|
|
138
|
+
* id as a final tiebreaker so two notes written in the same millisecond still
|
|
139
|
+
* render in a stable order.
|
|
140
|
+
*/
|
|
141
|
+
function compareNotes(sort) {
|
|
142
|
+
return (left, right) => {
|
|
143
|
+
if (left.pinned !== right.pinned)
|
|
144
|
+
return left.pinned ? -1 : 1;
|
|
145
|
+
if (sort === 'created') {
|
|
146
|
+
return right.createdAt.localeCompare(left.createdAt) || right.updatedAt.localeCompare(left.updatedAt) || left.id.localeCompare(right.id);
|
|
147
|
+
}
|
|
148
|
+
return right.updatedAt.localeCompare(left.updatedAt) || left.id.localeCompare(right.id);
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** The store facade the Host endpoint and the agent tool share. */
|
|
152
|
+
export class StickyWallStore {
|
|
153
|
+
domain;
|
|
154
|
+
maxNotes;
|
|
155
|
+
now;
|
|
156
|
+
notes;
|
|
157
|
+
closed = false;
|
|
158
|
+
/**
|
|
159
|
+
* Store-level mutation chain.
|
|
160
|
+
*
|
|
161
|
+
* The storage domain serializes its own writes, but a read-then-write pair
|
|
162
|
+
* (the `maxNotes` check followed by the `put`) is two steps: two concurrent
|
|
163
|
+
* creates at the boundary would both read the pre-write count and both
|
|
164
|
+
* succeed. Every mutation therefore runs on this chain, so each check and its
|
|
165
|
+
* write are one indivisible step.
|
|
166
|
+
*/
|
|
167
|
+
chain = Promise.resolve();
|
|
168
|
+
constructor(domain, maxNotes, now = () => new Date()) {
|
|
169
|
+
this.domain = domain;
|
|
170
|
+
this.maxNotes = maxNotes;
|
|
171
|
+
this.now = now;
|
|
172
|
+
this.notes = domain.table('notes');
|
|
173
|
+
}
|
|
174
|
+
/** Open one domain and wrap it in a store. */
|
|
175
|
+
static async open(open, options) {
|
|
176
|
+
const domain = await open(DOMAIN_SPEC);
|
|
177
|
+
const store = new StickyWallStore(domain, options.maxNotes, options.now ?? (() => new Date()));
|
|
178
|
+
if (options.seedWelcomeNote)
|
|
179
|
+
await store.seedWelcomeNote();
|
|
180
|
+
return store;
|
|
181
|
+
}
|
|
182
|
+
/** Release the domain handle; later writes fail instead of silently dropping. */
|
|
183
|
+
async close() {
|
|
184
|
+
if (this.closed)
|
|
185
|
+
return;
|
|
186
|
+
this.closed = true;
|
|
187
|
+
await this.domain.close();
|
|
188
|
+
}
|
|
189
|
+
assertOpen() {
|
|
190
|
+
if (this.closed)
|
|
191
|
+
throw new WallError('closed', 'The note wall is shutting down.');
|
|
192
|
+
}
|
|
193
|
+
/** Queue one mutation so its checks and writes cannot interleave with another. */
|
|
194
|
+
mutate(job) {
|
|
195
|
+
const result = this.chain.then(job);
|
|
196
|
+
this.chain = result.then(() => undefined, () => undefined);
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
liveCount() {
|
|
200
|
+
let count = 0;
|
|
201
|
+
for (const [, note] of this.notes.entries())
|
|
202
|
+
if (!note.archived)
|
|
203
|
+
count += 1;
|
|
204
|
+
return count;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Reject a write that would take the live (non-archived) note count past
|
|
208
|
+
* `maxNotes`.
|
|
209
|
+
*
|
|
210
|
+
* Every path that can make a note live goes through this — `create`, an
|
|
211
|
+
* `update` that unarchives, and a batch restore — because the documented cap
|
|
212
|
+
* is an invariant of the wall, not a hint on the create form.
|
|
213
|
+
*/
|
|
214
|
+
assertRoom(incomingLive) {
|
|
215
|
+
if (incomingLive <= 0)
|
|
216
|
+
return;
|
|
217
|
+
const projected = this.liveCount() + incomingLive;
|
|
218
|
+
if (projected > this.maxNotes) {
|
|
219
|
+
throw new WallError('wall-full', `The wall already holds ${this.maxNotes} notes; archive or delete one first.`, {
|
|
220
|
+
limit: this.maxNotes,
|
|
221
|
+
projected,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** Write the one-time welcome note on a brand-new wall. */
|
|
226
|
+
async seedWelcomeNote() {
|
|
227
|
+
if (this.notes.size > 0)
|
|
228
|
+
return;
|
|
229
|
+
const stamp = this.now().toISOString();
|
|
230
|
+
const note = {
|
|
231
|
+
id: 'welcome',
|
|
232
|
+
title: '便签墙',
|
|
233
|
+
body: '随手记下一闪而过的灵感,或一件临时的工作。点右下角的 + 新建一张便签;按 ⌘/Ctrl + Enter 保存。',
|
|
234
|
+
tags: ['开始'],
|
|
235
|
+
color: 'yellow',
|
|
236
|
+
pinned: true,
|
|
237
|
+
archived: false,
|
|
238
|
+
createdAt: stamp,
|
|
239
|
+
updatedAt: stamp,
|
|
240
|
+
// The one note the plugin places itself, so a brand-new wall opens with a
|
|
241
|
+
// card that is obviously meant to be dragged.
|
|
242
|
+
layout: { x: 120, y: 96, rotate: -1.6, width: 'md' },
|
|
243
|
+
};
|
|
244
|
+
await this.notes.put(note.id, note);
|
|
245
|
+
}
|
|
246
|
+
/** Every stored note, newest-relevant first; archived ones only on request. */
|
|
247
|
+
list(includeArchived = false, sort = 'pinned') {
|
|
248
|
+
this.assertOpen();
|
|
249
|
+
const notes = [];
|
|
250
|
+
for (const [, note] of this.notes.entries()) {
|
|
251
|
+
const value = note;
|
|
252
|
+
if (value.archived && !includeArchived)
|
|
253
|
+
continue;
|
|
254
|
+
notes.push(value);
|
|
255
|
+
}
|
|
256
|
+
return notes.sort(compareNotes(sort));
|
|
257
|
+
}
|
|
258
|
+
/** Create one note from a Client or agent submission. */
|
|
259
|
+
async create(input, id) {
|
|
260
|
+
this.assertOpen();
|
|
261
|
+
return this.mutate(() => this.createNow(input, id));
|
|
262
|
+
}
|
|
263
|
+
async createNow(input, id) {
|
|
264
|
+
const body = normalizeBody(input?.body);
|
|
265
|
+
const title = normalizeTitle(input?.title);
|
|
266
|
+
this.assertRoom(1);
|
|
267
|
+
const stamp = this.now().toISOString();
|
|
268
|
+
const note = {
|
|
269
|
+
id: id ?? globalThis.crypto.randomUUID(),
|
|
270
|
+
title,
|
|
271
|
+
body,
|
|
272
|
+
tags: normalizeTags(input?.tags),
|
|
273
|
+
color: input?.color === undefined ? 'yellow' : normalizeColor(input.color),
|
|
274
|
+
pinned: input?.pinned === true,
|
|
275
|
+
archived: false,
|
|
276
|
+
createdAt: stamp,
|
|
277
|
+
updatedAt: stamp,
|
|
278
|
+
layout: input?.layout === undefined ? null : normalizeLayout(input.layout),
|
|
279
|
+
};
|
|
280
|
+
if (this.notes.get(note.id) !== undefined)
|
|
281
|
+
throw new WallError('duplicate-id', `A note with id ${JSON.stringify(note.id)} already exists.`);
|
|
282
|
+
await this.notes.put(note.id, note);
|
|
283
|
+
return note;
|
|
284
|
+
}
|
|
285
|
+
/** Apply one patch; `updatedAt` moves only when a field actually changed. */
|
|
286
|
+
async update(id, patch) {
|
|
287
|
+
this.assertOpen();
|
|
288
|
+
return this.mutate(() => this.updateNow(id, patch));
|
|
289
|
+
}
|
|
290
|
+
async updateNow(id, patch) {
|
|
291
|
+
const current = this.notes.get(id);
|
|
292
|
+
if (current === undefined)
|
|
293
|
+
throw new WallError('not-found', `No note with id ${JSON.stringify(id)}.`, { id });
|
|
294
|
+
const next = { ...current };
|
|
295
|
+
if (patch?.body !== undefined)
|
|
296
|
+
next.body = normalizeBody(patch.body);
|
|
297
|
+
if (patch?.title !== undefined)
|
|
298
|
+
next.title = normalizeTitle(patch.title);
|
|
299
|
+
if (patch?.tags !== undefined)
|
|
300
|
+
next.tags = normalizeTags(patch.tags);
|
|
301
|
+
if (patch?.color !== undefined)
|
|
302
|
+
next.color = normalizeColor(patch.color);
|
|
303
|
+
if (patch?.pinned !== undefined)
|
|
304
|
+
next.pinned = patch.pinned === true;
|
|
305
|
+
if (patch?.archived !== undefined)
|
|
306
|
+
next.archived = patch.archived === true;
|
|
307
|
+
// Restoring an archived note makes it live again, so it must fit the cap.
|
|
308
|
+
if (current.archived && !next.archived)
|
|
309
|
+
this.assertRoom(1);
|
|
310
|
+
const changed = ['body', 'title', 'color', 'pinned', 'archived'].some((key) => next[key] !== current[key]) ||
|
|
311
|
+
JSON.stringify(next.tags) !== JSON.stringify(current.tags);
|
|
312
|
+
if (!changed)
|
|
313
|
+
return current;
|
|
314
|
+
next.updatedAt = this.now().toISOString();
|
|
315
|
+
return this.notes.update(id, () => next);
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Flip the archived flag on a batch; returns the notes that actually moved.
|
|
319
|
+
*
|
|
320
|
+
* Every id is resolved BEFORE the first write: a missing id rejects the whole
|
|
321
|
+
* request, so a bad batch cannot leave half the selection archived while the
|
|
322
|
+
* Client still believes the mutation failed.
|
|
323
|
+
*/
|
|
324
|
+
async setArchived(ids, archived) {
|
|
325
|
+
this.assertOpen();
|
|
326
|
+
return this.mutate(() => this.setArchivedNow(ids, archived));
|
|
327
|
+
}
|
|
328
|
+
async setArchivedNow(ids, archived) {
|
|
329
|
+
const selected = assertIds(ids);
|
|
330
|
+
const targets = [];
|
|
331
|
+
for (const id of selected) {
|
|
332
|
+
const current = this.notes.get(id);
|
|
333
|
+
if (current === undefined)
|
|
334
|
+
throw new WallError('not-found', `No note with id ${JSON.stringify(id)}.`, { id });
|
|
335
|
+
if (current.archived !== archived)
|
|
336
|
+
targets.push(current);
|
|
337
|
+
}
|
|
338
|
+
// A restore batch makes every target live; check the whole batch up front so
|
|
339
|
+
// a partial restore can never exceed the cap.
|
|
340
|
+
if (!archived)
|
|
341
|
+
this.assertRoom(targets.length);
|
|
342
|
+
const changed = [];
|
|
343
|
+
for (const current of targets) {
|
|
344
|
+
const next = { ...current, archived, updatedAt: this.now().toISOString() };
|
|
345
|
+
changed.push(await this.notes.update(current.id, () => next));
|
|
346
|
+
}
|
|
347
|
+
return changed;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Delete a batch; returns the ids that were removed.
|
|
351
|
+
*
|
|
352
|
+
* The whole selection is resolved before the first delete, so a request
|
|
353
|
+
* naming an absent note changes nothing: a caller is never told the batch
|
|
354
|
+
* failed while part of it was already applied. Batch semantics match
|
|
355
|
+
* {@link setArchived}.
|
|
356
|
+
*/
|
|
357
|
+
async remove(ids) {
|
|
358
|
+
this.assertOpen();
|
|
359
|
+
return this.mutate(() => this.removeNow(ids));
|
|
360
|
+
}
|
|
361
|
+
async removeNow(ids) {
|
|
362
|
+
const selected = assertIds(ids);
|
|
363
|
+
for (const id of selected) {
|
|
364
|
+
if (this.notes.get(id) === undefined)
|
|
365
|
+
throw new WallError('not-found', `No note with id ${JSON.stringify(id)}.`, { id });
|
|
366
|
+
}
|
|
367
|
+
const removed = [];
|
|
368
|
+
for (const id of selected) {
|
|
369
|
+
await this.notes.delete(id);
|
|
370
|
+
removed.push(id);
|
|
371
|
+
}
|
|
372
|
+
return removed;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Move one card on the free canvas.
|
|
376
|
+
*
|
|
377
|
+
* Moving is its own operation rather than an `update` patch: it deliberately
|
|
378
|
+
* does NOT bump `updatedAt`, because rearranging a wall is not editing a
|
|
379
|
+
* note's content and must not reshuffle a "by updated" sort. The write is
|
|
380
|
+
* still serialised on the mutation chain so two drags cannot race.
|
|
381
|
+
*/
|
|
382
|
+
async move(id, layout) {
|
|
383
|
+
this.assertOpen();
|
|
384
|
+
return this.mutate(() => this.moveNow(id, layout));
|
|
385
|
+
}
|
|
386
|
+
async moveNow(id, layout) {
|
|
387
|
+
const current = this.notes.get(id);
|
|
388
|
+
if (current === undefined)
|
|
389
|
+
throw new WallError('not-found', `No note with id ${JSON.stringify(id)}.`, { id });
|
|
390
|
+
const next = normalizeLayout(layout);
|
|
391
|
+
if (sameLayout(current.layout, next))
|
|
392
|
+
return current;
|
|
393
|
+
return this.notes.update(id, () => ({ ...current, layout: next }));
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Tidy the wall: repack every note into a deterministic shelf arrangement.
|
|
397
|
+
*
|
|
398
|
+
* Returns only the notes that actually moved, so the Client can reconcile
|
|
399
|
+
* without refetching the whole wall, and pressing tidy on an already-tidy
|
|
400
|
+
* wall is a no-op instead of a write storm.
|
|
401
|
+
*/
|
|
402
|
+
async arrange() {
|
|
403
|
+
this.assertOpen();
|
|
404
|
+
return this.mutate(() => this.arrangeNow());
|
|
405
|
+
}
|
|
406
|
+
async arrangeNow() {
|
|
407
|
+
// Every note, archived ones included. Packing only the live set left an
|
|
408
|
+
// archived-but-visible card at its dragged position, on top of the freshly
|
|
409
|
+
// tidied ones — the wall has to be tidy in every filter, not just the
|
|
410
|
+
// default one.
|
|
411
|
+
const current = this.list(true, 'pinned');
|
|
412
|
+
const planned = arrangeLayouts(current);
|
|
413
|
+
const changed = [];
|
|
414
|
+
for (const note of current) {
|
|
415
|
+
const target = planned.get(note.id);
|
|
416
|
+
if (target === undefined || sameLayout(note.layout, target))
|
|
417
|
+
continue;
|
|
418
|
+
changed.push(await this.notes.update(note.id, () => ({ ...note, layout: target })));
|
|
419
|
+
}
|
|
420
|
+
return changed;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Permanently remove every archived note.
|
|
424
|
+
*
|
|
425
|
+
* The archived set is read once and deleted in order; a delete failure stops
|
|
426
|
+
* the sweep and surfaces, leaving the remaining archived notes intact rather
|
|
427
|
+
* than reporting a clean sweep that did not happen.
|
|
428
|
+
*/
|
|
429
|
+
async clearArchived() {
|
|
430
|
+
this.assertOpen();
|
|
431
|
+
return this.mutate(() => this.clearArchivedNow());
|
|
432
|
+
}
|
|
433
|
+
async clearArchivedNow() {
|
|
434
|
+
const archived = this.list(true).filter((note) => note.archived).map((note) => note.id);
|
|
435
|
+
const removed = [];
|
|
436
|
+
for (const id of archived) {
|
|
437
|
+
await this.notes.delete(id);
|
|
438
|
+
removed.push(id);
|
|
439
|
+
}
|
|
440
|
+
return removed;
|
|
441
|
+
}
|
|
442
|
+
/** Compact plain-text digest of the wall for the model-facing tool. */
|
|
443
|
+
summary(limit = 20) {
|
|
444
|
+
this.assertOpen();
|
|
445
|
+
const all = this.list(true, 'updated');
|
|
446
|
+
return {
|
|
447
|
+
total: all.length,
|
|
448
|
+
archived: all.filter((note) => note.archived).length,
|
|
449
|
+
notes: all.slice(0, Math.max(1, Math.min(limit, 50))),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host ↔ Client transport: one exact Fetch route inside Connection's fence.
|
|
3
|
+
*
|
|
4
|
+
* Connection owns the Host/Origin check, the browser-session authentication and
|
|
5
|
+
* the Peer scope; this module registers one exact `POST /api/<endpoint>` route
|
|
6
|
+
* and only has to validate its own envelope and payload. No physical server, no
|
|
7
|
+
* Express, and no second RPC channel is claimed.
|
|
8
|
+
*
|
|
9
|
+
* The wire envelope is the inspected Connection unary shape:
|
|
10
|
+
* `{ type: 'client-request', rpcId, method, payload }` in and
|
|
11
|
+
* `{ type: 'server-response', rpcId, result: { ok: true, value } | { ok: false, error } }`
|
|
12
|
+
* out. A non-owner Peer is rejected by Connection before this handler runs.
|
|
13
|
+
*/
|
|
14
|
+
import { type Note, type NoteInput, type NoteLayout, type NotePatch, type NoteSort, type WallResult } from './types.js';
|
|
15
|
+
/** Path of the exact route, under the shared `/api` channel. */
|
|
16
|
+
export declare const EXECUTE_PATH = "/api/skillre-sticky-wall/execute";
|
|
17
|
+
/** One buffered exact Fetch route as the Connection registry accepts it. */
|
|
18
|
+
export interface FetchRoute {
|
|
19
|
+
readonly path: string;
|
|
20
|
+
readonly methods: readonly ['POST'];
|
|
21
|
+
readonly requestBody: 'buffered';
|
|
22
|
+
readonly fetch: (request: Request) => Promise<Response>;
|
|
23
|
+
}
|
|
24
|
+
/** The Host Connection face this plugin depends on. */
|
|
25
|
+
export interface HostConnection {
|
|
26
|
+
readonly fetch: {
|
|
27
|
+
register(route: FetchRoute): () => Promise<void>;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** The store operations the route dispatches onto. */
|
|
31
|
+
export interface WallActions {
|
|
32
|
+
list(includeArchived?: boolean, sort?: NoteSort): Note[];
|
|
33
|
+
create(input: NoteInput): Promise<Note>;
|
|
34
|
+
update(id: string, patch: NotePatch): Promise<Note>;
|
|
35
|
+
setArchived(ids: string[], archived: boolean): Promise<Note[]>;
|
|
36
|
+
remove(ids: string[]): Promise<string[]>;
|
|
37
|
+
clearArchived(): Promise<string[]>;
|
|
38
|
+
move(id: string, layout: NoteLayout): Promise<Note>;
|
|
39
|
+
arrange(): Promise<Note[]>;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Build the exact route Connection registers; every failure stays inside the
|
|
43
|
+
* envelope. `ready` resolves the store lazily so a storage-open failure is
|
|
44
|
+
* reported on the first request instead of at registration time.
|
|
45
|
+
*/
|
|
46
|
+
export declare function createExecuteRoute(ready: () => Promise<WallActions>): FetchRoute;
|
|
47
|
+
/** Re-export the result envelope type for the Client half's structural mirror. */
|
|
48
|
+
export type { WallResult };
|