ghuser1 commited on
Commit
fccdf92
·
verified ·
1 Parent(s): 9b99d65

Upload 18 files

Browse files
DEPLOYMENT.md ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Docker Deployment
2
+
3
+ The Docker image exposes the backup proxy on port `7860`, which is the public
4
+ entrypoint for Hugging Face Spaces. The original app runs internally on port
5
+ `4173`.
6
+
7
+ ## Environment
8
+
9
+ Required rclone configuration is provided by environment variables. The default
10
+ backup destination is:
11
+
12
+ ```text
13
+ REMOTE_FOLDER=huggingface:notes
14
+ ```
15
+
16
+ The backup file is always:
17
+
18
+ ```text
19
+ ${REMOTE_FOLDER}/notes.db
20
+ ```
21
+
22
+ There are two supported ways to provide rclone configuration.
23
+
24
+ ### Option 1: rclone.conf secret
25
+
26
+ Preferred for Hugging Face Secrets:
27
+
28
+ ```text
29
+ RCLONE_CONF=<raw rclone.conf content>
30
+ ```
31
+
32
+ The entrypoint writes it to:
33
+
34
+ ```text
35
+ /root/.config/rclone/rclone.conf
36
+ ```
37
+
38
+ Compatibility options are also supported:
39
+
40
+ ```text
41
+ RCLONE_CONFIG_BASE64=<base64 encoded rclone.conf>
42
+ RCLONE_CONFIG_CONTENT=<raw rclone.conf content>
43
+ ```
44
+
45
+ ### Option 2: rclone environment remote config
46
+
47
+ Configure the `huggingface` rclone remote with rclone environment variables,
48
+ for example:
49
+
50
+ ```text
51
+ RCLONE_CONFIG_HUGGINGFACE_TYPE=...
52
+ RCLONE_CONFIG_HUGGINGFACE_...=...
53
+ ```
54
+
55
+ The exact variables depend on the rclone backend used for the remote.
56
+
57
+ ## Startup Restore
58
+
59
+ On container startup, the entrypoint checks whether this file exists:
60
+
61
+ ```text
62
+ ${REMOTE_FOLDER}/notes.db
63
+ ```
64
+
65
+ If it exists, it is restored to:
66
+
67
+ ```text
68
+ data/notes.db
69
+ ```
70
+
71
+ If it does not exist, the app starts as a first install and initializes a new
72
+ database.
73
+
74
+ ## Manual Backup
75
+
76
+ The backup proxy injects a backup button into the page. Clicking it calls:
77
+
78
+ ```text
79
+ POST /api/backup/run
80
+ ```
81
+
82
+ The proxy verifies the current session with the app before running the backup.
83
+ Unauthenticated users receive `401`.
84
+
85
+ The server does not accept any command, path, or remote from the browser. The
86
+ backup target is fixed by `REMOTE_FOLDER`, and only one remote file is kept:
87
+
88
+ ```text
89
+ notes.db
90
+ ```
91
+
92
+ Uploads use a temporary remote file first:
93
+
94
+ ```text
95
+ notes.db.uploading
96
+ ```
97
+
98
+ Then it is moved into place as `notes.db`.
scripts/seed-mock.mjs CHANGED
@@ -1,5 +1,5 @@
1
  import { join } from "node:path";
2
- import { createRepository } from "../data/repository.mjs";
3
 
4
  const repository = createRepository({ dbPath: join(process.cwd(), "data", "notes.db") });
5
  await repository.init();
 
1
  import { join } from "node:path";
2
+ import { createRepository } from "../src/data/repository.mjs";
3
 
4
  const repository = createRepository({ dbPath: join(process.cwd(), "data", "notes.db") });
5
  await repository.init();
server.mjs CHANGED
@@ -1,16 +1,19 @@
1
  import { serve } from "@hono/node-server";
2
  import { Hono } from "hono";
3
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
 
4
  import { readFile } from "node:fs/promises";
5
  import { extname, join, normalize } from "node:path";
6
  import { createSessionToken, hashPassword, hashSessionToken, verifyPassword } from "./auth.mjs";
7
- import { createRepository } from "./data/repository.mjs";
8
- import { maxNoteBodyLength } from "./data/state-utils.mjs";
9
 
10
  const port = Number(process.env.PORT || 4173);
11
  const root = process.cwd();
 
12
  const app = new Hono();
