提交0.1.0版本
- 完成了书签的基本功能和插件
This commit is contained in:
@@ -1 +1 @@
|
||||
export * from "./src/index.js";
|
||||
export * from "./src/index.js";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@browser-bookmark/shared",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"exports": {
|
||||
".": "./index.js"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "@browser-bookmark/shared",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"exports": {
|
||||
".": "./index.js"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +1,77 @@
|
||||
// Netscape Bookmark file format parser (Chrome/Edge export)
|
||||
// Parses <DL>/<DT><H3> folders and <DT><A> bookmarks.
|
||||
|
||||
export function parseNetscapeBookmarkHtml(html) {
|
||||
// Minimal, dependency-free parser using DOMParser (works in browsers).
|
||||
// For Node.js, server will use a separate HTML parser.
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
|
||||
const rootDl = doc.querySelector("dl");
|
||||
if (!rootDl) return { folders: [], bookmarks: [] };
|
||||
|
||||
const folders = [];
|
||||
const bookmarks = [];
|
||||
let folderIdSeq = 1;
|
||||
|
||||
function normText(s) {
|
||||
return String(s || "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
// Collect <DT> nodes that belong to the current <DL> level.
|
||||
// Chrome/Edge exported HTML often uses `<DL><p>` and browsers may wrap
|
||||
// subsequent nodes under <p> or other wrapper elements.
|
||||
function collectLevelDt(container) {
|
||||
const out = [];
|
||||
const els = Array.from(container.children || []);
|
||||
for (const el of els) {
|
||||
const tag = el.tagName?.toLowerCase();
|
||||
if (!tag) continue;
|
||||
if (tag === "dt") {
|
||||
out.push(el);
|
||||
continue;
|
||||
}
|
||||
if (tag === "dl") {
|
||||
// nested list belongs to the previous <DT>
|
||||
continue;
|
||||
}
|
||||
out.push(...collectLevelDt(el));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Find the nested <DL> that belongs to a <DT>, even if <DT> is wrapped (e.g. inside <p>).
|
||||
function findNextDlForDt(dt, stopDl) {
|
||||
let cur = dt;
|
||||
while (cur && cur !== stopDl) {
|
||||
const next = cur.nextElementSibling;
|
||||
if (next && next.tagName?.toLowerCase() === "dl") return next;
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function walkDl(dl, parentFolderId) {
|
||||
const dts = collectLevelDt(dl);
|
||||
for (const node of dts) {
|
||||
const h3 = node.querySelector("h3");
|
||||
const a = node.querySelector("a");
|
||||
const nestedDl = node.querySelector("dl");
|
||||
const nextDl = nestedDl || findNextDlForDt(node, dl);
|
||||
|
||||
if (h3) {
|
||||
const id = String(folderIdSeq++);
|
||||
const name = normText(h3.textContent || "");
|
||||
folders.push({ id, parentFolderId: parentFolderId ?? null, name });
|
||||
if (nextDl) walkDl(nextDl, id);
|
||||
} else if (a) {
|
||||
const title = normText(a.textContent || "");
|
||||
const url = a.getAttribute("href") || "";
|
||||
bookmarks.push({ parentFolderId: parentFolderId ?? null, title, url });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walkDl(rootDl, null);
|
||||
return { folders, bookmarks };
|
||||
}
|
||||
// Netscape Bookmark file format parser (Chrome/Edge export)
|
||||
// Parses <DL>/<DT><H3> folders and <DT><A> bookmarks.
|
||||
|
||||
export function parseNetscapeBookmarkHtml(html) {
|
||||
// Minimal, dependency-free parser using DOMParser (works in browsers).
|
||||
// For Node.js, server will use a separate HTML parser.
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
|
||||
const rootDl = doc.querySelector("dl");
|
||||
if (!rootDl) return { folders: [], bookmarks: [] };
|
||||
|
||||
const folders = [];
|
||||
const bookmarks = [];
|
||||
let folderIdSeq = 1;
|
||||
|
||||
function normText(s) {
|
||||
return String(s || "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
// Collect <DT> nodes that belong to the current <DL> level.
|
||||
// Chrome/Edge exported HTML often uses `<DL><p>` and browsers may wrap
|
||||
// subsequent nodes under <p> or other wrapper elements.
|
||||
function collectLevelDt(container) {
|
||||
const out = [];
|
||||
const els = Array.from(container.children || []);
|
||||
for (const el of els) {
|
||||
const tag = el.tagName?.toLowerCase();
|
||||
if (!tag) continue;
|
||||
if (tag === "dt") {
|
||||
out.push(el);
|
||||
continue;
|
||||
}
|
||||
if (tag === "dl") {
|
||||
// nested list belongs to the previous <DT>
|
||||
continue;
|
||||
}
|
||||
out.push(...collectLevelDt(el));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Find the nested <DL> that belongs to a <DT>, even if <DT> is wrapped (e.g. inside <p>).
|
||||
function findNextDlForDt(dt, stopDl) {
|
||||
let cur = dt;
|
||||
while (cur && cur !== stopDl) {
|
||||
const next = cur.nextElementSibling;
|
||||
if (next && next.tagName?.toLowerCase() === "dl") return next;
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function walkDl(dl, parentFolderId) {
|
||||
const dts = collectLevelDt(dl);
|
||||
for (const node of dts) {
|
||||
const h3 = node.querySelector("h3");
|
||||
const a = node.querySelector("a");
|
||||
const nestedDl = node.querySelector("dl");
|
||||
const nextDl = nestedDl || findNextDlForDt(node, dl);
|
||||
|
||||
if (h3) {
|
||||
const id = String(folderIdSeq++);
|
||||
const name = normText(h3.textContent || "");
|
||||
folders.push({ id, parentFolderId: parentFolderId ?? null, name });
|
||||
if (nextDl) walkDl(nextDl, id);
|
||||
} else if (a) {
|
||||
const title = normText(a.textContent || "");
|
||||
const url = a.getAttribute("href") || "";
|
||||
bookmarks.push({ parentFolderId: parentFolderId ?? null, title, url });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walkDl(rootDl, null);
|
||||
return { folders, bookmarks };
|
||||
}
|
||||
|
||||
@@ -1,49 +1,49 @@
|
||||
export function normalizeUrl(input) {
|
||||
try {
|
||||
const url = new URL(input);
|
||||
url.hash = "";
|
||||
|
||||
url.protocol = url.protocol.toLowerCase();
|
||||
url.hostname = url.hostname.toLowerCase();
|
||||
|
||||
// Remove default ports
|
||||
if ((url.protocol === "http:" && url.port === "80") || (url.protocol === "https:" && url.port === "443")) {
|
||||
url.port = "";
|
||||
}
|
||||
|
||||
// Trim trailing slash on pathname (but keep root '/')
|
||||
if (url.pathname.length > 1 && url.pathname.endsWith("/")) {
|
||||
url.pathname = url.pathname.slice(0, -1);
|
||||
}
|
||||
|
||||
// Drop common tracking params
|
||||
const trackingPrefixes = ["utm_", "spm", "gclid", "fbclid"];
|
||||
for (const key of [...url.searchParams.keys()]) {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (trackingPrefixes.some((p) => lowerKey.startsWith(p))) {
|
||||
url.searchParams.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort params for stable output
|
||||
const sorted = [...url.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
url.search = "";
|
||||
for (const [k, v] of sorted) url.searchParams.append(k, v);
|
||||
|
||||
return url.toString();
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
export function computeUrlHash(normalizedUrl) {
|
||||
// Lightweight hash (non-crypto) for dedupe key; server may replace with crypto later.
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < normalizedUrl.length; i++) {
|
||||
hash ^= normalizedUrl.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0).toString(16);
|
||||
}
|
||||
|
||||
export { parseNetscapeBookmarkHtml } from "./bookmarkHtml.js";
|
||||
export function normalizeUrl(input) {
|
||||
try {
|
||||
const url = new URL(input);
|
||||
url.hash = "";
|
||||
|
||||
url.protocol = url.protocol.toLowerCase();
|
||||
url.hostname = url.hostname.toLowerCase();
|
||||
|
||||
// Remove default ports
|
||||
if ((url.protocol === "http:" && url.port === "80") || (url.protocol === "https:" && url.port === "443")) {
|
||||
url.port = "";
|
||||
}
|
||||
|
||||
// Trim trailing slash on pathname (but keep root '/')
|
||||
if (url.pathname.length > 1 && url.pathname.endsWith("/")) {
|
||||
url.pathname = url.pathname.slice(0, -1);
|
||||
}
|
||||
|
||||
// Drop common tracking params
|
||||
const trackingPrefixes = ["utm_", "spm", "gclid", "fbclid"];
|
||||
for (const key of [...url.searchParams.keys()]) {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (trackingPrefixes.some((p) => lowerKey.startsWith(p))) {
|
||||
url.searchParams.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort params for stable output
|
||||
const sorted = [...url.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
url.search = "";
|
||||
for (const [k, v] of sorted) url.searchParams.append(k, v);
|
||||
|
||||
return url.toString();
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
export function computeUrlHash(normalizedUrl) {
|
||||
// Lightweight hash (non-crypto) for dedupe key; server may replace with crypto later.
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < normalizedUrl.length; i++) {
|
||||
hash ^= normalizedUrl.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0).toString(16);
|
||||
}
|
||||
|
||||
export { parseNetscapeBookmarkHtml } from "./bookmarkHtml.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
{
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user