feat: 实现文件夹和书签的持久排序与拖拽功能

This commit is contained in:
2026-01-18 23:33:31 +08:00
parent 6eb3c730bb
commit dbeb181e5d
49 changed files with 3141 additions and 507 deletions

View File

@@ -15,6 +15,7 @@ create table if not exists bookmark_folders (
parent_id uuid null references bookmark_folders(id) on delete cascade,
name text not null,
visibility text not null default 'private',
sort_order integer not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
@@ -25,6 +26,7 @@ create table if not exists bookmarks (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users(id) on delete cascade,
folder_id uuid null references bookmark_folders(id) on delete set null,
sort_order integer not null default 0,
title text not null,
url text not null,
url_normalized text not null,
@@ -37,5 +39,6 @@ create table if not exists bookmarks (
);
create index if not exists idx_bookmarks_user_updated_at on bookmarks (user_id, updated_at);
create index if not exists idx_bookmarks_user_folder_sort on bookmarks (user_id, folder_id, sort_order);
create index if not exists idx_bookmarks_user_url_hash on bookmarks (user_id, url_hash);
create index if not exists idx_bookmarks_visibility on bookmarks (visibility);

View File

@@ -0,0 +1,5 @@
alter table if exists bookmark_folders
add column if not exists sort_order integer not null default 0;
create index if not exists idx_bookmark_folders_user_parent_sort
on bookmark_folders (user_id, parent_id, sort_order);

View File

@@ -0,0 +1,5 @@
alter table if exists bookmarks
add column if not exists sort_order integer not null default 0;
create index if not exists idx_bookmarks_user_folder_sort
on bookmarks (user_id, folder_id, sort_order);

View File

@@ -9,7 +9,8 @@
"build": "node -c src/index.js && node -c src/routes/auth.routes.js && node -c src/routes/bookmarks.routes.js && node -c src/routes/folders.routes.js && node -c src/routes/importExport.routes.js && node -c src/routes/sync.routes.js",
"test": "node --test",
"lint": "eslint .",
"db:migrate": "node src/migrate.js"
"db:migrate": "node src/migrate.js",
"db:reset": "node src/resetDb.js"
},
"dependencies": {
"@browser-bookmark/shared": "0.1.0",

View File

@@ -22,9 +22,11 @@ loadEnv();
export function getConfig() {
const serverPort = Number(process.env.SERVER_PORT || 3001);
const adminEmail = String(process.env.ADMIN_EMAIL || "").trim().toLowerCase();
return {
serverPort,
adminEmail,
database: {
host: process.env.DATABASE_HOST || "127.0.0.1",
port: Number(process.env.DATABASE_PORT || 5432),

View File

@@ -5,6 +5,7 @@ import jwt from "@fastify/jwt";
import { getConfig } from "./config.js";
import { createPool } from "./db.js";
import { authRoutes } from "./routes/auth.routes.js";
import { adminRoutes } from "./routes/admin.routes.js";
import { foldersRoutes } from "./routes/folders.routes.js";
import { bookmarksRoutes } from "./routes/bookmarks.routes.js";
import { importExportRoutes } from "./routes/importExport.routes.js";
@@ -15,7 +16,9 @@ const app = Fastify({ logger: true });
// Plugins
await app.register(cors, {
origin: true,
credentials: true
credentials: true,
methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "Accept"]
});
await app.register(multipart);
@@ -25,7 +28,29 @@ if (!jwtSecret) {
}
await app.register(jwt, { secret: jwtSecret });
app.decorate("pg", createPool());
const pool = createPool();
app.decorate("pg", pool);
// Detect optional DB features (for backwards compatibility when migrations haven't run yet).
async function hasColumn(tableName, columnName) {
try {
const r = await app.pg.query(
"select 1 from information_schema.columns where table_schema=current_schema() and table_name=$1 and column_name=$2 limit 1",
[tableName, columnName]
);
return r.rowCount > 0;
} catch {
return false;
}
}
const folderSortOrderSupported = await hasColumn("bookmark_folders", "sort_order");
const bookmarkSortOrderSupported = await hasColumn("bookmarks", "sort_order");
app.decorate("features", {
folderSortOrder: folderSortOrderSupported,
bookmarkSortOrder: bookmarkSortOrderSupported
});
app.decorate("authenticate", async (req, reply) => {
try {
@@ -44,7 +69,11 @@ app.setErrorHandler((err, _req, reply) => {
app.get("/health", async () => ({ ok: true }));
// Routes
const config = getConfig();
app.decorate("config", config);
await authRoutes(app);
await adminRoutes(app);
await foldersRoutes(app);
await bookmarksRoutes(app);
await importExportRoutes(app);
@@ -54,5 +83,5 @@ app.addHook("onClose", async (instance) => {
await instance.pg.end();
});
const { serverPort } = getConfig();
const { serverPort } = config;
await app.listen({ port: serverPort, host: "0.0.0.0" });

View File

@@ -0,0 +1,29 @@
import { httpError } from "./httpErrors.js";
function normalizeEmail(email) {
return String(email || "").trim().toLowerCase();
}
export async function requireAdmin(app, req) {
await app.authenticate(req);
const userId = req.user?.sub;
if (!userId) throw httpError(401, "unauthorized");
const res = await app.pg.query(
"select id, email, role, created_at, updated_at from users where id=$1",
[userId]
);
const row = res.rows[0];
if (!row) throw httpError(401, "unauthorized");
const adminEmail = normalizeEmail(app.config?.adminEmail);
const isAdmin = Boolean(adminEmail) && normalizeEmail(row.email) === adminEmail;
if (!isAdmin) throw httpError(403, "admin only");
req.adminUser = row;
}
export function isAdminEmail(app, email) {
const adminEmail = normalizeEmail(app.config?.adminEmail);
return Boolean(adminEmail) && normalizeEmail(email) === adminEmail;
}

View File

@@ -8,26 +8,58 @@ export function parseNetscapeBookmarkHtmlNode(html) {
const folders = [];
const bookmarks = [];
function walkDl($dl, parentTempId) {
// Netscape format: <DL><p> contains repeating <DT> items and nested <DL>
const children = $dl.children().toArray();
for (let i = 0; i < children.length; i++) {
const node = children[i];
if (!node || node.tagName?.toLowerCase() !== "dt") continue;
function normText(s) {
return String(s || "").replace(/\s+/g, " ").trim();
}
function collectLevelDt(node) {
const out = [];
const children = $(node).contents().toArray();
for (const child of children) {
if (!child || child.type !== "tag") continue;
const tag = child.tagName?.toLowerCase();
if (tag === "dt") {
out.push(child);
continue;
}
if (tag === "dl") {
// nested list belongs to the previous <DT>
continue;
}
out.push(...collectLevelDt(child));
}
return out;
}
function findNextDlForDt(dtNode, stopDlNode) {
let cur = dtNode;
while (cur && cur !== stopDlNode) {
let next = cur.nextSibling;
while (next && next.type !== "tag") next = next.nextSibling;
if (next && next.type === "tag" && next.tagName?.toLowerCase() === "dl") return $(next);
cur = cur.parent;
}
return null;
}
function walkDl($dl, parentTempId) {
// Netscape format: <DL><p> contains repeating <DT> items and nested <DL>.
// When parsed, <DT> may be wrapped (e.g. inside <p>), so we must be robust.
const dts = collectLevelDt($dl[0]);
for (const node of dts) {
const $dt = $(node);
const $h3 = $dt.children("h3").first();
const $a = $dt.children("a").first();
const $next = $(children[i + 1] || null);
const nextIsDl = $next && $next[0]?.tagName?.toLowerCase() === "dl";
const $h3 = $dt.children("h3").first().length ? $dt.children("h3").first() : $dt.find("h3").first();
const $a = $dt.children("a").first().length ? $dt.children("a").first() : $dt.find("a").first();
const $nestedDl = $dt.children("dl").first();
const $nextDl = $nestedDl.length ? $nestedDl : findNextDlForDt(node, $dl[0]);
if ($h3.length) {
const tempId = `${folders.length + 1}`;
const name = ($h3.text() || "").trim();
const name = normText($h3.text() || "");
folders.push({ tempId, parentTempId: parentTempId ?? null, name });
if (nextIsDl) walkDl($next, tempId);
if ($nextDl?.length) walkDl($nextDl, tempId);
} else if ($a.length) {
const title = ($a.text() || "").trim();
const title = normText($a.text() || "");
const url = $a.attr("href") || "";
bookmarks.push({ parentTempId: parentTempId ?? null, title, url });
}

View File

@@ -15,6 +15,7 @@ export function folderRowToDto(row) {
parentId: row.parent_id,
name: row.name,
visibility: row.visibility,
sortOrder: row.sort_order ?? 0,
createdAt: row.created_at,
updatedAt: row.updated_at
};
@@ -25,6 +26,7 @@ export function bookmarkRowToDto(row) {
id: row.id,
userId: row.user_id,
folderId: row.folder_id,
sortOrder: row.sort_order ?? 0,
title: row.title,
url: row.url,
urlNormalized: row.url_normalized,

View File

@@ -0,0 +1,26 @@
import { createPool } from "./db.js";
async function main() {
const pool = createPool();
try {
// Destructive: development convenience only.
await pool.query("begin");
try {
await pool.query("drop table if exists bookmarks cascade");
await pool.query("drop table if exists bookmark_folders cascade");
await pool.query("drop table if exists users cascade");
await pool.query("drop table if exists schema_migrations cascade");
await pool.query("commit");
} catch (e) {
await pool.query("rollback");
throw e;
}
} finally {
await pool.end();
}
// Re-apply migrations.
await import("./migrate.js");
}
main();

View File

@@ -0,0 +1,147 @@
import { computeUrlHash, normalizeUrl } from "@browser-bookmark/shared";
import { httpError } from "../lib/httpErrors.js";
import { requireAdmin, isAdminEmail } from "../lib/admin.js";
import { bookmarkRowToDto, folderRowToDto, userRowToDto } from "../lib/rows.js";
function toUserDtoWithAdminOverride(app, row) {
const dto = userRowToDto(row);
if (isAdminEmail(app, dto.email)) dto.role = "admin";
return dto;
}
export async function adminRoutes(app) {
app.get(
"/admin/users",
{ preHandler: [async (req) => requireAdmin(app, req)] },
async () => {
const res = await app.pg.query(
"select id, email, role, created_at, updated_at from users order by created_at desc limit 500"
);
return res.rows.map((r) => toUserDtoWithAdminOverride(app, r));
}
);
app.get(
"/admin/users/:id/folders",
{ preHandler: [async (req) => requireAdmin(app, req)] },
async (req) => {
const userId = req.params?.id;
if (!userId) throw httpError(400, "user id required");
const orderBy = app.features?.folderSortOrder
? "parent_id nulls first, sort_order asc, name asc"
: "parent_id nulls first, name asc";
const res = await app.pg.query(
`select * from bookmark_folders where user_id=$1 order by ${orderBy} limit 1000`,
[userId]
);
return res.rows.map(folderRowToDto);
}
);
app.get(
"/admin/users/:id/bookmarks",
{ preHandler: [async (req) => requireAdmin(app, req)] },
async (req) => {
const userId = req.params?.id;
if (!userId) throw httpError(400, "user id required");
const q = (req.query?.q || "").trim();
const params = [userId];
let where = "where user_id=$1 and deleted_at is null";
if (q) {
params.push(`%${q}%`);
where += ` and (title ilike $${params.length} or url ilike $${params.length})`;
}
const orderBy = app.features?.bookmarkSortOrder
? "folder_id nulls first, sort_order asc, updated_at desc"
: "updated_at desc";
const res = await app.pg.query(
`select * from bookmarks ${where} order by ${orderBy} limit 500`,
params
);
return res.rows.map(bookmarkRowToDto);
}
);
app.delete(
"/admin/users/:userId/bookmarks/:bookmarkId",
{ preHandler: [async (req) => requireAdmin(app, req)] },
async (req) => {
const userId = req.params?.userId;
const bookmarkId = req.params?.bookmarkId;
if (!userId || !bookmarkId) throw httpError(400, "userId and bookmarkId required");
const res = await app.pg.query(
"update bookmarks set deleted_at=now(), updated_at=now() where id=$1 and user_id=$2 and deleted_at is null returning *",
[bookmarkId, userId]
);
if (!res.rows[0]) throw httpError(404, "bookmark not found");
return bookmarkRowToDto(res.rows[0]);
}
);
app.delete(
"/admin/users/:userId/folders/:folderId",
{ preHandler: [async (req) => requireAdmin(app, req)] },
async (req) => {
const userId = req.params?.userId;
const folderId = req.params?.folderId;
if (!userId || !folderId) throw httpError(400, "userId and folderId required");
const res = await app.pg.query(
"delete from bookmark_folders where id=$1 and user_id=$2 returning *",
[folderId, userId]
);
if (!res.rows[0]) throw httpError(404, "folder not found");
return folderRowToDto(res.rows[0]);
}
);
app.post(
"/admin/users/:userId/bookmarks/:bookmarkId/copy-to-me",
{ preHandler: [async (req) => requireAdmin(app, req)] },
async (req) => {
const sourceUserId = req.params?.userId;
const bookmarkId = req.params?.bookmarkId;
const adminUserId = req.adminUser?.id;
if (!sourceUserId || !bookmarkId) throw httpError(400, "userId and bookmarkId required");
if (!adminUserId) throw httpError(401, "unauthorized");
const srcRes = await app.pg.query(
"select * from bookmarks where id=$1 and user_id=$2 and deleted_at is null",
[bookmarkId, sourceUserId]
);
const src = srcRes.rows[0];
if (!src) throw httpError(404, "bookmark not found");
const urlNormalized = normalizeUrl(src.url);
const urlHash = computeUrlHash(urlNormalized);
const existing = await app.pg.query(
"select * from bookmarks where user_id=$1 and url_hash=$2 and deleted_at is null limit 1",
[adminUserId, urlHash]
);
if (existing.rows[0]) {
const merged = await app.pg.query(
`update bookmarks
set title=$1, url=$2, url_normalized=$3, visibility='private', folder_id=null, source='manual', updated_at=now()
where id=$4
returning *`,
[src.title, src.url, urlNormalized, existing.rows[0].id]
);
return bookmarkRowToDto(merged.rows[0]);
}
const res = await app.pg.query(
`insert into bookmarks (user_id, folder_id, title, url, url_normalized, url_hash, visibility, source)
values ($1, null, $2, $3, $4, $5, 'private', 'manual')
returning *`,
[adminUserId, src.title, src.url, urlNormalized, urlHash]
);
return bookmarkRowToDto(res.rows[0]);
}
);
}

View File

@@ -2,6 +2,19 @@ import { hashPassword, verifyPassword } from "../lib/auth.js";
import { httpError } from "../lib/httpErrors.js";
import { userRowToDto } from "../lib/rows.js";
function normalizeEmail(email) {
return String(email || "").trim().toLowerCase();
}
function toUserDtoWithAdminOverride(app, row) {
const dto = userRowToDto(row);
const adminEmail = normalizeEmail(app.config?.adminEmail);
if (adminEmail && normalizeEmail(dto.email) === adminEmail) {
dto.role = "admin";
}
return dto;
}
export async function authRoutes(app) {
app.post("/auth/register", async (req) => {
const { email, password } = req.body || {};
@@ -15,7 +28,7 @@ export async function authRoutes(app) {
"insert into users (email, password_hash) values ($1, $2) returning id, email, role, created_at, updated_at",
[email, passwordHash]
);
const user = userRowToDto(res.rows[0]);
const user = toUserDtoWithAdminOverride(app, res.rows[0]);
const token = await app.jwt.sign({ sub: user.id, role: user.role });
return { token, user };
} catch (err) {
@@ -39,8 +52,9 @@ export async function authRoutes(app) {
if (!ok) throw httpError(401, "invalid credentials");
const user = userRowToDto(row);
const token = await app.jwt.sign({ sub: user.id, role: user.role });
return { token, user };
const userWithRole = toUserDtoWithAdminOverride(app, row);
const token = await app.jwt.sign({ sub: userWithRole.id, role: userWithRole.role });
return { token, user: userWithRole };
});
app.get(
@@ -54,7 +68,7 @@ export async function authRoutes(app) {
);
const row = res.rows[0];
if (!row) throw httpError(404, "user not found");
return userRowToDto(row);
return toUserDtoWithAdminOverride(app, row);
}
);
}

View File

@@ -34,8 +34,12 @@ export async function bookmarksRoutes(app) {
where += ` and (title ilike $${params.length} or url ilike $${params.length})`;
}
const orderBy = app.features?.bookmarkSortOrder
? "folder_id nulls first, sort_order asc, updated_at desc"
: "updated_at desc";
const res = await app.pg.query(
`select * from bookmarks ${where} order by updated_at desc limit 500`,
`select * from bookmarks ${where} order by ${orderBy} limit 500`,
params
);
return res.rows.map(bookmarkRowToDto);
@@ -62,26 +66,117 @@ export async function bookmarksRoutes(app) {
if (existing.rows[0]) {
// auto-merge
const merged = await app.pg.query(
`update bookmarks
set title=$1, url=$2, url_normalized=$3, visibility=$4, folder_id=$5, source='manual', updated_at=now()
where id=$6
returning *`,
[title, url, urlNormalized, visibility, folderId ?? null, existing.rows[0].id]
);
const targetFolderId = folderId ?? null;
const merged = app.features?.bookmarkSortOrder
? await app.pg.query(
`update bookmarks
set title=$1,
url=$2,
url_normalized=$3,
visibility=$4,
folder_id=$5,
sort_order = case
when folder_id is distinct from $5 then (
select coalesce(max(sort_order), -1) + 1
from bookmarks
where user_id=$7 and folder_id is not distinct from $5 and deleted_at is null
)
else sort_order
end,
source='manual',
updated_at=now()
where id=$6
returning *`,
[title, url, urlNormalized, visibility, targetFolderId, existing.rows[0].id, userId]
)
: await app.pg.query(
`update bookmarks
set title=$1, url=$2, url_normalized=$3, visibility=$4, folder_id=$5, source='manual', updated_at=now()
where id=$6
returning *`,
[title, url, urlNormalized, visibility, targetFolderId, existing.rows[0].id]
);
return bookmarkRowToDto(merged.rows[0]);
}
const res = await app.pg.query(
`insert into bookmarks (user_id, folder_id, title, url, url_normalized, url_hash, visibility, source)
values ($1, $2, $3, $4, $5, $6, $7, 'manual')
returning *`,
[userId, folderId ?? null, title, url, urlNormalized, urlHash, visibility]
);
const targetFolderId = folderId ?? null;
const res = app.features?.bookmarkSortOrder
? await app.pg.query(
`insert into bookmarks (user_id, folder_id, sort_order, title, url, url_normalized, url_hash, visibility, source)
values (
$1,
$2,
(select coalesce(max(sort_order), -1) + 1 from bookmarks where user_id=$1 and folder_id is not distinct from $2 and deleted_at is null),
$3,
$4,
$5,
$6,
$7,
'manual'
)
returning *`,
[userId, targetFolderId, title, url, urlNormalized, urlHash, visibility]
)
: await app.pg.query(
`insert into bookmarks (user_id, folder_id, title, url, url_normalized, url_hash, visibility, source)
values ($1, $2, $3, $4, $5, $6, $7, 'manual')
returning *`,
[userId, targetFolderId, title, url, urlNormalized, urlHash, visibility]
);
return bookmarkRowToDto(res.rows[0]);
}
);
app.post(
"/bookmarks/reorder",
{ preHandler: [app.authenticate] },
async (req) => {
if (!app.features?.bookmarkSortOrder) {
throw httpError(
409,
"bookmark sort order is not supported by current database schema. Please run server migrations (db:migrate)."
);
}
const userId = req.user.sub;
const { folderId, orderedIds } = req.body || {};
const folder = folderId ?? null;
if (!Array.isArray(orderedIds) || orderedIds.length === 0) {
throw httpError(400, "orderedIds required");
}
const siblings = await app.pg.query(
"select id from bookmarks where user_id=$1 and folder_id is not distinct from $2 and deleted_at is null",
[userId, folder]
);
const siblingIds = siblings.rows.map((r) => r.id);
const want = new Set(orderedIds);
if (want.size !== orderedIds.length) throw httpError(400, "orderedIds must be unique");
if (siblingIds.length !== orderedIds.length) throw httpError(400, "orderedIds must include all bookmarks in the folder");
for (const id of siblingIds) {
if (!want.has(id)) throw httpError(400, "orderedIds must include all bookmarks in the folder");
}
await app.pg.query("begin");
try {
for (let i = 0; i < orderedIds.length; i++) {
await app.pg.query(
"update bookmarks set sort_order=$1, updated_at=now() where id=$2 and user_id=$3 and deleted_at is null",
[i, orderedIds[i], userId]
);
}
await app.pg.query("commit");
} catch (e) {
await app.pg.query("rollback");
throw e;
}
return { ok: true };
}
);
app.patch(
"/bookmarks/:id",
{ preHandler: [app.authenticate] },
@@ -134,6 +229,19 @@ export async function bookmarksRoutes(app) {
params.push(body.visibility);
}
if (Object.prototype.hasOwnProperty.call(body, "sortOrder")) {
if (!app.features?.bookmarkSortOrder) {
throw httpError(
409,
"sortOrder is not supported by current database schema. Please run server migrations (db:migrate)."
);
}
const n = Number(body.sortOrder);
if (!Number.isFinite(n)) throw httpError(400, "sortOrder must be a number");
sets.push(`sort_order=$${i++}`);
params.push(Math.trunc(n));
}
if (Object.prototype.hasOwnProperty.call(body, "url")) {
sets.push(`url=$${i++}`);
params.push(nextUrl);

View File

@@ -7,8 +7,11 @@ export async function foldersRoutes(app) {
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
const orderBy = app.features?.folderSortOrder
? "parent_id nulls first, sort_order asc, name asc"
: "parent_id nulls first, name asc";
const res = await app.pg.query(
"select * from bookmark_folders where user_id=$1 order by name",
`select * from bookmark_folders where user_id=$1 order by ${orderBy}`,
[userId]
);
return res.rows.map(folderRowToDto);
@@ -21,19 +24,106 @@ export async function foldersRoutes(app) {
async (req) => {
const userId = req.user.sub;
const { parentId, name, visibility } = req.body || {};
if (!name) throw httpError(400, "name required");
if (!visibility) throw httpError(400, "visibility required");
const res = await app.pg.query(
`insert into bookmark_folders (user_id, parent_id, name, visibility)
values ($1, $2, $3, $4)
returning *`,
[userId, parentId ?? null, name, visibility]
);
await app.pg.query("begin");
try {
// Move bookmarks in this folder back to root (so they remain visible).
await app.pg.query(
"update bookmarks set folder_id=null, updated_at=now() where user_id=$1 and folder_id=$2 and deleted_at is null",
[userId, id]
);
// Lift child folders to root.
await app.pg.query(
"update bookmark_folders set parent_id=null, updated_at=now() where user_id=$1 and parent_id=$2",
[userId, id]
);
const res = await app.pg.query(
"delete from bookmark_folders where id=$1 and user_id=$2 returning id",
[id, userId]
);
if (!res.rows[0]) throw httpError(404, "folder not found");
await app.pg.query("commit");
} catch (e) {
await app.pg.query("rollback");
throw e;
}
return { ok: true };
const res = app.features?.folderSortOrder
? await app.pg.query(
`insert into bookmark_folders (user_id, parent_id, name, visibility, sort_order)
values (
$1,
$2,
$3,
$4,
(select coalesce(max(sort_order), -1) + 1 from bookmark_folders where user_id=$1 and parent_id is not distinct from $2)
)
returning *`,
[userId, parent, name, visibility]
)
: await app.pg.query(
`insert into bookmark_folders (user_id, parent_id, name, visibility)
values ($1, $2, $3, $4)
returning *`,
[userId, parent, name, visibility]
);
return folderRowToDto(res.rows[0]);
}
);
app.post(
"/folders/reorder",
{ preHandler: [app.authenticate] },
async (req) => {
if (!app.features?.folderSortOrder) {
throw httpError(
409,
"folder sort order is not supported by current database schema. Please run server migrations (db:migrate)."
);
}
const userId = req.user.sub;
const { parentId, orderedIds } = req.body || {};
const parent = parentId ?? null;
if (!Array.isArray(orderedIds) || orderedIds.length === 0) {
throw httpError(400, "orderedIds required");
}
const siblings = await app.pg.query(
"select id from bookmark_folders where user_id=$1 and parent_id is not distinct from $2",
[userId, parent]
);
const siblingIds = siblings.rows.map((r) => r.id);
// ensure same set
const want = new Set(orderedIds);
if (want.size !== orderedIds.length) throw httpError(400, "orderedIds must be unique");
if (siblingIds.length !== orderedIds.length) throw httpError(400, "orderedIds must include all sibling folders");
for (const id of siblingIds) {
if (!want.has(id)) throw httpError(400, "orderedIds must include all sibling folders");
}
await app.pg.query("begin");
try {
for (let i = 0; i < orderedIds.length; i++) {
await app.pg.query(
"update bookmark_folders set sort_order=$1, updated_at=now() where id=$2 and user_id=$3",
[i, orderedIds[i], userId]
);
}
await app.pg.query("commit");
} catch (e) {
await app.pg.query("rollback");
throw e;
}
return { ok: true };
}
);
app.patch(
"/folders/:id",
{ preHandler: [app.authenticate] },
@@ -68,6 +158,19 @@ export async function foldersRoutes(app) {
params.push(body.visibility);
}
if (Object.prototype.hasOwnProperty.call(body, "sortOrder")) {
if (!app.features?.folderSortOrder) {
throw httpError(
409,
"sortOrder is not supported by current database schema. Please run server migrations (db:migrate)."
);
}
const n = Number(body.sortOrder);
if (!Number.isFinite(n)) throw httpError(400, "sortOrder must be a number");
sets.push(`sort_order=$${i++}`);
params.push(Math.trunc(n));
}
if (sets.length === 0) throw httpError(400, "no fields to update");
params.push(id, userId);

View File

@@ -16,24 +16,65 @@ export async function importExportRoutes(app) {
const parsed = parseNetscapeBookmarkHtmlNode(html);
// Create folders preserving structure
// Flatten folders (no nesting): dedupe/merge by folder name for this user.
const normName = (s) => String(s || "").replace(/\s+/g, " ").trim().toLowerCase();
const existingFolders = await app.pg.query(
"select id, name from bookmark_folders where user_id=$1",
[userId]
);
const folderIdByName = new Map(
existingFolders.rows.map((r) => [normName(r.name), r.id])
);
const tempIdToFolderName = new Map(
(parsed.folders || []).map((f) => [f.tempId, f.name])
);
const tempToDbId = new Map();
for (const f of parsed.folders) {
const parentId = f.parentTempId ? tempToDbId.get(f.parentTempId) : null;
const res = await app.pg.query(
`insert into bookmark_folders (user_id, parent_id, name, visibility)
values ($1, $2, $3, 'private')
returning id`,
[userId, parentId, f.name]
);
tempToDbId.set(f.tempId, res.rows[0].id);
for (const f of parsed.folders || []) {
const key = normName(f.name);
if (!key) continue;
let id = folderIdByName.get(key);
if (!id) {
const res = app.features?.folderSortOrder
? await app.pg.query(
`insert into bookmark_folders (user_id, parent_id, name, visibility, sort_order)
values (
$1,
null,
$2,
'private',
(select coalesce(max(sort_order), -1) + 1 from bookmark_folders where user_id=$1 and parent_id is null)
)
returning id`,
[userId, f.name]
)
: await app.pg.query(
`insert into bookmark_folders (user_id, parent_id, name, visibility)
values ($1, null, $2, 'private')
returning id`,
[userId, f.name]
);
id = res.rows[0].id;
folderIdByName.set(key, id);
}
tempToDbId.set(f.tempId, id);
}
let imported = 0;
let merged = 0;
for (const b of parsed.bookmarks) {
const folderId = b.parentTempId ? tempToDbId.get(b.parentTempId) : null;
// Map bookmark's folder via folder name (flattened).
let folderId = null;
if (b.parentTempId) {
const fname = tempIdToFolderName.get(b.parentTempId);
const key = normName(fname);
folderId = key ? (folderIdByName.get(key) || tempToDbId.get(b.parentTempId) || null) : null;
}
const urlNormalized = normalizeUrl(b.url);
const urlHash = computeUrlHash(urlNormalized);