13
- const repository = createRepository({ dbPath: join(root, "data", "notes.db") });
 
14
  const sessionCookie = "web_notes_session";
15
  const sessionTtlMs = 1000 * 60 * 60 * 24 * 30;
16
 
 
1
  import { serve } from "@hono/node-server";
2
  import { Hono } from "hono";
3
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
4
+ import { mkdirSync } from "node:fs";
5
  import { readFile } from "node:fs/promises";
6
  import { extname, join, normalize } from "node:path";
7
  import { createSessionToken, hashPassword, hashSessionToken, verifyPassword } from "./auth.mjs";
8
+ import { createRepository } from "./src/data/repository.mjs";
9
+ import { maxNoteBodyLength } from "./src/data/state-utils.mjs";
10
 
11
  const port = Number(process.env.PORT || 4173);
12
  const root = process.cwd();
13
+ const dataDir = join(root, "data");
14
  const app = new Hono();
15
+ mkdirSync(dataDir, { recursive: true });
16
+ const repository = createRepository({ dbPath: join(dataDir, "notes.db") });
17
  const sessionCookie = "web_notes_session";
18
  const sessionTtlMs = 1000 * 60 * 60 * 24 * 30;
19
 
src/.DS_Store ADDED
Binary file (6.15 kB). View file
 
