From 684f5cff102cf2aaa1b3bc3e700e1c03c5ebc782 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Sat, 18 Apr 2026 23:04:27 +0100 Subject: [PATCH] vault backup: 2026-04-18 23:04:27 --- .obsidian/community-plugins.json | 3 +- .obsidian/plugins/hoarder-sync/main.js | 1507 ++++++++++++++++++ .obsidian/plugins/hoarder-sync/manifest.json | 9 + .obsidian/plugins/hoarder-sync/styles.css | 74 + 99 Daily/2026-04-18.md | 6 + 5 files changed, 1598 insertions(+), 1 deletion(-) create mode 100644 .obsidian/plugins/hoarder-sync/main.js create mode 100644 .obsidian/plugins/hoarder-sync/manifest.json create mode 100644 .obsidian/plugins/hoarder-sync/styles.css diff --git a/.obsidian/community-plugins.json b/.obsidian/community-plugins.json index 48a69ec..832ad3f 100644 --- a/.obsidian/community-plugins.json +++ b/.obsidian/community-plugins.json @@ -10,5 +10,6 @@ "obsidian-html-plugin", "obsidian-git", "omnisearch", - "obsidian-local-images-plus" + "obsidian-local-images-plus", + "hoarder-sync" ] \ No newline at end of file diff --git a/.obsidian/plugins/hoarder-sync/main.js b/.obsidian/plugins/hoarder-sync/main.js new file mode 100644 index 0000000..9b977f1 --- /dev/null +++ b/.obsidian/plugins/hoarder-sync/main.js @@ -0,0 +1,1507 @@ +/* +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 += ` +![${title}](${escapeMarkdownPath(imagePath)}) +`; + fm.image = toWikilink(imagePath); + } else { + content += ` +![${title}](${escapeMarkdownPath(assetUrl)}) +`; + } + } else if (bookmark.content.sourceUrl) { + content += ` +![${title}](${escapeMarkdownPath(bookmark.content.sourceUrl)}) +`; + } + } 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 += ` +![${title} - ${label}](${escapeMarkdownPath(imagePath)}) +`; + 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 += ` +![${title} - ${label}](${escapeMarkdownPath(assetUrl)}) +`; + } + } + } + if (assetIds.length === 0 && bookmark.content.imageUrl) { + content += ` +![${title}](${escapeMarkdownPath(bookmark.content.imageUrl)}) +`; + } + } + 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 += ` +![${title} - ${label}](${escapeMarkdownPath(imagePath)}) +`; + fm.additional = fm.additional || []; + fm.additional.push(toWikilink(imagePath)); + } else { + content += ` +![${title} - ${label}](${escapeMarkdownPath(assetUrl)}) +`; + } + } + } + } + } + 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 */ \ No newline at end of file diff --git a/.obsidian/plugins/hoarder-sync/manifest.json b/.obsidian/plugins/hoarder-sync/manifest.json new file mode 100644 index 0000000..b2e5272 --- /dev/null +++ b/.obsidian/plugins/hoarder-sync/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "hoarder-sync", + "name": "Hoarder Sync", + "version": "1.12.0", + "minAppVersion": "1.7.0", + "description": "Sync your Hoarder bookmarks", + "author": "Jordan Hofker", + "isDesktopOnly": false +} diff --git a/.obsidian/plugins/hoarder-sync/styles.css b/.obsidian/plugins/hoarder-sync/styles.css new file mode 100644 index 0000000..3decc39 --- /dev/null +++ b/.obsidian/plugins/hoarder-sync/styles.css @@ -0,0 +1,74 @@ +.hoarder-wide-input { + width: var(--size-4-100); +} + +.hoarder-medium-input { + width: var(--size-4-75); +} + +.hoarder-small-input { + width: var(--size-4-50); +} + +.hoarder-suggestion-dropdown { + background: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: var(--size-4-1); + box-shadow: 0 var(--size-2-1) var(--size-4-2) rgba(0, 0, 0, 0.1); + max-height: var(--size-4-50); + overflow-y: auto; + width: var(--size-4-50); + position: absolute; + z-index: 1000; + display: block; + opacity: 1; + transition: opacity 150ms ease-in-out; +} + +.hoarder-suggestion-dropdown-hidden { + display: none; + opacity: 0; +} + +.hoarder-suggestion-item { + padding: var(--size-4-2) var(--size-4-3); + cursor: var(--cursor); + transition: background-color 100ms ease-in-out; +} + +.hoarder-suggestion-item:hover { + background: var(--background-modifier-hover); +} + +.setting-item-description { + margin-bottom: var(--size-4-4); +} + +/* Karakeep Highlight Callouts */ +.callout[data-callout="karakeep-yellow"] { + --callout-color: 254, 240, 138; + --callout-icon: lucide-highlighter; +} + +.callout[data-callout="karakeep-red"] { + --callout-color: 254, 202, 202; + --callout-icon: lucide-highlighter; +} + +.callout[data-callout="karakeep-green"] { + --callout-color: 187, 247, 208; + --callout-icon: lucide-highlighter; +} + +.callout[data-callout="karakeep-blue"] { + --callout-color: 191, 219, 254; + --callout-icon: lucide-highlighter; +} + +.callout[data-callout^="karakeep-"] { + margin: var(--size-4-2) 0; +} + +.callout[data-callout^="karakeep-"] .callout-content { + padding: var(--size-4-2) var(--size-4-3); +} diff --git a/99 Daily/2026-04-18.md b/99 Daily/2026-04-18.md index 644b95e..3f8a062 100644 --- a/99 Daily/2026-04-18.md +++ b/99 Daily/2026-04-18.md @@ -452,3 +452,9 @@ tags: [daily] - 23:00 | `aimpress` - **Asked:** Help set up and configure Proxmox on a new server with bootable USB. - **Done:** Deployed home.ai-impress.com homepage with authentication and fixed access list permissions for IP connectivity. +- 23:02 | `aimpress` + - **Asked:** Help with setting up and configuring Proxmox on a new server. + - **Done:** Debugged network access issues and resolved password authentication and port 3000 conflicts across multiple LXC containers. +- 23:03 | `aimpress` + - **Asked:** Can you help set up and configure Proxmox on a new server with a bootable USB? + - **Done:** Configured proxy host and access list for Homepage, then fixed domain blocking issue in the proxy configuration.