Upload 18 files
Browse files- DEPLOYMENT.md +98 -0
- scripts/seed-mock.mjs +1 -1
- server.mjs +6 -3
- src/.DS_Store +0 -0
- src/data/d1-repository.mjs +213 -0
- src/data/repository.mjs +20 -0
- src/data/sqlite-repository.mjs +244 -0
- src/data/state-utils.mjs +77 -0
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 |
-
|
|
|
|
| 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 |
+
}
|