src/data/d1-repository.mjs ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { clampNoteBody, createSeedState, normalizeStateForWrite } from "./state-utils.mjs";
2
+
3
+ export function createD1Repository({ db }) {
4
+ return {
5
+ runtime: "workerd",
6
+ async init() {
7
+ await db.exec(schemaSql);
8
+ await seedDatabase(db);
9
+ },
10
+ async readState() {
11
+ return readState(db);
12
+ },
13
+ async writeState(state) {
14
+ await writeState(db, state);
15
+ return readState(db);
16
+ },
17
+ async createNote(note) {
18
+ return createNote(db, note);
19
+ },
20
+ async updateNote(id, patch) {
21
+ return updateNote(db, id, patch);
22
+ },
23
+ async deleteNote(id) {
24
+ await db.prepare("DELETE FROM notes WHERE id = ?").bind(id).run();
25
+ },
26
+ async createFolder(folder) {
27
+ return createFolder(db, folder);
28
+ },
29
+ async deleteFolder(id) {
30
+ return deleteFolder(db, id);
31
+ },
32
+ async getUser() {
33
+ return await db.prepare("SELECT id, password_hash AS passwordHash, created_at AS createdAt FROM users ORDER BY created_at ASC LIMIT 1").first() || null;
34
+ },
35
+ async createUser({ passwordHash }) {
36
+ const now = Date.now();
37
+ const id = crypto.randomUUID();
38
+ await db.prepare("INSERT INTO users (id, password_hash, created_at) VALUES (?, ?, ?)").bind(id, passwordHash, now).run();
39
+ return { id, passwordHash, createdAt: now };
40
+ },
41
+ async updateUserPassword({ passwordHash }) {
42
+ const user = await this.getUser();
43
+ if (!user) return null;
44
+ await db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").bind(passwordHash, user.id).run();
45
+ return { ...user, passwordHash };
46
+ },
47
+ async createSession({ tokenHash, expiresAt }) {
48
+ const id = crypto.randomUUID();
49
+ await db.prepare("INSERT INTO sessions (id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?)").bind(id, tokenHash, expiresAt, Date.now()).run();
50
+ return { id, tokenHash, expiresAt };
51
+ },
52
+ async getSession(tokenHash) {
53
+ await db.prepare("DELETE FROM sessions WHERE expires_at <= ?").bind(Date.now()).run();
54
+ return await db.prepare("SELECT id, token_hash AS tokenHash, expires_at AS expiresAt FROM sessions WHERE token_hash = ? LIMIT 1").bind(tokenHash).first() || null;
55
+ },
56
+ async deleteSession(tokenHash) {
57
+ await db.prepare("DELETE FROM sessions WHERE token_hash = ?").bind(tokenHash).run();
58
+ },
59
+ async deleteAllSessions() {
60
+ await db.prepare("DELETE FROM sessions").run();
61
+ }
62
+ };
63
+ }
64
+
65
+ const schemaSql = `
66
+ CREATE TABLE IF NOT EXISTS folders (
67
+ id TEXT PRIMARY KEY,
68
+ name TEXT NOT NULL,
69
+ created_at INTEGER NOT NULL,
70
+ updated_at INTEGER NOT NULL,
71
+ version INTEGER NOT NULL DEFAULT 1
72
+ );
73
+ CREATE TABLE IF NOT EXISTS notes (
74
+ id TEXT PRIMARY KEY,
75
+ folder TEXT NOT NULL,
76
+ body TEXT NOT NULL,
77
+ created_at INTEGER NOT NULL,
78
+ updated_at INTEGER NOT NULL,
79
+ version INTEGER NOT NULL DEFAULT 1,
80
+ FOREIGN KEY(folder) REFERENCES folders(id)
81
+ );
82
+ CREATE TABLE IF NOT EXISTS users (
83
+ id TEXT PRIMARY KEY,
84
+ password_hash TEXT NOT NULL,
85
+ created_at INTEGER NOT NULL
86
+ );
87
+ CREATE TABLE IF NOT EXISTS sessions (
88
+ id TEXT PRIMARY KEY,
89
+ token_hash TEXT NOT NULL UNIQUE,
90
+ expires_at INTEGER NOT NULL,
91
+ created_at INTEGER NOT NULL
92
+ );
93
+ `;
94
+
95
+ async function seedDatabase(db) {
96
+ const existing = await db.prepare("SELECT COUNT(*) AS count FROM folders").first();
97
+ if (existing?.count > 0) return;
98
+ await writeState(db, createSeedState());
99
+ }
100
+
101
+ async function readState(db) {
102
+ const foldersResult = await db.prepare(`
103
+ SELECT id, name, created_at AS createdAt, updated_at AS updatedAt, version
104
+ FROM folders
105
+ ORDER BY created_at ASC
106
+ `).all();
107
+ const notesResult = await db.prepare(`
108
+ SELECT id, folder, body, created_at AS createdAt, updated_at AS updatedAt, version
109
+ FROM notes
110
+ ORDER BY updated_at DESC
111
+ `).all();
112
+
113
+ return {
114
+ folders: foldersResult.results || [],
115
+ notes: notesResult.results || []
116
+ };
117
+ }
118
+
119
+ async function writeState(db, state) {
120
+ const { folders, notes } = normalizeStateForWrite(state);
121
+ const statements = [
122
+ db.prepare("DELETE FROM notes"),
123
+ db.prepare("DELETE FROM folders"),
124
+ ...folders.map((folder) => db.prepare(`
125
+ INSERT INTO folders (id, name, created_at, updated_at, version)
126
+ VALUES (?, ?, ?, ?, ?)
127
+ `).bind(folder.id, folder.name, folder.createdAt, folder.updatedAt, folder.version)),
128
+ ...notes.map((note) => db.prepare(`
129
+ INSERT INTO notes (id, folder, body, created_at, updated_at, version)
130
+ VALUES (?, ?, ?, ?, ?, ?)
131
+ `).bind(note.id, note.folder, note.body, note.createdAt, note.updatedAt, note.version))
132
+ ];
133
+
134
+ await db.batch(statements);
135
+ }
136
+
137
+ async function createNote(db, note) {
138
+ const now = Date.now();
139
+ const folder = await db.prepare("SELECT id FROM folders WHERE id = ? LIMIT 1").bind(note.folder).first();
140
+ const folderId = folder?.id || "notes";
141
+ const createdAt = Number(note.createdAt || now);
142
+ const updatedAt = Number(note.updatedAt || now);
143
+ const id = String(note.id || crypto.randomUUID());
144
+ await db.prepare(`
145
+ INSERT INTO notes (id, folder, body, created_at, updated_at, version)
146
+ VALUES (?, ?, ?, ?, ?, 1)
147
+ `).bind(id, folderId, clampNoteBody(note.body), createdAt, updatedAt).run();
148
+ return getNote(db, id);
149
+ }
150
+
151
+ async function updateNote(db, id, patch) {
152
+ const existing = await getNote(db, id);
153
+ if (!existing) return { status: "missing" };
154
+ const expectedVersion = Number(patch.version);
155
+ if (expectedVersion && expectedVersion !== existing.version) {
156
+ return { status: "conflict", note: existing };
157
+ }
158
+ const folder = patch.folder ? await db.prepare("SELECT id FROM folders WHERE id = ? LIMIT 1").bind(patch.folder).first() : null;
159
+ const nextFolder = folder?.id || existing.folder;
160
+ const nextBody = patch.body === undefined ? existing.body : clampNoteBody(patch.body);
161
+ const nextUpdatedAt = Number(patch.updatedAt || Date.now());
162
+ const nextVersion = existing.version + 1;
163
+ await db.prepare(`
164
+ UPDATE notes
165
+ SET folder = ?, body = ?, updated_at = ?, version = ?
166
+ WHERE id = ?
167
+ `).bind(nextFolder, nextBody, nextUpdatedAt, nextVersion, id).run();
168
+ return { status: "ok", note: await getNote(db, id) };
169
+ }
170
+
171
+ async function getNote(db, id) {
172
+ return await db.prepare(`
173
+ SELECT id, folder, body, created_at AS createdAt, updated_at AS updatedAt, version
174
+ FROM notes
175
+ WHERE id = ?
176
+ LIMIT 1
177
+ `).bind(id).first() || null;
178
+ }
179
+
180
+ async function createFolder(db, folder) {
181
+ const now = Date.now();
182
+ const id = String(folder.id || crypto.randomUUID());
183
+ const name = String(folder.name || "未命名文件夹").trim() || "未命名文件夹";
184
+ const createdAt = Number(folder.createdAt || now);
185
+ const updatedAt = Number(folder.updatedAt || now);
186
+ await db.prepare(`
187
+ INSERT INTO folders (id, name, created_at, updated_at, version)
188
+ VALUES (?, ?, ?, ?, 1)
189
+ `).bind(id, name, createdAt, updatedAt).run();
190
+ return getFolder(db, id);
191
+ }
192
+
193
+ async function deleteFolder(db, id) {
194
+ const folder = await getFolder(db, id);
195
+ if (!folder || folder.id === "notes") return { status: "missing" };
196
+ const fallback = await getFolder(db, "notes") || await db.prepare("SELECT id FROM folders ORDER BY created_at ASC LIMIT 1").first();
197
+ if (!fallback) return { status: "missing" };
198
+ const now = Date.now();
199
+ await db.batch([
200
+ db.prepare("UPDATE notes SET folder = ?, updated_at = ?, version = version + 1 WHERE folder = ?").bind(fallback.id, now, folder.id),
201
+ db.prepare("DELETE FROM folders WHERE id = ?").bind(folder.id)
202
+ ]);
203
+ return { status: "ok", fallbackFolderId: fallback.id };
204
+ }
205
+
206
+ async function getFolder(db, id) {
207
+ return await db.prepare(`
208
+ SELECT id, name, created_at AS createdAt, updated_at AS updatedAt, version
209
+ FROM folders
210
+ WHERE id = ?
211
+ LIMIT 1
212
+ `).bind(id).first() || null;
213
+ }
src/data/repository.mjs ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getRuntimeKey } from "hono/adapter";
2
+ import { createD1Repository } from "./d1-repository.mjs";
3
+ import { createSqliteRepository } from "./sqlite-repository.mjs";
4
+
5
+ export function createRepository(options = {}) {
6
+ const runtime = options.runtime || getRuntimeKey();
7
+
8
+ if (runtime === "workerd") {
9
+ if (!options.env?.DB && !options.db) {
10
+ throw new Error("Cloudflare runtime requires a D1 binding named DB.");
11
+ }
12
+ return createD1Repository({ db: options.db || options.env.DB });
13
+ }
14
+
15
+ if (runtime === "node") {
16
+ return createSqliteRepository({ dbPath: options.dbPath });
17
+ }
18
+
19
+ throw new Error(`Unsupported runtime for data repository: ${runtime}`);
20
+ }
src/data/sqlite-repository.mjs ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { clampNoteBody, createSeedState, normalizeStateForWrite } from "./state-utils.mjs";
3
+
4
+ export function createSqliteRepository({ dbPath }) {
5
+ const db = new DatabaseSync(dbPath);
6
+
7
+ return {
8
+ runtime: "node",
9
+ async init() {
10
+ db.exec(schemaSql);
11
+ seedDatabase(db);
12
+ },
13
+ async readState() {
14
+ return readState(db);
15
+ },
16
+ async writeState(state) {
17
+ writeState(db, state);
18
+ return readState(db);
19
+ },
20
+ async createNote(note) {
21
+ return createNote(db, note);
22
+ },
23
+ async updateNote(id, patch) {
24
+ return updateNote(db, id, patch);
25
+ },
26
+ async deleteNote(id) {
27
+ db.prepare("DELETE FROM notes WHERE id = ?").run(id);
28
+ },
29
+ async createFolder(folder) {
30
+ return createFolder(db, folder);
31
+ },
32
+ async deleteFolder(id) {
33
+ return deleteFolder(db, id);
34
+ },
35
+ async getUser() {
36
+ return db.prepare("SELECT id, password_hash AS passwordHash, created_at AS createdAt FROM users ORDER BY created_at ASC LIMIT 1").get() || null;
37
+ },
38
+ async updateUserPassword({ passwordHash }) {
39
+ const user = await this.getUser();
40
+ if (!user) return null;
41
+ db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(passwordHash, user.id);
42
+ return { ...user, passwordHash };
43
+ },
44
+ async createUser({ passwordHash }) {
45
+ const now = Date.now();
46
+ const id = crypto.randomUUID();
47
+ db.prepare("INSERT INTO users (id, password_hash, created_at) VALUES (?, ?, ?)").run(id, passwordHash, now);
48
+ return { id, passwordHash, createdAt: now };
49
+ },
50
+ async createSession({ tokenHash, expiresAt }) {
51
+ const id = crypto.randomUUID();
52
+ db.prepare("INSERT INTO sessions (id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?)").run(id, tokenHash, expiresAt, Date.now());
53
+ return { id, tokenHash, expiresAt };
54
+ },
55
+ async getSession(tokenHash) {
56
+ db.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(Date.now());
57
+ return db.prepare("SELECT id, token_hash AS tokenHash, expires_at AS expiresAt FROM sessions WHERE token_hash = ? LIMIT 1").get(tokenHash) || null;
58
+ },
59
+ async deleteSession(tokenHash) {
60
+ db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash);
61
+ },
62
+ async deleteAllSessions() {
63
+ db.prepare("DELETE FROM sessions").run();
64
+ }
65
+ };
66
+ }
67
+
68
+ const schemaSql = `
69
+ PRAGMA journal_mode = WAL;
70
+ CREATE TABLE IF NOT EXISTS folders (
71
+ id TEXT PRIMARY KEY,
72
+ name TEXT NOT NULL,
73
+ created_at INTEGER NOT NULL,
74
+ updated_at INTEGER NOT NULL,
75
+ version INTEGER NOT NULL DEFAULT 1
76
+ );
77
+ CREATE TABLE IF NOT EXISTS notes (
78
+ id TEXT PRIMARY KEY,
79
+ folder TEXT NOT NULL,
80
+ body TEXT NOT NULL,
81
+ created_at INTEGER NOT NULL,
82
+ updated_at INTEGER NOT NULL,
83
+ version INTEGER NOT NULL DEFAULT 1,
84
+ FOREIGN KEY(folder) REFERENCES folders(id)
85
+ );
86
+ CREATE TABLE IF NOT EXISTS users (
87
+ id TEXT PRIMARY KEY,
88
+ password_hash TEXT NOT NULL,
89
+ created_at INTEGER NOT NULL
90
+ );
91
+ CREATE TABLE IF NOT EXISTS sessions (
92
+ id TEXT PRIMARY KEY,
93
+ token_hash TEXT NOT NULL UNIQUE,
94
+ expires_at INTEGER NOT NULL,
95
+ created_at INTEGER NOT NULL
96
+ );
97
+ `;
98
+
99
+ function seedDatabase(db) {
100
+ migrateDatabase(db);
101
+ const existing = db.prepare("SELECT COUNT(*) AS count FROM folders").get();
102
+ if (existing.count > 0) return;
103
+ writeState(db, createSeedState());
104
+ }
105
+
106
+ function migrateDatabase(db) {
107
+ addColumnIfMissing(db, "folders", "version", "INTEGER NOT NULL DEFAULT 1");
108
+ addColumnIfMissing(db, "notes", "version", "INTEGER NOT NULL DEFAULT 1");
109
+ }
110
+
111
+ function addColumnIfMissing(db, table, column, definition) {
112
+ const columns = db.prepare(`PRAGMA table_info(${table})`).all();
113
+ if (columns.some((entry) => entry.name === column)) return;
114
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
115
+ }
116
+
117
+ function readState(db) {
118
+ const folders = db.prepare(`
119
+ SELECT id, name, created_at AS createdAt, updated_at AS updatedAt, version
120
+ FROM folders
121
+ ORDER BY created_at ASC
122
+ `).all();
123
+ const notes = db.prepare(`
124
+ SELECT id, folder, body, created_at AS createdAt, updated_at AS updatedAt, version
125
+ FROM notes
126
+ ORDER BY updated_at DESC
127
+ `).all();
128
+
129
+ return { folders, notes };
130
+ }
131
+
132
+ function writeState(db, state) {
133
+ const { folders, notes } = normalizeStateForWrite(state);
134
+ const insertFolder = db.prepare(`
135
+ INSERT INTO folders (id, name, created_at, updated_at, version)
136
+ VALUES (?, ?, ?, ?, ?)
137
+ `);
138
+ const insertNote = db.prepare(`
139
+ INSERT INTO notes (id, folder, body, created_at, updated_at, version)
140
+ VALUES (?, ?, ?, ?, ?, ?)
141
+ `);
142
+
143
+ db.exec("BEGIN");
144
+ try {
145
+ db.exec("DELETE FROM notes");
146
+ db.exec("DELETE FROM folders");
147
+ folders.forEach((folder) => {
148
+ insertFolder.run(folder.id, folder.name, folder.createdAt, folder.updatedAt, folder.version);
149
+ });
150
+ notes.forEach((note) => {
151
+ insertNote.run(note.id, note.folder, note.body, note.createdAt, note.updatedAt, note.version);
152
+ });
153
+ db.exec("COMMIT");
154
+ } catch (error) {
155
+ db.exec("ROLLBACK");
156
+ throw error;
157
+ }
158
+ }
159
+
160
+ function createNote(db, note) {
161
+ const now = Date.now();
162
+ const folder = db.prepare("SELECT id FROM folders WHERE id = ? LIMIT 1").get(note.folder);
163
+ const folderId = folder?.id || "notes";
164
+ const createdAt = Number(note.createdAt || now);
165
+ const updatedAt = Number(note.updatedAt || now);
166
+ const id = String(note.id || crypto.randomUUID());
167
+ db.prepare(`
168
+ INSERT INTO notes (id, folder, body, created_at, updated_at, version)
169
+ VALUES (?, ?, ?, ?, ?, 1)
170
+ `).run(id, folderId, clampNoteBody(note.body), createdAt, updatedAt);
171
+ return getNote(db, id);
172
+ }
173
+
174
+ function updateNote(db, id, patch) {
175
+ const existing = getNote(db, id);
176
+ if (!existing) return { status: "missing" };
177
+ const expectedVersion = Number(patch.version);
178
+ if (expectedVersion && expectedVersion !== existing.version) {
179
+ return { status: "conflict", note: existing };
180
+ }
181
+
182
+ const folder = patch.folder && db.prepare("SELECT id FROM folders WHERE id = ? LIMIT 1").get(patch.folder);
183
+ const nextFolder = folder?.id || existing.folder;
184
+ const nextBody = patch.body === undefined ? existing.body : clampNoteBody(patch.body);
185
+ const nextUpdatedAt = Number(patch.updatedAt || Date.now());
186
+ const nextVersion = existing.version + 1;
187
+
188
+ db.prepare(`
189
+ UPDATE notes
190
+ SET folder = ?, body = ?, updated_at = ?, version = ?
191
+ WHERE id = ?
192
+ `).run(nextFolder, nextBody, nextUpdatedAt, nextVersion, id);
193
+
194
+ return { status: "ok", note: getNote(db, id) };
195
+ }
196
+
197
+ function getNote(db, id) {
198
+ return db.prepare(`
199
+ SELECT id, folder, body, created_at AS createdAt, updated_at AS updatedAt, version
200
+ FROM notes
201
+ WHERE id = ?
202
+ LIMIT 1
203
+ `).get(id) || null;
204
+ }
205
+
206
+ function createFolder(db, folder) {
207
+ const now = Date.now();
208
+ const id = String(folder.id || crypto.randomUUID());
209
+ const name = String(folder.name || "未命名文件夹").trim() || "未命名文件夹";
210
+ const createdAt = Number(folder.createdAt || now);
211
+ const updatedAt = Number(folder.updatedAt || now);
212
+ db.prepare(`
213
+ INSERT INTO folders (id, name, created_at, updated_at, version)
214
+ VALUES (?, ?, ?, ?, 1)
215
+ `).run(id, name, createdAt, updatedAt);
216
+ return getFolder(db, id);
217
+ }
218
+
219
+ function deleteFolder(db, id) {
220
+ const folder = getFolder(db, id);
221
+ if (!folder || folder.id === "notes") return { status: "missing" };
222
+ const fallback = getFolder(db, "notes") || db.prepare("SELECT id FROM folders ORDER BY created_at ASC LIMIT 1").get();
223
+ if (!fallback) return { status: "missing" };
224
+ const now = Date.now();
225
+ db.exec("BEGIN");
226
+ try {
227
+ db.prepare("UPDATE notes SET folder = ?, updated_at = ?, version = version + 1 WHERE folder = ?").run(fallback.id, now, folder.id);
228
+ db.prepare("DELETE FROM folders WHERE id = ?").run(folder.id);
229
+ db.exec("COMMIT");
230
+ } catch (error) {
231
+ db.exec("ROLLBACK");
232
+ throw error;
233
+ }
234
+ return { status: "ok", fallbackFolderId: fallback.id };
235
+ }
236
+
237
+ function getFolder(db, id) {
238
+ return db.prepare(`
239
+ SELECT id, name, created_at AS createdAt, updated_at AS updatedAt, version
240
+ FROM folders
241
+ WHERE id = ?
242
+ LIMIT 1
243
+ `).get(id) || null;
244
+ }
src/data/state-utils.mjs ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const maxNoteBodyLength = 1024 * 1024;
2
+
3
+ export function createSeedState(now = Date.now()) {
4
+ return {
5
+ folders: [
6
+ {
7
+ id: "notes",
8
+ name: "备忘录",
9
+ createdAt: now,
10
+ updatedAt: now
11
+ }
12
+ ],
13
+ notes: [
14
+ {
15
+ id: crypto.randomUUID(),
16
+ folder: "notes",
17
+ body: "欢迎使用备忘录\n\n左侧选择文件夹,中间选择笔记,右侧直接编辑。第一行会自动作为标题。",
18
+ createdAt: now - 1000 * 60 * 60 * 24,
19
+ updatedAt: now - 1000 * 60 * 12,
20
+ version: 1
21
+ },
22
+ {
23
+ id: crypto.randomUUID(),
24
+ folder: "notes",
25
+ body: "待办清单\n\n- 记录想法\n- 整理项目\n- 做一个真正好用的 Web 版笔记",
26
+ createdAt: now - 1000 * 60 * 60 * 3,
27
+ updatedAt: now - 1000 * 60 * 35,
28
+ version: 1
29
+ }
30
+ ]
31
+ };
32
+ }
33
+
34
+ export function normalizeStateForWrite({ folders = [], notes = [] }, now = Date.now()) {
35
+ const normalizedFolders = normalizeFolders(folders, now);
36
+ const folderIds = new Set(normalizedFolders.map((folder) => folder.id));
37
+ const normalizedNotes = notes.map((note) => ({
38
+ id: String(note.id || crypto.randomUUID()),
39
+ folder: folderIds.has(note.folder) ? note.folder : "notes",
40
+ body: clampNoteBody(note.body),
41
+ createdAt: Number(note.createdAt || now),
42
+ updatedAt: Number(note.updatedAt || now),
43
+ version: Number(note.version || 1)
44
+ }));
45
+
46
+ return {
47
+ folders: normalizedFolders,
48
+ notes: normalizedNotes
49
+ };
50
+ }
51
+
52
+ function normalizeFolders(folders, now) {
53
+ const seen = new Set();
54
+ const normalized = folders
55
+ .map((folder) => ({
56
+ id: String(folder.id || crypto.randomUUID()),
57
+ name: String(folder.name || "未命名文件夹").trim() || "未命名文件夹",
58
+ createdAt: Number(folder.createdAt || now),
59
+ updatedAt: Number(folder.updatedAt || now),
60
+ version: Number(folder.version || 1)
61
+ }))
62
+ .filter((folder) => {
63
+ if (seen.has(folder.id)) return false;
64
+ seen.add(folder.id);
65
+ return true;
66
+ });
67
+
68
+ if (!seen.has("notes")) {
69
+ normalized.unshift({ id: "notes", name: "备忘录", createdAt: now, updatedAt: now, version: 1 });
70
+ }
71
+
72
+ return normalized;
73
+ }
74
+
75
+ export function clampNoteBody(value) {
76
+ return String(value || "").slice(0, maxNoteBodyLength);
77
+ }