1507 lines
58 KiB
JavaScript
1507 lines
58 KiB
JavaScript
|
|
/*
|
||
|
|
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||
|
|
if you want to view the source, please visit the github repository of this plugin
|
||
|
|
*/
|
||
|
|
|
||
|
|
var __defProp = Object.defineProperty;
|
||
|
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||
|
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||
|
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||
|
|
var __export = (target, all) => {
|
||
|
|
for (var name in all)
|
||
|
|
__defProp(target, name, { get: all[name], enumerable: true });
|
||
|
|
};
|
||
|
|
var __copyProps = (to, from, except, desc) => {
|
||
|
|
if (from && typeof from === "object" || typeof from === "function") {
|
||
|
|
for (let key of __getOwnPropNames(from))
|
||
|
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
||
|
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||
|
|
}
|
||
|
|
return to;
|
||
|
|
};
|
||
|
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||
|
|
|
||
|
|
// src/main.ts
|
||
|
|
var main_exports = {};
|
||
|
|
__export(main_exports, {
|
||
|
|
default: () => HoarderPlugin
|
||
|
|
});
|
||
|
|
module.exports = __toCommonJS(main_exports);
|
||
|
|
var import_obsidian3 = require("obsidian");
|
||
|
|
|
||
|
|
// src/asset-handler.ts
|
||
|
|
function getAssetUrl(assetId, client, settings) {
|
||
|
|
if (client) {
|
||
|
|
return client.getAssetUrl(assetId);
|
||
|
|
}
|
||
|
|
const baseUrl = settings.apiEndpoint.replace(/\/v1\/?$/, "");
|
||
|
|
return `${baseUrl}/assets/${assetId}`;
|
||
|
|
}
|
||
|
|
function sanitizeAssetFileName(title) {
|
||
|
|
let sanitizedTitle = title.replace(/[\\/:*?"<>|]/g, "-").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
||
|
|
const maxTitleLength = 30;
|
||
|
|
if (sanitizedTitle.length > maxTitleLength) {
|
||
|
|
const truncated = sanitizedTitle.substring(0, maxTitleLength);
|
||
|
|
const lastDash = truncated.lastIndexOf("-");
|
||
|
|
if (lastDash > maxTitleLength / 2) {
|
||
|
|
sanitizedTitle = truncated.substring(0, lastDash);
|
||
|
|
} else {
|
||
|
|
sanitizedTitle = truncated;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return sanitizedTitle;
|
||
|
|
}
|
||
|
|
async function downloadImage(app, url, assetId, title, client, settings) {
|
||
|
|
var _a;
|
||
|
|
try {
|
||
|
|
if (!await app.vault.adapter.exists(settings.attachmentsFolder)) {
|
||
|
|
await app.vault.createFolder(settings.attachmentsFolder);
|
||
|
|
}
|
||
|
|
const extension = ((_a = url.split(".").pop()) == null ? void 0 : _a.toLowerCase()) || "jpg";
|
||
|
|
const safeExtension = ["jpg", "jpeg", "png", "gif", "webp"].includes(extension) ? extension : "jpg";
|
||
|
|
const safeTitle = sanitizeAssetFileName(title);
|
||
|
|
const fileName = `${assetId}${safeTitle ? "-" + safeTitle : ""}.${safeExtension}`;
|
||
|
|
const filePath = `${settings.attachmentsFolder}/${fileName}`;
|
||
|
|
const files = await app.vault.adapter.list(settings.attachmentsFolder);
|
||
|
|
const existingFile = files.files.find(
|
||
|
|
(filePathItem) => filePathItem.startsWith(`${settings.attachmentsFolder}/${assetId}`)
|
||
|
|
);
|
||
|
|
if (existingFile) {
|
||
|
|
return existingFile;
|
||
|
|
}
|
||
|
|
let buffer;
|
||
|
|
const apiDomain = new URL(settings.apiEndpoint).origin;
|
||
|
|
if (url.startsWith(apiDomain) && client) {
|
||
|
|
buffer = await client.downloadAsset(assetId);
|
||
|
|
} else {
|
||
|
|
const headers = {};
|
||
|
|
if (url.startsWith(apiDomain)) {
|
||
|
|
headers["Authorization"] = `Bearer ${settings.apiKey}`;
|
||
|
|
}
|
||
|
|
const response = await fetch(url, { headers });
|
||
|
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||
|
|
buffer = await response.arrayBuffer();
|
||
|
|
}
|
||
|
|
await app.vault.adapter.writeBinary(filePath, buffer);
|
||
|
|
return filePath;
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Error downloading image:", url, error);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
function escapeMarkdownPath(path) {
|
||
|
|
if (path.includes(" ") || /[<>\[\](){}]/.test(path)) {
|
||
|
|
return `<${path}>`;
|
||
|
|
}
|
||
|
|
return path;
|
||
|
|
}
|
||
|
|
var toWikilink = (path) => `"[[${path}]]"`;
|
||
|
|
async function processBookmarkAssets(app, bookmark, title, client, settings) {
|
||
|
|
let content = "";
|
||
|
|
const fm = {};
|
||
|
|
if (bookmark.content.type === "asset" && bookmark.content.assetType === "image") {
|
||
|
|
if (bookmark.content.assetId) {
|
||
|
|
const assetUrl = getAssetUrl(bookmark.content.assetId, client, settings);
|
||
|
|
let imagePath = null;
|
||
|
|
if (settings.downloadAssets) {
|
||
|
|
imagePath = await downloadImage(
|
||
|
|
app,
|
||
|
|
assetUrl,
|
||
|
|
bookmark.content.assetId,
|
||
|
|
title,
|
||
|
|
client,
|
||
|
|
settings
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (imagePath) {
|
||
|
|
content += `
|
||
|
|
})
|
||
|
|
`;
|
||
|
|
fm.image = toWikilink(imagePath);
|
||
|
|
} else {
|
||
|
|
content += `
|
||
|
|
})
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
} else if (bookmark.content.sourceUrl) {
|
||
|
|
content += `
|
||
|
|
})
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
} else if (bookmark.content.type === "link") {
|
||
|
|
const assetIds = [];
|
||
|
|
const assetLabels = [];
|
||
|
|
if (bookmark.content.imageAssetId) {
|
||
|
|
assetIds.push(bookmark.content.imageAssetId);
|
||
|
|
assetLabels.push("Banner Image");
|
||
|
|
}
|
||
|
|
if (bookmark.content.screenshotAssetId) {
|
||
|
|
assetIds.push(bookmark.content.screenshotAssetId);
|
||
|
|
assetLabels.push("Screenshot");
|
||
|
|
}
|
||
|
|
if (bookmark.content.fullPageArchiveAssetId) {
|
||
|
|
assetIds.push(bookmark.content.fullPageArchiveAssetId);
|
||
|
|
assetLabels.push("Full Page Archive");
|
||
|
|
}
|
||
|
|
if (bookmark.content.videoAssetId) {
|
||
|
|
assetIds.push(bookmark.content.videoAssetId);
|
||
|
|
assetLabels.push("Video");
|
||
|
|
}
|
||
|
|
for (let i = 0; i < assetIds.length; i++) {
|
||
|
|
const assetId = assetIds[i];
|
||
|
|
const label = assetLabels[i];
|
||
|
|
const assetUrl = getAssetUrl(assetId, client, settings);
|
||
|
|
if (label === "Video") {
|
||
|
|
content += `
|
||
|
|
[${title} - ${label}](${escapeMarkdownPath(assetUrl)})
|
||
|
|
`;
|
||
|
|
} else {
|
||
|
|
let imagePath = null;
|
||
|
|
if (settings.downloadAssets) {
|
||
|
|
imagePath = await downloadImage(
|
||
|
|
app,
|
||
|
|
assetUrl,
|
||
|
|
assetId,
|
||
|
|
`${title}-${label}`,
|
||
|
|
client,
|
||
|
|
settings
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (imagePath) {
|
||
|
|
content += `
|
||
|
|
})
|
||
|
|
`;
|
||
|
|
if (label === "Banner Image") {
|
||
|
|
fm.banner = toWikilink(imagePath);
|
||
|
|
} else if (label === "Screenshot") {
|
||
|
|
fm.screenshot = toWikilink(imagePath);
|
||
|
|
} else if (label === "Full Page Archive") {
|
||
|
|
fm.full_page_archive = toWikilink(imagePath);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
content += `
|
||
|
|
})
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (assetIds.length === 0 && bookmark.content.imageUrl) {
|
||
|
|
content += `
|
||
|
|
})
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (bookmark.assets && bookmark.assets.length > 0) {
|
||
|
|
const processedAssetIds = /* @__PURE__ */ new Set();
|
||
|
|
if (bookmark.content.type === "asset" && bookmark.content.assetId) {
|
||
|
|
processedAssetIds.add(bookmark.content.assetId);
|
||
|
|
}
|
||
|
|
if (bookmark.content.type === "link") {
|
||
|
|
if (bookmark.content.imageAssetId) processedAssetIds.add(bookmark.content.imageAssetId);
|
||
|
|
if (bookmark.content.screenshotAssetId)
|
||
|
|
processedAssetIds.add(bookmark.content.screenshotAssetId);
|
||
|
|
if (bookmark.content.fullPageArchiveAssetId)
|
||
|
|
processedAssetIds.add(bookmark.content.fullPageArchiveAssetId);
|
||
|
|
if (bookmark.content.videoAssetId) processedAssetIds.add(bookmark.content.videoAssetId);
|
||
|
|
}
|
||
|
|
for (const asset of bookmark.assets) {
|
||
|
|
if (!processedAssetIds.has(asset.id)) {
|
||
|
|
const assetUrl = getAssetUrl(asset.id, client, settings);
|
||
|
|
const label = asset.assetType === "image" ? "Additional Image" : asset.assetType;
|
||
|
|
if (asset.assetType === "video") {
|
||
|
|
content += `
|
||
|
|
[${title} - ${label}](${escapeMarkdownPath(assetUrl)})
|
||
|
|
`;
|
||
|
|
} else {
|
||
|
|
let imagePath = null;
|
||
|
|
if (settings.downloadAssets) {
|
||
|
|
imagePath = await downloadImage(
|
||
|
|
app,
|
||
|
|
assetUrl,
|
||
|
|
asset.id,
|
||
|
|
`${title}-${label}`,
|
||
|
|
client,
|
||
|
|
settings
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (imagePath) {
|
||
|
|
content += `
|
||
|
|
})
|
||
|
|
`;
|
||
|
|
fm.additional = fm.additional || [];
|
||
|
|
fm.additional.push(toWikilink(imagePath));
|
||
|
|
} else {
|
||
|
|
content += `
|
||
|
|
})
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return { content, frontmatter: Object.keys(fm).length > 0 ? fm : null };
|
||
|
|
}
|
||
|
|
|
||
|
|
// src/bookmark-utils.ts
|
||
|
|
function getBookmarkTitle(bookmark) {
|
||
|
|
if (bookmark.title) {
|
||
|
|
return bookmark.title;
|
||
|
|
}
|
||
|
|
if (bookmark.content.type === "link") {
|
||
|
|
if (bookmark.content.title) {
|
||
|
|
return bookmark.content.title;
|
||
|
|
}
|
||
|
|
if (bookmark.content.url) {
|
||
|
|
return extractTitleFromUrl(bookmark.content.url);
|
||
|
|
}
|
||
|
|
} else if (bookmark.content.type === "text") {
|
||
|
|
if (bookmark.content.text) {
|
||
|
|
return extractTitleFromText(bookmark.content.text);
|
||
|
|
}
|
||
|
|
} else if (bookmark.content.type === "asset") {
|
||
|
|
if (bookmark.content.fileName) {
|
||
|
|
return bookmark.content.fileName.replace(/\.[^/.]+$/, "");
|
||
|
|
}
|
||
|
|
if (bookmark.content.sourceUrl) {
|
||
|
|
return extractTitleFromUrl(bookmark.content.sourceUrl);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return `Bookmark-${bookmark.id}-${new Date(bookmark.createdAt).toISOString().split("T")[0]}`;
|
||
|
|
}
|
||
|
|
function extractTitleFromUrl(url) {
|
||
|
|
var _a, _b;
|
||
|
|
try {
|
||
|
|
const parsedUrl = new URL(url);
|
||
|
|
const pathTitle = (_b = (_a = parsedUrl.pathname.split("/").pop()) == null ? void 0 : _a.replace(/\.[^/.]+$/, "")) == null ? void 0 : _b.replace(/-|_/g, " ");
|
||
|
|
if (pathTitle) {
|
||
|
|
return pathTitle;
|
||
|
|
}
|
||
|
|
return parsedUrl.hostname.replace(/^www\./, "");
|
||
|
|
} catch (e) {
|
||
|
|
return url;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
function extractTitleFromText(text) {
|
||
|
|
const firstLine = text.split("\n")[0];
|
||
|
|
if (firstLine.length <= 100) {
|
||
|
|
return firstLine;
|
||
|
|
}
|
||
|
|
return firstLine.substring(0, 97) + "...";
|
||
|
|
}
|
||
|
|
|
||
|
|
// src/deletion-handler.ts
|
||
|
|
function determineDeletionActions(localBookmarkIds, activeBookmarkIds, archivedBookmarkIds, settings) {
|
||
|
|
const instructions = [];
|
||
|
|
if (!settings.syncDeletions && !settings.handleArchivedBookmarks) {
|
||
|
|
return instructions;
|
||
|
|
}
|
||
|
|
for (const bookmarkId of localBookmarkIds) {
|
||
|
|
const isActive = activeBookmarkIds.has(bookmarkId);
|
||
|
|
const isArchived = archivedBookmarkIds.has(bookmarkId);
|
||
|
|
if (!isActive && !isArchived) {
|
||
|
|
if (settings.syncDeletions && settings.deletionAction !== "ignore") {
|
||
|
|
instructions.push({
|
||
|
|
bookmarkId,
|
||
|
|
action: settings.deletionAction,
|
||
|
|
reason: "deleted"
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} else if (!isActive && isArchived) {
|
||
|
|
if (settings.handleArchivedBookmarks && settings.archivedBookmarkAction !== "ignore") {
|
||
|
|
instructions.push({
|
||
|
|
bookmarkId,
|
||
|
|
action: settings.archivedBookmarkAction,
|
||
|
|
reason: "archived"
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return instructions;
|
||
|
|
}
|
||
|
|
function countDeletionResults(instructions) {
|
||
|
|
const results = {
|
||
|
|
deleted: 0,
|
||
|
|
archived: 0,
|
||
|
|
tagged: 0,
|
||
|
|
archivedHandled: 0
|
||
|
|
};
|
||
|
|
for (const instruction of instructions) {
|
||
|
|
if (instruction.reason === "deleted") {
|
||
|
|
switch (instruction.action) {
|
||
|
|
case "delete":
|
||
|
|
results.deleted++;
|
||
|
|
break;
|
||
|
|
case "archive":
|
||
|
|
results.archived++;
|
||
|
|
break;
|
||
|
|
case "tag":
|
||
|
|
results.tagged++;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
} else if (instruction.reason === "archived") {
|
||
|
|
results.archivedHandled++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return results;
|
||
|
|
}
|
||
|
|
|
||
|
|
// src/filename-utils.ts
|
||
|
|
function sanitizeFileName(title, createdAt) {
|
||
|
|
const date = typeof createdAt === "string" ? new Date(createdAt) : createdAt;
|
||
|
|
const dateStr = date.toISOString().split("T")[0];
|
||
|
|
let sanitizedTitle = title.replace(/[\\/:*?"<>|]/g, "-").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
||
|
|
const maxTitleLength = 36;
|
||
|
|
if (sanitizedTitle.length > maxTitleLength) {
|
||
|
|
const truncated = sanitizedTitle.substring(0, maxTitleLength);
|
||
|
|
const lastDash = truncated.lastIndexOf("-");
|
||
|
|
if (lastDash > maxTitleLength / 2) {
|
||
|
|
sanitizedTitle = truncated.substring(0, lastDash);
|
||
|
|
} else {
|
||
|
|
sanitizedTitle = truncated;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return `${dateStr}-${sanitizedTitle}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// src/filter-utils.ts
|
||
|
|
function shouldIncludeBookmark(bookmarkTags, includedTags, excludedTags, isFavorited) {
|
||
|
|
if (includedTags.length > 0) {
|
||
|
|
const hasIncludedTag = includedTags.some((includedTag) => bookmarkTags.includes(includedTag));
|
||
|
|
if (!hasIncludedTag) {
|
||
|
|
return { include: false, reason: "missing_included_tag" };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (!isFavorited && excludedTags.length > 0) {
|
||
|
|
const hasExcludedTag = excludedTags.some((excludedTag) => bookmarkTags.includes(excludedTag));
|
||
|
|
if (hasExcludedTag) {
|
||
|
|
return { include: false, reason: "excluded_tag" };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return { include: true };
|
||
|
|
}
|
||
|
|
|
||
|
|
// src/formatting-utils.ts
|
||
|
|
function escapeYaml(str) {
|
||
|
|
if (!str) return "";
|
||
|
|
if (str.includes("\n") || /[:#{}\[\],&*?|<>=!%@`]/.test(str)) {
|
||
|
|
return `|
|
||
|
|
${str.replace(/\n/g, "\n ")}`;
|
||
|
|
}
|
||
|
|
if (str.includes('"')) {
|
||
|
|
return `'${str}'`;
|
||
|
|
}
|
||
|
|
if (str.includes("'") || /^[ \t]|[ \t]$/.test(str)) {
|
||
|
|
return `"${str.replace(/\"/g, '\\"')}"`;
|
||
|
|
}
|
||
|
|
return str;
|
||
|
|
}
|
||
|
|
function escapeMarkdownPath2(path) {
|
||
|
|
if (path.includes(" ") || /[<>[\](){}]/.test(path)) {
|
||
|
|
return `<${path}>`;
|
||
|
|
}
|
||
|
|
return path;
|
||
|
|
}
|
||
|
|
|
||
|
|
// src/hoarder-client.ts
|
||
|
|
var import_obsidian = require("obsidian");
|
||
|
|
var HoarderApiClient = class {
|
||
|
|
constructor(baseUrl, apiKey, useObsidianRequest = false) {
|
||
|
|
this.baseUrl = baseUrl.replace(/\/$/, "");
|
||
|
|
this.apiKey = apiKey;
|
||
|
|
this.useObsidianRequest = useObsidianRequest;
|
||
|
|
}
|
||
|
|
async makeRequest(endpoint, method = "GET", body, params) {
|
||
|
|
const url = new URL(`${this.baseUrl}${endpoint}`);
|
||
|
|
if (params) {
|
||
|
|
Object.entries(params).forEach(([key, value]) => {
|
||
|
|
if (value !== void 0 && value !== null) {
|
||
|
|
url.searchParams.append(key, String(value));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
const headers = {
|
||
|
|
"Content-Type": "application/json",
|
||
|
|
Authorization: `Bearer ${this.apiKey}`
|
||
|
|
};
|
||
|
|
try {
|
||
|
|
if (this.useObsidianRequest) {
|
||
|
|
const response = await (0, import_obsidian.requestUrl)({
|
||
|
|
url: url.toString(),
|
||
|
|
method,
|
||
|
|
headers,
|
||
|
|
body: body ? JSON.stringify(body) : void 0
|
||
|
|
});
|
||
|
|
if (response.status >= 400) {
|
||
|
|
throw new Error(`HTTP ${response.status}: ${response.text || "Unknown error"}`);
|
||
|
|
}
|
||
|
|
return response.json;
|
||
|
|
} else {
|
||
|
|
const response = await fetch(url.toString(), {
|
||
|
|
method,
|
||
|
|
headers,
|
||
|
|
body: body ? JSON.stringify(body) : void 0
|
||
|
|
});
|
||
|
|
if (!response.ok) {
|
||
|
|
const errorText = await response.text();
|
||
|
|
throw new Error(`HTTP ${response.status}: ${errorText || "Unknown error"}`);
|
||
|
|
}
|
||
|
|
return await response.json();
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("API request failed:", url.toString(), error);
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
async getBookmarks(params) {
|
||
|
|
return this.makeRequest("/bookmarks", "GET", void 0, params);
|
||
|
|
}
|
||
|
|
async updateBookmark(bookmarkId, data) {
|
||
|
|
return this.makeRequest(`/bookmarks/${bookmarkId}`, "PATCH", data);
|
||
|
|
}
|
||
|
|
async getBookmarkHighlights(bookmarkId) {
|
||
|
|
return this.makeRequest(
|
||
|
|
`/bookmarks/${bookmarkId}/highlights`,
|
||
|
|
"GET"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
async getHighlights(params) {
|
||
|
|
return this.makeRequest("/highlights", "GET", void 0, params);
|
||
|
|
}
|
||
|
|
async getAllHighlights() {
|
||
|
|
const allHighlights = [];
|
||
|
|
let cursor;
|
||
|
|
do {
|
||
|
|
const data = await this.getHighlights({
|
||
|
|
limit: 100,
|
||
|
|
cursor: cursor || void 0
|
||
|
|
});
|
||
|
|
allHighlights.push(...data.highlights || []);
|
||
|
|
cursor = data.nextCursor || void 0;
|
||
|
|
} while (cursor);
|
||
|
|
return allHighlights;
|
||
|
|
}
|
||
|
|
async downloadAsset(assetId) {
|
||
|
|
const url = `${this.baseUrl}/assets/${assetId}`;
|
||
|
|
const headers = {
|
||
|
|
Authorization: `Bearer ${this.apiKey}`
|
||
|
|
};
|
||
|
|
try {
|
||
|
|
if (this.useObsidianRequest) {
|
||
|
|
const response = await (0, import_obsidian.requestUrl)({
|
||
|
|
url,
|
||
|
|
method: "GET",
|
||
|
|
headers
|
||
|
|
});
|
||
|
|
if (response.status >= 400) {
|
||
|
|
throw new Error(`HTTP ${response.status}: ${response.text || "Unknown error"}`);
|
||
|
|
}
|
||
|
|
return response.arrayBuffer;
|
||
|
|
} else {
|
||
|
|
const response = await fetch(url, {
|
||
|
|
method: "GET",
|
||
|
|
headers
|
||
|
|
});
|
||
|
|
if (!response.ok) {
|
||
|
|
const errorText = await response.text();
|
||
|
|
throw new Error(`HTTP ${response.status}: ${errorText || "Unknown error"}`);
|
||
|
|
}
|
||
|
|
return await response.arrayBuffer();
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Asset download failed:", url, error);
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
getAssetUrl(assetId) {
|
||
|
|
const baseUrl = this.baseUrl.replace(/\/api\/v1\/?$/, "");
|
||
|
|
return `${baseUrl}/assets/${assetId}`;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// src/markdown-utils.ts
|
||
|
|
function extractNotesSection(content) {
|
||
|
|
const notesMatch = content.match(/## Notes\n\n([\s\S]*?)(?=\n##|\n\[|$)/);
|
||
|
|
return notesMatch ? notesMatch[1].trim() : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// src/message-utils.ts
|
||
|
|
function buildSyncMessage(stats) {
|
||
|
|
let message = `Successfully synced ${stats.totalBookmarks} bookmark${stats.totalBookmarks === 1 ? "" : "s"}`;
|
||
|
|
if (stats.skippedFiles > 0) {
|
||
|
|
message += ` (skipped ${stats.skippedFiles} existing file${stats.skippedFiles === 1 ? "" : "s"})`;
|
||
|
|
}
|
||
|
|
if (stats.updatedInHoarder > 0) {
|
||
|
|
message += ` and updated ${stats.updatedInHoarder} note${stats.updatedInHoarder === 1 ? "" : "s"} in Karakeep`;
|
||
|
|
}
|
||
|
|
if (stats.excludedByTags > 0) {
|
||
|
|
message += `, excluded ${stats.excludedByTags} bookmark${stats.excludedByTags === 1 ? "" : "s"} by tags`;
|
||
|
|
}
|
||
|
|
if (stats.includedByTags > 0 && stats.includedTagsEnabled) {
|
||
|
|
message += `, included ${stats.includedByTags} bookmark${stats.includedByTags === 1 ? "" : "s"} by tags`;
|
||
|
|
}
|
||
|
|
if (stats.skippedNoHighlights > 0) {
|
||
|
|
message += `, skipped ${stats.skippedNoHighlights} bookmark${stats.skippedNoHighlights === 1 ? "" : "s"} without highlights`;
|
||
|
|
}
|
||
|
|
const totalDeleted = stats.deletionResults.deleted + stats.deletionResults.archived + stats.deletionResults.tagged;
|
||
|
|
const totalArchived = stats.deletionResults.archivedHandled;
|
||
|
|
if (totalDeleted > 0 || totalArchived > 0) {
|
||
|
|
if (totalDeleted > 0) {
|
||
|
|
message += `, processed ${totalDeleted} deleted bookmark${totalDeleted === 1 ? "" : "s"}`;
|
||
|
|
if (stats.deletionResults.deleted > 0) {
|
||
|
|
message += ` (${stats.deletionResults.deleted} deleted)`;
|
||
|
|
}
|
||
|
|
if (stats.deletionResults.archived > 0) {
|
||
|
|
message += ` (${stats.deletionResults.archived} archived)`;
|
||
|
|
}
|
||
|
|
if (stats.deletionResults.tagged > 0) {
|
||
|
|
message += ` (${stats.deletionResults.tagged} tagged)`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (totalArchived > 0) {
|
||
|
|
message += `, handled ${totalArchived} archived bookmark${totalArchived === 1 ? "" : "s"}`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return message;
|
||
|
|
}
|
||
|
|
|
||
|
|
// src/settings.ts
|
||
|
|
var import_obsidian2 = require("obsidian");
|
||
|
|
var DEFAULT_SETTINGS = {
|
||
|
|
apiKey: "",
|
||
|
|
apiEndpoint: "https://api.hoarder.app/api/v1",
|
||
|
|
syncFolder: "Hoarder",
|
||
|
|
attachmentsFolder: "Hoarder/attachments",
|
||
|
|
syncIntervalMinutes: 60,
|
||
|
|
lastSyncTimestamp: 0,
|
||
|
|
updateExistingFiles: false,
|
||
|
|
excludeArchived: true,
|
||
|
|
onlyFavorites: false,
|
||
|
|
syncNotesToHoarder: true,
|
||
|
|
syncHighlights: true,
|
||
|
|
onlyBookmarksWithHighlights: false,
|
||
|
|
excludedTags: [],
|
||
|
|
includedTags: [],
|
||
|
|
downloadAssets: true,
|
||
|
|
syncDeletions: false,
|
||
|
|
deletionAction: "delete",
|
||
|
|
deletionTag: "deleted",
|
||
|
|
archiveFolder: "Hoarder/deleted",
|
||
|
|
handleArchivedBookmarks: false,
|
||
|
|
archivedBookmarkAction: "delete",
|
||
|
|
archivedBookmarkTag: "archived",
|
||
|
|
archivedBookmarkFolder: "Hoarder/archived",
|
||
|
|
useObsidianRequest: false
|
||
|
|
};
|
||
|
|
var FolderSuggest = class extends import_obsidian2.AbstractInputSuggest {
|
||
|
|
constructor(app, inputEl) {
|
||
|
|
super(app, inputEl);
|
||
|
|
this.folders = this.getFolders();
|
||
|
|
this.inputEl = inputEl;
|
||
|
|
}
|
||
|
|
getSuggestions(inputStr) {
|
||
|
|
const lowerCaseInputStr = inputStr.toLowerCase();
|
||
|
|
return this.folders.filter((folder) => folder.path.toLowerCase().contains(lowerCaseInputStr));
|
||
|
|
}
|
||
|
|
renderSuggestion(folder, el) {
|
||
|
|
el.setText(folder.path);
|
||
|
|
}
|
||
|
|
selectSuggestion(folder) {
|
||
|
|
const value = folder.path;
|
||
|
|
this.inputEl.value = value;
|
||
|
|
this.inputEl.trigger("input");
|
||
|
|
this.close();
|
||
|
|
}
|
||
|
|
getFolders() {
|
||
|
|
const folders = [];
|
||
|
|
this.app.vault.getAllLoadedFiles().forEach((file) => {
|
||
|
|
if (file instanceof import_obsidian2.TFolder) {
|
||
|
|
folders.push(file);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
return folders.sort((a, b) => a.path.localeCompare(b.path));
|
||
|
|
}
|
||
|
|
};
|
||
|
|
var HoarderSettingTab = class extends import_obsidian2.PluginSettingTab {
|
||
|
|
constructor(app, plugin) {
|
||
|
|
super(app, plugin);
|
||
|
|
this.updateSyncButton = (isSyncing) => {
|
||
|
|
if (this.syncButton) {
|
||
|
|
this.syncButton.setButtonText(isSyncing ? "Syncing..." : "Sync Now");
|
||
|
|
this.syncButton.setDisabled(isSyncing);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
this.plugin = plugin;
|
||
|
|
}
|
||
|
|
onunload() {
|
||
|
|
this.plugin.events.off("sync-state-change", this.updateSyncButton);
|
||
|
|
}
|
||
|
|
display() {
|
||
|
|
const { containerEl } = this;
|
||
|
|
containerEl.empty();
|
||
|
|
containerEl.createEl("h3", { text: "API Configuration" });
|
||
|
|
containerEl.createEl("div", {
|
||
|
|
text: "Connection settings for your Karakeep instance",
|
||
|
|
cls: "setting-item-description"
|
||
|
|
});
|
||
|
|
new import_obsidian2.Setting(containerEl).setName("Api key").setDesc("Your Hoarder API key").addText(
|
||
|
|
(text) => text.setPlaceholder("Enter your API key").setValue(this.plugin.settings.apiKey).onChange(async (value) => {
|
||
|
|
this.plugin.settings.apiKey = value;
|
||
|
|
await this.plugin.saveSettings();
|
||
|
|
}).inputEl.addClass("hoarder-wide-input")
|
||
|
|
);
|
||
|
|
new import_obsidian2.Setting(containerEl).setName("Api endpoint").setDesc("Hoarder API endpoint URL (default: https://api.karakeep.app/api/v1)").addText(
|
||
|
|
(text) => text.setPlaceholder("Enter API endpoint").setValue(this.plugin.settings.apiEndpoint).onChange(async (value) => {
|
||
|
|
this.plugin.settings.apiEndpoint = value;
|
||
|
|
await this.plugin.saveSettings();
|
||
|
|
}).inputEl.addClass("hoarder-wide-input")
|
||
|
|
);
|
||
|
|
new import_obsidian2.Setting(containerEl).setName("Bypass CORS").setDesc(
|
||
|
|
"Use Obsidian's internal request method to avoid CORS issues. Enable this if you're experiencing connection problems."
|
||
|
|
).addToggle(
|
||
|
|
(toggle) => toggle.setValue(this.plugin.settings.useObsidianRequest).onChange(async (value) => {
|
||
|
|
this.plugin.settings.useObsidianRequest = value;
|
||
|
|
await this.plugin.saveSettings();
|
||
|
|
})
|
||
|
|
);
|
||
|
|
containerEl.createEl("h3", { text: "File Organization" });
|
||
|
|
containerEl.createEl("div", {
|
||
|
|
text: "Configure where your bookmarks and assets are stored",
|
||
|
|
cls: "setting-item-description"
|
||
|
|
});
|
||
|
|
new import_obsidian2.Setting(containerEl).setName("Sync folder").setDesc("Folder where bookmarks will be saved").addText((text) => {
|
||
|
|
text.setPlaceholder("Example: folder1/folder2").setValue(this.plugin.settings.syncFolder).onChange(async (value) => {
|
||
|
|
this.plugin.settings.syncFolder = value;
|
||
|
|
await this.plugin.saveSettings();
|
||
|
|
});
|
||
|
|
text.inputEl.addClass("hoarder-medium-input");
|
||
|
|
new FolderSuggest(this.app, text.inputEl);
|
||
|
|
return text;
|
||
|
|
});
|
||
|
|
new import_obsidian2.Setting(containerEl).setName("Attachments folder").setDesc("Folder where bookmark images will be saved").addText((text) => {
|
||
|
|
text.setPlaceholder("Example: folder1/attachments").setValue(this.plugin.settings.attachmentsFolder).onChange(async (value) => {
|
||
|
|
this.plugin.settings.attachmentsFolder = value;
|
||
|
|
await this.plugin.saveSettings();
|
||
|
|
});
|
||
|
|
text.inputEl.addClass("hoarder-medium-input");
|
||
|
|
new FolderSuggest(this.app, text.inputEl);
|
||
|
|
return text;
|
||
|
|
});
|
||
|
|
containerEl.createEl("h3", { text: "Sync Behavior" });
|
||
|
|
containerEl.createEl("div", {
|
||
|
|
text: "Control how synchronization works",
|
||
|
|
cls: "setting-item-description"
|
||
|
|
});
|
||
|
|
new import_obsidian2.Setting(containerEl).setName("Sync interval").setDesc("How often to sync (in minutes)").addText(
|
||
|
|
(text) => text.setPlaceholder("60").setValue(String(this.plugin.settings.syncIntervalMinutes)).onChange(async (value) => {
|
||
|
|
const numValue = parseInt(value);
|
||
|
|
if (!isNaN(numValue) && numValue > 0) {
|
||
|
|
this.plugin.settings.syncIntervalMinutes = numValue;
|
||
|
|
await this.plugin.saveSettings();
|
||
|
|
this.plugin.startPeriodicSync();
|
||
|
|
}
|
||
|
|
}).inputEl.addClass("hoarder-small-input")
|
||
|
|
);
|
||
|
|
new import_obsidian2.Setting(containerEl).setName("Update existing files").setDesc(
|
||
|
|
"Whether to update existing bookmark files when remote data changes. When disabled, only new bookmarks will be created."
|
||
|
|
).addToggle(
|
||
|
|
(toggle) => toggle.setValue(this.plugin.settings.updateExistingFiles).onChange(async (value) => {
|
||
|
|
this.plugin.settings.updateExistingFiles = value;
|
||
|
|
await this.plugin.saveSettings();
|
||
|
|
})
|
||
|
|
);
|
||
|
|
new import_obsidian2.Setting(containerEl).setName("Sync notes to Karakeep").setDesc("Whether to sync notes to Karakeep").addToggle(
|
||
|
|
(toggle) => toggle.setValue(this.plugin.settings.syncNotesToHoarder).onChange(async (value) => {
|
||
|
|
this.plugin.settings.syncNotesToHoarder = value;
|
||
|
|
await this.plugin.saveSettings();
|
||
|
|
})
|
||
|
|
);
|
||
|
|
new import_obsidian2.Setting(containerEl).setName("Sync highlights").setDesc("Whether to sync highlights from Karakeep into bookmark files").addToggle(
|
||
|
|
(toggle) => toggle.setValue(this.plugin.settings.syncHighlights).onChange(async (value) => {
|
||
|
|
this.plugin.settings.syncHighlights = value;
|
||
|
|
await this.plugin.saveSettings();
|
||
|
|
})
|
||
|
|
);
|
||
|
|
new import_obsidian2.Setting(containerEl).setName("Download assets").setDesc(
|
||
|
|
"Download images and other assets locally (if disabled, assets will be embedded using their source URLs)"
|
||
|
|
).addToggle(
|
||
|
|
(toggle) => toggle.setValue(this.plugin.settings.downloadAssets).onChange(async (value) => {
|
||
|
|
this.plugin.settings.downloadAssets = value;
|
||
|
|
await this.plugin.saveSettings();
|
||
|
|
})
|
||
|
|
);
|
||
|
|
containerEl.createEl("h3", { text: "Sync Filtering" });
|
||
|
|
containerEl.createEl("div", {
|
||
|
|
text: "Control which bookmarks are synchronized",
|
||
|
|
cls: "setting-item-description"
|
||
|
|
});
|
||
|
|
new import_obsidian2.Setting(containerEl).setName("Exclude archived").setDesc("Exclude archived bookmarks from sync").addToggle(
|
||
|
|
(toggle) => toggle.setValue(this.plugin.settings.excludeArchived).onChange(async (value) => {
|
||
|
|
this.plugin.settings.excludeArchived = value;
|
||
|
|
await this.plugin.saveSettings();
|
||
|
|
})
|
||
|
|
);
|
||
|
|
new import_obsidian2.Setting(containerEl).setName("Only favorites").setDesc("Only sync favorited bookmarks").addToggle(
|
||
|
|
(toggle) => toggle.setValue(this.plugin.settings.onlyFavorites).onChange(async (value) => {
|
||
|
|
this.plugin.settings.onlyFavorites = value;
|
||
|
|
await this.plugin.saveSettings();
|
||
|
|
})
|
||
|
|
);
|
||
|
|
new import_obsidian2.Setting(containerEl).setName("Only bookmarks with highlights").setDesc(
|
||
|
|
"Only sync bookmarks that have highlights (requires 'Sync highlights' to be enabled)"
|
||
|
|
).addToggle(
|
||
|
|
(toggle) => toggle.setValue(this.plugin.settings.onlyBookmarksWithHighlights).onChange(async (value) => {
|
||
|
|
this.plugin.settings.onlyBookmarksWithHighlights = value;
|
||
|
|
await this.plugin.saveSettings();
|
||
|
|
})
|
||
|
|
);
|
||
|
|
new import_obsidian2.Setting(containerEl).setName("Excluded tags").setDesc("Bookmarks with these tags will not be synced (comma-separated), unless favorited").addText(
|
||
|
|
(text) => text.setPlaceholder("private, secret, draft").setValue(this.plugin.settings.excludedTags.join(", ")).onChange(async (value) => {
|
||
|
|
this.plugin.settings.excludedTags = value.split(",").map((tag) => tag.trim()).filter((tag) => tag.length > 0);
|
||
|
|
await this.plugin.saveSettings();
|
||
|
|
}).inputEl.addClass("hoarder-wide-input")
|
||
|
|
);
|
||
|
|
new import_obsidian2.Setting(containerEl).setName("Included tags").setDesc("Bookmarks with these tags will be synced (comma-separated)").addText(
|
||
|
|
(text) => text.setPlaceholder("public, shared").setValue(this.plugin.settings.includedTags.join(", ")).onChange(async (value) => {
|
||
|
|
this.plugin.settings.includedTags = value.split(",").map((tag) => tag.trim()).filter((tag) => tag.length > 0);
|
||
|
|
await this.plugin.saveSettings();
|
||
|
|
}).inputEl.addClass("hoarder-wide-input")
|
||
|
|
);
|
||
|
|
containerEl.createEl("h3", { text: "Deletion Handling" });
|
||
|
|
containerEl.createEl("div", {
|
||
|
|
text: "Configure what happens when bookmarks are deleted in Karakeep",
|
||
|
|
cls: "setting-item-description"
|
||
|
|
});
|
||
|
|
const syncDeletionsToggle = new import_obsidian2.Setting(containerEl).setName("Sync deletions").setDesc("Automatically handle bookmarks that are deleted in Karakeep").addToggle(
|
||
|
|
(toggle) => toggle.setValue(this.plugin.settings.syncDeletions).onChange(async (value) => {
|
||
|
|
this.plugin.settings.syncDeletions = value;
|
||
|
|
await this.plugin.saveSettings();
|
||
|
|
this.display();
|
||
|
|
})
|
||
|
|
);
|
||
|
|
if (this.plugin.settings.syncDeletions) {
|
||
|
|
const deletionActionSetting = new import_obsidian2.Setting(containerEl).setName("Deletion action").setDesc("What to do with local files when bookmarks are deleted in Karakeep").addDropdown(
|
||
|
|
(dropdown) => dropdown.addOption("delete", "Delete file").addOption("archive", "Move to archive folder").addOption("tag", "Add deletion tag").setValue(this.plugin.settings.deletionAction).onChange(async (value) => {
|
||
|
|
this.plugin.settings.deletionAction = value;
|
||
|
|
await this.plugin.saveSettings();
|
||
|
|
this.display();
|
||
|
|
})
|
||
|
|
);
|
||
|
|
if (this.plugin.settings.deletionAction === "archive") {
|
||
|
|
new import_obsidian2.Setting(containerEl).setName("Archive folder").setDesc("Folder to move deleted bookmarks to").addText((text) => {
|
||
|
|
text.setPlaceholder("Example: Hoarder/deleted").setValue(this.plugin.settings.archiveFolder).onChange(async (value) => {
|
||
|
|
this.plugin.settings.archiveFolder = value;
|
||
|
|
await this.plugin.saveSettings();
|
||
|
|
});
|
||
|
|
text.inputEl.addClass("hoarder-medium-input");
|
||
|
|
new FolderSuggest(this.app, text.inputEl);
|
||
|
|
return text;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if (this.plugin.settings.deletionAction === "tag") {
|
||
|
|
new import_obsidian2.Setting(containerEl).setName("Deletion tag").setDesc("Tag to add to files when bookmarks are deleted").addText(
|
||
|
|
(text) => text.setPlaceholder("deleted").setValue(this.plugin.settings.deletionTag).onChange(async (value) => {
|
||
|
|
this.plugin.settings.deletionTag = value;
|
||
|
|
await this.plugin.saveSettings();
|
||
|
|
}).inputEl.addClass("hoarder-medium-input")
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
containerEl.createEl("h3", { text: "Archive Handling" });
|
||
|
|
containerEl.createEl("div", {
|
||
|
|
text: "Configure what happens when bookmarks are archived in Karakeep",
|
||
|
|
cls: "setting-item-description"
|
||
|
|
});
|
||
|
|
const handleArchivedToggle = new import_obsidian2.Setting(containerEl).setName("Handle archived bookmarks").setDesc("Separately handle bookmarks that are archived (not deleted) in Karakeep").addToggle(
|
||
|
|
(toggle) => toggle.setValue(this.plugin.settings.handleArchivedBookmarks).onChange(async (value) => {
|
||
|
|
this.plugin.settings.handleArchivedBookmarks = value;
|
||
|
|
await this.plugin.saveSettings();
|
||
|
|
this.display();
|
||
|
|
})
|
||
|
|
);
|
||
|
|
if (this.plugin.settings.handleArchivedBookmarks) {
|
||
|
|
const archivedActionSetting = new import_obsidian2.Setting(containerEl).setName("Archived bookmark action").setDesc("What to do with local files when bookmarks are archived in Karakeep").addDropdown(
|
||
|
|
(dropdown) => dropdown.addOption("ignore", "Do nothing").addOption("delete", "Delete file").addOption("archive", "Move to archive folder").addOption("tag", "Add archived tag").setValue(this.plugin.settings.archivedBookmarkAction).onChange(async (value) => {
|
||
|
|
this.plugin.settings.archivedBookmarkAction = value;
|
||
|
|
await this.plugin.saveSettings();
|
||
|
|
this.display();
|
||
|
|
})
|
||
|
|
);
|
||
|
|
if (this.plugin.settings.archivedBookmarkAction === "archive") {
|
||
|
|
new import_obsidian2.Setting(containerEl).setName("Archived bookmark folder").setDesc("Folder to move archived bookmarks to").addText((text) => {
|
||
|
|
text.setPlaceholder("Example: Hoarder/archived").setValue(this.plugin.settings.archivedBookmarkFolder).onChange(async (value) => {
|
||
|
|
this.plugin.settings.archivedBookmarkFolder = value;
|
||
|
|
await this.plugin.saveSettings();
|
||
|
|
});
|
||
|
|
text.inputEl.addClass("hoarder-medium-input");
|
||
|
|
new FolderSuggest(this.app, text.inputEl);
|
||
|
|
return text;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if (this.plugin.settings.archivedBookmarkAction === "tag") {
|
||
|
|
new import_obsidian2.Setting(containerEl).setName("Archived bookmark tag").setDesc("Tag to add to files when bookmarks are archived").addText(
|
||
|
|
(text) => text.setPlaceholder("archived").setValue(this.plugin.settings.archivedBookmarkTag).onChange(async (value) => {
|
||
|
|
this.plugin.settings.archivedBookmarkTag = value;
|
||
|
|
await this.plugin.saveSettings();
|
||
|
|
}).inputEl.addClass("hoarder-medium-input")
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
containerEl.createEl("h3", { text: "Manual Actions & Status" });
|
||
|
|
containerEl.createEl("div", {
|
||
|
|
text: "Manual sync controls and synchronization status",
|
||
|
|
cls: "setting-item-description"
|
||
|
|
});
|
||
|
|
new import_obsidian2.Setting(containerEl).setName("Manual sync").setDesc("Sync bookmarks now").addButton((button) => {
|
||
|
|
this.syncButton = button.setButtonText(this.plugin.isSyncing ? "Syncing..." : "Sync Now").setDisabled(this.plugin.isSyncing).onClick(async () => {
|
||
|
|
const result = await this.plugin.syncBookmarks();
|
||
|
|
new import_obsidian2.Notice(result.message);
|
||
|
|
});
|
||
|
|
this.plugin.events.on("sync-state-change", this.updateSyncButton);
|
||
|
|
return button;
|
||
|
|
});
|
||
|
|
if (this.plugin.settings.lastSyncTimestamp > 0) {
|
||
|
|
containerEl.createEl("div", {
|
||
|
|
text: `Last synced: ${new Date(this.plugin.settings.lastSyncTimestamp).toLocaleString()}`,
|
||
|
|
cls: "setting-item-description"
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// src/tag-utils.ts
|
||
|
|
function sanitizeTag(tag) {
|
||
|
|
let sanitized = tag.trim();
|
||
|
|
if (!sanitized) return null;
|
||
|
|
sanitized = sanitized.replace(/\s+/g, "-");
|
||
|
|
sanitized = sanitized.replace(/[^a-zA-Z0-9_\-/]/g, "");
|
||
|
|
if (!sanitized) return null;
|
||
|
|
if (/^\d+$/.test(sanitized)) {
|
||
|
|
sanitized = "tag-" + sanitized;
|
||
|
|
}
|
||
|
|
if (/^[\d\/\-_]+$/.test(sanitized)) {
|
||
|
|
sanitized = "tag-" + sanitized;
|
||
|
|
}
|
||
|
|
return sanitized;
|
||
|
|
}
|
||
|
|
function sanitizeTags(tags) {
|
||
|
|
return tags.map(sanitizeTag).filter((tag) => tag !== null);
|
||
|
|
}
|
||
|
|
|
||
|
|
// src/main.ts
|
||
|
|
var HoarderPlugin = class extends import_obsidian3.Plugin {
|
||
|
|
constructor() {
|
||
|
|
super(...arguments);
|
||
|
|
this.isSyncing = false;
|
||
|
|
this.skippedFiles = 0;
|
||
|
|
this.events = new import_obsidian3.Events();
|
||
|
|
this.modificationTimeout = null;
|
||
|
|
this.lastSyncedNotes = null;
|
||
|
|
this.client = null;
|
||
|
|
}
|
||
|
|
async onload() {
|
||
|
|
await this.loadSettings();
|
||
|
|
this.initializeClient();
|
||
|
|
this.addSettingTab(new HoarderSettingTab(this.app, this));
|
||
|
|
this.addCommand({
|
||
|
|
id: "trigger-hoarder-sync",
|
||
|
|
name: "Sync Bookmarks",
|
||
|
|
callback: async () => {
|
||
|
|
const result = await this.syncBookmarks();
|
||
|
|
new import_obsidian3.Notice(result.message);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
this.registerEvent(
|
||
|
|
this.app.vault.on("modify", async (file) => {
|
||
|
|
if (this.settings.syncNotesToHoarder && file.path.startsWith(this.settings.syncFolder) && file.path.endsWith(".md") && file instanceof import_obsidian3.TFile) {
|
||
|
|
if (this.modificationTimeout) {
|
||
|
|
window.clearTimeout(this.modificationTimeout);
|
||
|
|
}
|
||
|
|
this.modificationTimeout = window.setTimeout(async () => {
|
||
|
|
await this.handleFileModification(file);
|
||
|
|
}, 2e3);
|
||
|
|
}
|
||
|
|
})
|
||
|
|
);
|
||
|
|
this.startPeriodicSync();
|
||
|
|
}
|
||
|
|
onunload() {
|
||
|
|
if (this.syncIntervalId) {
|
||
|
|
window.clearInterval(this.syncIntervalId);
|
||
|
|
}
|
||
|
|
if (this.modificationTimeout) {
|
||
|
|
window.clearTimeout(this.modificationTimeout);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
async loadSettings() {
|
||
|
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||
|
|
}
|
||
|
|
async saveSettings() {
|
||
|
|
await this.saveData(this.settings);
|
||
|
|
this.initializeClient();
|
||
|
|
}
|
||
|
|
startPeriodicSync() {
|
||
|
|
if (this.syncIntervalId) {
|
||
|
|
window.clearInterval(this.syncIntervalId);
|
||
|
|
}
|
||
|
|
const interval = this.settings.syncIntervalMinutes * 60 * 1e3;
|
||
|
|
this.syncBookmarks();
|
||
|
|
this.syncIntervalId = window.setInterval(() => {
|
||
|
|
this.syncBookmarks();
|
||
|
|
}, interval);
|
||
|
|
}
|
||
|
|
async fetchBookmarks(cursor, limit = 100) {
|
||
|
|
if (!this.client) {
|
||
|
|
throw new Error("Client not initialized");
|
||
|
|
}
|
||
|
|
return await this.client.getBookmarks({
|
||
|
|
limit,
|
||
|
|
cursor: cursor || void 0,
|
||
|
|
archived: this.settings.excludeArchived ? false : void 0,
|
||
|
|
favourited: this.settings.onlyFavorites ? true : void 0
|
||
|
|
});
|
||
|
|
}
|
||
|
|
async fetchAllBookmarks(includeArchived = false) {
|
||
|
|
if (!this.client) {
|
||
|
|
throw new Error("Client not initialized");
|
||
|
|
}
|
||
|
|
const allBookmarks = [];
|
||
|
|
let cursor;
|
||
|
|
do {
|
||
|
|
const data = await this.client.getBookmarks({
|
||
|
|
limit: 100,
|
||
|
|
cursor: cursor || void 0,
|
||
|
|
archived: includeArchived ? void 0 : false,
|
||
|
|
favourited: this.settings.onlyFavorites ? true : void 0
|
||
|
|
});
|
||
|
|
allBookmarks.push(...data.bookmarks || []);
|
||
|
|
cursor = data.nextCursor || void 0;
|
||
|
|
} while (cursor);
|
||
|
|
return allBookmarks;
|
||
|
|
}
|
||
|
|
async extractNotesFromFile(filePath) {
|
||
|
|
var _a, _b;
|
||
|
|
try {
|
||
|
|
const file = this.app.vault.getAbstractFileByPath(filePath);
|
||
|
|
if (!(file instanceof import_obsidian3.TFile)) {
|
||
|
|
return { currentNotes: null, originalNotes: null };
|
||
|
|
}
|
||
|
|
const content = await this.app.vault.adapter.read(filePath);
|
||
|
|
const currentNotes = extractNotesSection(content);
|
||
|
|
const metadata = (_a = this.app.metadataCache.getFileCache(file)) == null ? void 0 : _a.frontmatter;
|
||
|
|
const originalNotes = (_b = metadata == null ? void 0 : metadata.original_note) != null ? _b : null;
|
||
|
|
return { currentNotes, originalNotes };
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Error reading file:", error);
|
||
|
|
return { currentNotes: null, originalNotes: null };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
async updateBookmarkInHoarder(bookmarkId, note) {
|
||
|
|
try {
|
||
|
|
if (!this.client) {
|
||
|
|
throw new Error("Client not initialized");
|
||
|
|
}
|
||
|
|
await this.client.updateBookmark(bookmarkId, { note });
|
||
|
|
return true;
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Error updating bookmark in Hoarder:", error);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
setSyncing(value) {
|
||
|
|
this.isSyncing = value;
|
||
|
|
this.events.trigger("sync-state-change", value);
|
||
|
|
}
|
||
|
|
async getLocalBookmarkFiles() {
|
||
|
|
var _a;
|
||
|
|
const bookmarkFiles = /* @__PURE__ */ new Map();
|
||
|
|
const folderPath = this.settings.syncFolder;
|
||
|
|
if (!await this.app.vault.adapter.exists(folderPath)) {
|
||
|
|
return bookmarkFiles;
|
||
|
|
}
|
||
|
|
const files = this.app.vault.getMarkdownFiles();
|
||
|
|
for (const file of files) {
|
||
|
|
if (file.path.startsWith(folderPath) && file.path.endsWith(".md")) {
|
||
|
|
const metadata = (_a = this.app.metadataCache.getFileCache(file)) == null ? void 0 : _a.frontmatter;
|
||
|
|
const bookmarkId = metadata == null ? void 0 : metadata.bookmark_id;
|
||
|
|
if (bookmarkId) {
|
||
|
|
bookmarkFiles.set(bookmarkId, file.path);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return bookmarkFiles;
|
||
|
|
}
|
||
|
|
async handleDeletedAndArchivedBookmarks(localBookmarkFiles, activeBookmarkIds, archivedBookmarkIds) {
|
||
|
|
const deletionSettings = {
|
||
|
|
syncDeletions: this.settings.syncDeletions,
|
||
|
|
deletionAction: this.settings.deletionAction,
|
||
|
|
handleArchivedBookmarks: this.settings.handleArchivedBookmarks,
|
||
|
|
archivedBookmarkAction: this.settings.archivedBookmarkAction
|
||
|
|
};
|
||
|
|
const localBookmarkIds = Array.from(localBookmarkFiles.keys());
|
||
|
|
const instructions = determineDeletionActions(
|
||
|
|
localBookmarkIds,
|
||
|
|
activeBookmarkIds,
|
||
|
|
archivedBookmarkIds,
|
||
|
|
deletionSettings
|
||
|
|
);
|
||
|
|
for (const instruction of instructions) {
|
||
|
|
const filePath = localBookmarkFiles.get(instruction.bookmarkId);
|
||
|
|
if (!filePath) continue;
|
||
|
|
const file = this.app.vault.getAbstractFileByPath(filePath);
|
||
|
|
if (!(file instanceof import_obsidian3.TFile)) continue;
|
||
|
|
try {
|
||
|
|
switch (instruction.action) {
|
||
|
|
case "delete":
|
||
|
|
await this.app.vault.delete(file);
|
||
|
|
break;
|
||
|
|
case "archive":
|
||
|
|
const archiveFolder = instruction.reason === "deleted" ? this.settings.archiveFolder : this.settings.archivedBookmarkFolder;
|
||
|
|
await this.moveToArchiveFolder(file, archiveFolder);
|
||
|
|
break;
|
||
|
|
case "tag":
|
||
|
|
const tag = instruction.reason === "deleted" ? this.settings.deletionTag : this.settings.archivedBookmarkTag;
|
||
|
|
await this.addDeletionTag(file, tag);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error(`Error handling bookmark ${instruction.bookmarkId}:`, error);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return countDeletionResults(instructions);
|
||
|
|
}
|
||
|
|
async moveToArchiveFolder(file, archiveFolder) {
|
||
|
|
if (!archiveFolder) {
|
||
|
|
throw new Error("Archive folder not configured");
|
||
|
|
}
|
||
|
|
if (!await this.app.vault.adapter.exists(archiveFolder)) {
|
||
|
|
await this.app.vault.createFolder(archiveFolder);
|
||
|
|
}
|
||
|
|
const fileName = file.name;
|
||
|
|
const newPath = `${archiveFolder}/${fileName}`;
|
||
|
|
let finalPath = newPath;
|
||
|
|
let counter = 1;
|
||
|
|
while (await this.app.vault.adapter.exists(finalPath)) {
|
||
|
|
const nameWithoutExt = fileName.replace(/\.md$/, "");
|
||
|
|
finalPath = `${archiveFolder}/${nameWithoutExt}-${counter}.md`;
|
||
|
|
counter++;
|
||
|
|
}
|
||
|
|
await this.app.fileManager.renameFile(file, finalPath);
|
||
|
|
}
|
||
|
|
async addDeletionTag(file, tag) {
|
||
|
|
if (!tag) {
|
||
|
|
throw new Error("Tag not configured");
|
||
|
|
}
|
||
|
|
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
|
||
|
|
if (!frontmatter.tags) {
|
||
|
|
frontmatter.tags = [];
|
||
|
|
}
|
||
|
|
if (typeof frontmatter.tags === "string") {
|
||
|
|
frontmatter.tags = [frontmatter.tags];
|
||
|
|
}
|
||
|
|
if (!frontmatter.tags.includes(tag)) {
|
||
|
|
frontmatter.tags.push(tag);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
async syncBookmarks() {
|
||
|
|
var _a;
|
||
|
|
if (this.isSyncing) {
|
||
|
|
console.error("[Hoarder] Sync already in progress");
|
||
|
|
return { success: false, message: "Sync already in progress" };
|
||
|
|
}
|
||
|
|
if (!this.settings.apiKey) {
|
||
|
|
console.log("[Hoarder] API key not configured");
|
||
|
|
return { success: false, message: "Hoarder API key not configured" };
|
||
|
|
}
|
||
|
|
console.log("[Hoarder] Starting sync...");
|
||
|
|
console.log(
|
||
|
|
`[Hoarder] Settings: syncNotesToHoarder=${this.settings.syncNotesToHoarder}, updateExistingFiles=${this.settings.updateExistingFiles}`
|
||
|
|
);
|
||
|
|
this.setSyncing(true);
|
||
|
|
let totalBookmarks = 0;
|
||
|
|
this.skippedFiles = 0;
|
||
|
|
let updatedInHoarder = 0;
|
||
|
|
let excludedByTags = 0;
|
||
|
|
let includedByTags = 0;
|
||
|
|
let totalBookmarksProcessed = 0;
|
||
|
|
let skippedNoHighlights = 0;
|
||
|
|
try {
|
||
|
|
const folderPath = this.settings.syncFolder;
|
||
|
|
if (!await this.app.vault.adapter.exists(folderPath)) {
|
||
|
|
await this.app.vault.createFolder(folderPath);
|
||
|
|
}
|
||
|
|
const localBookmarkFiles = await this.getLocalBookmarkFiles();
|
||
|
|
const activeBookmarks = await this.fetchAllBookmarks(false);
|
||
|
|
const allBookmarks = await this.fetchAllBookmarks(true);
|
||
|
|
const activeBookmarkIds = new Set(activeBookmarks.map((b) => b.id));
|
||
|
|
const allBookmarkIds = new Set(allBookmarks.map((b) => b.id));
|
||
|
|
const archivedBookmarkIds = new Set(
|
||
|
|
allBookmarks.filter((b) => b.archived && !activeBookmarkIds.has(b.id)).map((b) => b.id)
|
||
|
|
);
|
||
|
|
let highlightsByBookmarkId = /* @__PURE__ */ new Map();
|
||
|
|
let bookmarkIdsWithHighlights = /* @__PURE__ */ new Set();
|
||
|
|
if ((this.settings.syncHighlights || this.settings.onlyBookmarksWithHighlights) && this.client) {
|
||
|
|
try {
|
||
|
|
const allHighlights = await this.client.getAllHighlights();
|
||
|
|
for (const highlight of allHighlights) {
|
||
|
|
if (!highlightsByBookmarkId.has(highlight.bookmarkId)) {
|
||
|
|
highlightsByBookmarkId.set(highlight.bookmarkId, []);
|
||
|
|
}
|
||
|
|
highlightsByBookmarkId.get(highlight.bookmarkId).push(highlight);
|
||
|
|
bookmarkIdsWithHighlights.add(highlight.bookmarkId);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Error fetching highlights in bulk:", error);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
let cursor;
|
||
|
|
do {
|
||
|
|
const result = await this.fetchBookmarks(cursor);
|
||
|
|
const bookmarks = result.bookmarks || [];
|
||
|
|
cursor = result.nextCursor || void 0;
|
||
|
|
totalBookmarksProcessed += bookmarks.length;
|
||
|
|
for (const bookmark of bookmarks) {
|
||
|
|
if (this.settings.onlyBookmarksWithHighlights && !bookmarkIdsWithHighlights.has(bookmark.id)) {
|
||
|
|
skippedNoHighlights++;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const bookmarkTags = bookmark.tags.map((tag) => tag.name.toLowerCase());
|
||
|
|
const filterResult = shouldIncludeBookmark(
|
||
|
|
bookmarkTags,
|
||
|
|
this.settings.includedTags.map((t) => t.toLowerCase()),
|
||
|
|
this.settings.excludedTags.map((t) => t.toLowerCase()),
|
||
|
|
bookmark.favourited
|
||
|
|
);
|
||
|
|
if (!filterResult.include) {
|
||
|
|
excludedByTags++;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (this.settings.includedTags.length > 0) {
|
||
|
|
includedByTags++;
|
||
|
|
}
|
||
|
|
const title = getBookmarkTitle(bookmark);
|
||
|
|
const fileName = `${folderPath}/${sanitizeFileName(title, bookmark.createdAt)}.md`;
|
||
|
|
const highlights = highlightsByBookmarkId.get(bookmark.id) || [];
|
||
|
|
const fileExists = await this.app.vault.adapter.exists(fileName);
|
||
|
|
if (fileExists) {
|
||
|
|
const file = this.app.vault.getAbstractFileByPath(fileName);
|
||
|
|
if (file instanceof import_obsidian3.TFile) {
|
||
|
|
const metadata = (_a = this.app.metadataCache.getFileCache(file)) == null ? void 0 : _a.frontmatter;
|
||
|
|
const storedModifiedTime = (metadata == null ? void 0 : metadata.modified) ? new Date(metadata.modified).getTime() : 0;
|
||
|
|
const bookmarkModifiedTime = bookmark.modifiedAt ? new Date(bookmark.modifiedAt).getTime() : new Date(bookmark.createdAt).getTime();
|
||
|
|
let hasNewHighlights = false;
|
||
|
|
if (this.settings.syncHighlights && highlights.length > 0) {
|
||
|
|
const newestHighlightTime = Math.max(
|
||
|
|
...highlights.map((h) => new Date(h.createdAt).getTime())
|
||
|
|
);
|
||
|
|
hasNewHighlights = !storedModifiedTime || newestHighlightTime > storedModifiedTime;
|
||
|
|
}
|
||
|
|
if (!this.settings.updateExistingFiles) {
|
||
|
|
this.skippedFiles++;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (this.settings.syncNotesToHoarder) {
|
||
|
|
const { currentNotes, originalNotes } = await this.extractNotesFromFile(fileName);
|
||
|
|
const remoteNotes = bookmark.note || "";
|
||
|
|
if (originalNotes === null && currentNotes !== null) {
|
||
|
|
console.debug(`[Hoarder] original_note missing for ${fileName}`);
|
||
|
|
if (currentNotes !== remoteNotes) {
|
||
|
|
console.log(`[Hoarder] Local notes differ from remote, syncing to Hoarder`);
|
||
|
|
const updated = await this.updateBookmarkInHoarder(bookmark.id, currentNotes);
|
||
|
|
if (updated) {
|
||
|
|
updatedInHoarder++;
|
||
|
|
bookmark.note = currentNotes;
|
||
|
|
this.lastSyncedNotes = currentNotes;
|
||
|
|
console.debug(
|
||
|
|
`[Hoarder] Initializing original_note to synced value for ${fileName}`
|
||
|
|
);
|
||
|
|
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
|
||
|
|
frontmatter["original_note"] = currentNotes;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
console.debug(
|
||
|
|
`[Hoarder] Notes match remote, skipping original_note initialization to preserve mtime`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
} else if (currentNotes !== null && originalNotes !== null && currentNotes !== originalNotes && currentNotes !== remoteNotes) {
|
||
|
|
console.log(`[Hoarder] Local notes changed for ${fileName}, syncing to Hoarder`);
|
||
|
|
const updated = await this.updateBookmarkInHoarder(bookmark.id, currentNotes);
|
||
|
|
if (updated) {
|
||
|
|
updatedInHoarder++;
|
||
|
|
bookmark.note = currentNotes;
|
||
|
|
this.lastSyncedNotes = currentNotes;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const newContent = await this.formatBookmarkAsMarkdown(bookmark, title, highlights);
|
||
|
|
const existingContent = await this.app.vault.adapter.read(fileName);
|
||
|
|
if (existingContent !== newContent) {
|
||
|
|
await this.app.vault.adapter.write(fileName, newContent);
|
||
|
|
totalBookmarks++;
|
||
|
|
} else {
|
||
|
|
this.skippedFiles++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
const content = await this.formatBookmarkAsMarkdown(bookmark, title, highlights);
|
||
|
|
await this.app.vault.create(fileName, content);
|
||
|
|
totalBookmarks++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} while (cursor);
|
||
|
|
const deletionResults = await this.handleDeletedAndArchivedBookmarks(
|
||
|
|
localBookmarkFiles,
|
||
|
|
activeBookmarkIds,
|
||
|
|
archivedBookmarkIds
|
||
|
|
);
|
||
|
|
this.settings.lastSyncTimestamp = Date.now();
|
||
|
|
await this.saveSettings();
|
||
|
|
const stats = {
|
||
|
|
totalBookmarks,
|
||
|
|
skippedFiles: this.skippedFiles,
|
||
|
|
updatedInHoarder,
|
||
|
|
excludedByTags,
|
||
|
|
includedByTags,
|
||
|
|
includedTagsEnabled: this.settings.includedTags.length > 0,
|
||
|
|
skippedNoHighlights,
|
||
|
|
deletionResults
|
||
|
|
};
|
||
|
|
const message = buildSyncMessage(stats);
|
||
|
|
return {
|
||
|
|
success: true,
|
||
|
|
message
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Error syncing bookmarks:", error);
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
message: `Error syncing: ${error.message}`
|
||
|
|
};
|
||
|
|
} finally {
|
||
|
|
this.setSyncing(false);
|
||
|
|
this.skippedFiles = 0;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
async formatBookmarkAsMarkdown(bookmark, title, highlights) {
|
||
|
|
const url = bookmark.content.type === "link" ? bookmark.content.url : bookmark.content.sourceUrl;
|
||
|
|
const description = bookmark.content.type === "link" ? bookmark.content.description : bookmark.content.text;
|
||
|
|
const rawTags = bookmark.tags.map((tag) => tag.name);
|
||
|
|
const tags = sanitizeTags(rawTags);
|
||
|
|
const { content: assetContent, frontmatter: assetsFm } = await processBookmarkAssets(
|
||
|
|
this.app,
|
||
|
|
bookmark,
|
||
|
|
title,
|
||
|
|
this.client,
|
||
|
|
this.settings
|
||
|
|
);
|
||
|
|
let assetsYaml = "";
|
||
|
|
if (assetsFm) {
|
||
|
|
const lines = [];
|
||
|
|
if (assetsFm.image) lines.push(`image: ${assetsFm.image}`);
|
||
|
|
if (assetsFm.banner) lines.push(`banner: ${assetsFm.banner}`);
|
||
|
|
if (assetsFm.screenshot) lines.push(`screenshot: ${assetsFm.screenshot}`);
|
||
|
|
if (assetsFm.full_page_archive)
|
||
|
|
lines.push(`full_page_archive: ${assetsFm.full_page_archive}`);
|
||
|
|
if (assetsFm.video) lines.push(`video: ${assetsFm.video}`);
|
||
|
|
if (assetsFm.additional && assetsFm.additional.length > 0) {
|
||
|
|
lines.push("additional:");
|
||
|
|
for (const link of assetsFm.additional) {
|
||
|
|
lines.push(` - ${link}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
assetsYaml = lines.join("\n") + "\n";
|
||
|
|
}
|
||
|
|
const tagsYaml = tags.length > 0 ? `tags:
|
||
|
|
- ${tags.join("\n - ")}
|
||
|
|
` : "";
|
||
|
|
let content = `---
|
||
|
|
bookmark_id: "${bookmark.id}"
|
||
|
|
url: ${escapeYaml(url)}
|
||
|
|
title: ${escapeYaml(title)}
|
||
|
|
date: ${new Date(bookmark.createdAt).toISOString()}
|
||
|
|
${bookmark.modifiedAt ? `modified: ${new Date(bookmark.modifiedAt).toISOString()}
|
||
|
|
` : ""}${tagsYaml}note: ${escapeYaml(bookmark.note)}
|
||
|
|
original_note: ${escapeYaml(bookmark.note)}
|
||
|
|
summary: ${escapeYaml(bookmark.summary)}
|
||
|
|
${assetsYaml}
|
||
|
|
---
|
||
|
|
|
||
|
|
# ${title}
|
||
|
|
`;
|
||
|
|
content += assetContent;
|
||
|
|
if (bookmark.summary) {
|
||
|
|
content += `
|
||
|
|
## Summary
|
||
|
|
|
||
|
|
${bookmark.summary}
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
if (description) {
|
||
|
|
content += `
|
||
|
|
## Description
|
||
|
|
|
||
|
|
${description}
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
if (highlights && highlights.length > 0 && this.settings.syncHighlights) {
|
||
|
|
content += `
|
||
|
|
## Highlights
|
||
|
|
|
||
|
|
`;
|
||
|
|
const sortedHighlights = highlights.sort((a, b) => a.startOffset - b.startOffset);
|
||
|
|
for (const highlight of sortedHighlights) {
|
||
|
|
const date = new Date(highlight.createdAt).toLocaleDateString("en-US", {
|
||
|
|
year: "numeric",
|
||
|
|
month: "long",
|
||
|
|
day: "numeric"
|
||
|
|
});
|
||
|
|
content += `> [!karakeep-${highlight.color}] ${date}
|
||
|
|
`;
|
||
|
|
const highlightLines = highlight.text.split("\n");
|
||
|
|
for (const line of highlightLines) {
|
||
|
|
content += `> ${line}
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
if (highlight.note && highlight.note.trim()) {
|
||
|
|
content += `>
|
||
|
|
`;
|
||
|
|
const noteLines = highlight.note.split("\n");
|
||
|
|
for (let i = 0; i < noteLines.length; i++) {
|
||
|
|
if (i === 0) {
|
||
|
|
content += `> *Note: ${noteLines[i]}*
|
||
|
|
`;
|
||
|
|
} else {
|
||
|
|
content += `> *${noteLines[i]}*
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
content += `
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
content += `
|
||
|
|
## Notes
|
||
|
|
|
||
|
|
${bookmark.note || ""}
|
||
|
|
`;
|
||
|
|
if (url && bookmark.content.type !== "asset") {
|
||
|
|
content += `
|
||
|
|
[Visit Link](${escapeMarkdownPath2(url)})
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
const hoarderUrl = `${this.settings.apiEndpoint.replace("/api/v1", "/dashboard/preview")}/${bookmark.id}`;
|
||
|
|
content += `
|
||
|
|
[View in Hoarder](${escapeMarkdownPath2(hoarderUrl)})`;
|
||
|
|
return content;
|
||
|
|
}
|
||
|
|
async handleFileModification(file) {
|
||
|
|
var _a;
|
||
|
|
try {
|
||
|
|
console.debug(`[Hoarder] File modified: ${file.path}`);
|
||
|
|
const { currentNotes, originalNotes } = await this.extractNotesFromFile(file.path);
|
||
|
|
const currentNotesStr = currentNotes || "";
|
||
|
|
const originalNotesStr = originalNotes || "";
|
||
|
|
console.log(
|
||
|
|
`[Hoarder] Current notes length: ${currentNotesStr.length}, Original notes length: ${originalNotesStr.length}`
|
||
|
|
);
|
||
|
|
if (currentNotesStr === this.lastSyncedNotes) {
|
||
|
|
console.log("[Hoarder] Skipping - notes match last synced version");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const metadata = (_a = this.app.metadataCache.getFileCache(file)) == null ? void 0 : _a.frontmatter;
|
||
|
|
const bookmarkId = metadata == null ? void 0 : metadata.bookmark_id;
|
||
|
|
if (!bookmarkId) {
|
||
|
|
console.log("[Hoarder] No bookmark_id found in frontmatter");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
console.log(`[Hoarder] Bookmark ID: ${bookmarkId}`);
|
||
|
|
if (originalNotes === null) {
|
||
|
|
const frontmatterNote = (metadata == null ? void 0 : metadata.note) || "";
|
||
|
|
console.log(`[Hoarder] original_note is null for ${file.path}`);
|
||
|
|
if (currentNotesStr !== frontmatterNote) {
|
||
|
|
console.log("[Hoarder] Notes have changed from frontmatter note");
|
||
|
|
const success = await this.updateBookmarkInHoarder(bookmarkId, currentNotesStr);
|
||
|
|
if (success) {
|
||
|
|
this.lastSyncedNotes = currentNotesStr;
|
||
|
|
setTimeout(async () => {
|
||
|
|
try {
|
||
|
|
const { currentNotes: latestNotes, originalNotes: currentOriginalNotes } = await this.extractNotesFromFile(file.path);
|
||
|
|
if (latestNotes === currentNotesStr && currentOriginalNotes !== currentNotesStr) {
|
||
|
|
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
|
||
|
|
frontmatter["original_note"] = currentNotesStr;
|
||
|
|
});
|
||
|
|
console.debug("[Hoarder] Initialized and updated original_note in frontmatter");
|
||
|
|
} else if (currentOriginalNotes === currentNotesStr) {
|
||
|
|
console.debug(
|
||
|
|
"[Hoarder] original_note already initialized, skipping frontmatter update"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("[Hoarder] Error updating frontmatter:", error);
|
||
|
|
}
|
||
|
|
}, 5e3);
|
||
|
|
new import_obsidian3.Notice("Notes synced to Hoarder");
|
||
|
|
} else {
|
||
|
|
console.error("[Hoarder] Failed to update bookmark in Hoarder");
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
console.debug(
|
||
|
|
"[Hoarder] Notes match frontmatter, skipping original_note initialization to preserve mtime"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (currentNotesStr !== originalNotesStr) {
|
||
|
|
console.log("[Hoarder] Notes have changed, syncing to Hoarder");
|
||
|
|
const updated = await this.updateBookmarkInHoarder(bookmarkId, currentNotesStr);
|
||
|
|
if (updated) {
|
||
|
|
this.lastSyncedNotes = currentNotesStr;
|
||
|
|
console.log("[Hoarder] Successfully synced notes to Hoarder");
|
||
|
|
setTimeout(async () => {
|
||
|
|
try {
|
||
|
|
const { currentNotes: latestNotes, originalNotes: currentOriginalNotes } = await this.extractNotesFromFile(file.path);
|
||
|
|
if (latestNotes === currentNotesStr && currentOriginalNotes !== currentNotesStr) {
|
||
|
|
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
|
||
|
|
frontmatter["original_note"] = currentNotesStr;
|
||
|
|
});
|
||
|
|
console.debug("[Hoarder] Updated original_note in frontmatter");
|
||
|
|
} else if (latestNotes !== currentNotesStr) {
|
||
|
|
console.debug("[Hoarder] Notes changed again, skipping frontmatter update");
|
||
|
|
} else {
|
||
|
|
console.debug(
|
||
|
|
"[Hoarder] original_note already up to date, skipping frontmatter update"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("[Hoarder] Error updating frontmatter:", error);
|
||
|
|
}
|
||
|
|
}, 5e3);
|
||
|
|
new import_obsidian3.Notice("Notes synced to Hoarder");
|
||
|
|
} else {
|
||
|
|
console.error("[Hoarder] Failed to update bookmark in Hoarder");
|
||
|
|
new import_obsidian3.Notice("Failed to sync notes to Hoarder");
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
console.log("[Hoarder] Notes unchanged, no sync needed");
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("[Hoarder] Error handling file modification:", error);
|
||
|
|
new import_obsidian3.Notice("Failed to sync notes to Hoarder");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
initializeClient() {
|
||
|
|
if (!this.settings.apiKey || !this.settings.apiEndpoint) {
|
||
|
|
this.client = null;
|
||
|
|
} else {
|
||
|
|
this.client = new HoarderApiClient(
|
||
|
|
this.settings.apiEndpoint,
|
||
|
|
this.settings.apiKey,
|
||
|
|
this.settings.useObsidianRequest
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
/* nosourcemap */
|