├── .tool-versions ├── .gitignore ├── manifest.json ├── .prettierrc ├── jest.config.js ├── .github └── workflows │ ├── test.yml │ ├── release.yml │ └── auto-tag.yml ├── tsconfig.json ├── src ├── markdown-utils.ts ├── formatting-utils.ts ├── filter-utils.ts ├── filename-utils.ts ├── tag-utils.ts ├── bookmark-utils.ts ├── message-utils.ts ├── deletion-handler.ts ├── hoarder-client.ts ├── tag-utils.test.ts ├── filename-utils.test.ts ├── filter-utils.test.ts ├── markdown-utils.test.ts ├── formatting-utils.test.ts ├── asset-handler.ts ├── bookmark-utils.test.ts ├── message-utils.test.ts ├── deletion-handler.test.ts ├── settings.ts └── main.ts ├── LICENSE ├── package.json ├── esbuild.config.mjs ├── styles.css ├── version.mjs └── README.md /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 22.12.0 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .env 4 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "hoarder-sync", 3 | "name": "Hoarder Sync", 4 | "version": "1.12.0", 5 | "minAppVersion": "1.7.0", 6 | "description": "Sync your Hoarder bookmarks", 7 | "author": "Jordan Hofker", 8 | "isDesktopOnly": false 9 | } 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "singleQuote": false, 5 | "tabWidth": 2, 6 | "useTabs": false, 7 | "printWidth": 100, 8 | "plugins": ["@trivago/prettier-plugin-sort-imports"], 9 | "importOrder": ["^@/(.*)$", "^[./]"], 10 | "importOrderSeparation": true, 11 | "importOrderSortSpecifiers": true 12 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | roots: ['/src'], 5 | testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'], 6 | transform: { 7 | '^.+\\.ts$': 'ts-jest', 8 | }, 9 | collectCoverageFrom: [ 10 | 'src/**/*.ts', 11 | '!src/**/*.d.ts', 12 | ], 13 | moduleFileExtensions: ['ts', 'js', 'json'], 14 | }; 15 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Use Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: "22.x" 20 | 21 | - name: Install dependencies 22 | run: npm install 23 | 24 | - name: Run tests 25 | run: npm test 26 | 27 | - name: Build 28 | run: npm run build 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "skipLibCheck": true, 15 | "lib": ["DOM", "ES5", "ES6", "ES7", "ES2021"], 16 | "types": ["node"] 17 | }, 18 | "include": ["**/*.ts"], 19 | "exclude": ["**/*.test.ts", "**/*.spec.ts", "node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /src/markdown-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Extracts the content of the "## Notes" section from markdown text. 3 | * 4 | * The pattern matches: 5 | * - "## Notes\n\n" followed by content 6 | * - Content continues until: 7 | * - Another section header (\n##) 8 | * - A link (\n[) 9 | * - End of string 10 | * 11 | * @param content - The markdown content to parse 12 | * @returns The notes content (trimmed), or null if no notes section found 13 | */ 14 | export function extractNotesSection(content: string): string | null { 15 | const notesMatch = content.match(/## Notes\n\n([\s\S]*?)(?=\n##|\n\[|$)/); 16 | return notesMatch ? notesMatch[1].trim() : null; 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Use Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: "22.x" 19 | 20 | - name: Install dependencies 21 | run: npm install 22 | 23 | - name: Run tests 24 | run: npm test 25 | 26 | - name: Build plugin 27 | run: npm run build 28 | 29 | - name: Create release 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | run: | 33 | tag="${GITHUB_REF#refs/tags/}" 34 | 35 | gh release create "$tag" \ 36 | --title="$tag" \ 37 | dist/main.js dist/manifest.json dist/styles.css -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Jordan Hofker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-hoarder", 3 | "version": "1.12.0", 4 | "description": "Sync your Hoarder bookmarks with Obsidian", 5 | "main": "dist/main.js", 6 | "scripts": { 7 | "clean": "rm -rf dist", 8 | "dev": "npm run clean && node esbuild.config.mjs", 9 | "build": "npm run clean && tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 10 | "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"", 11 | "test": "jest", 12 | "test:watch": "jest --watch", 13 | "test:coverage": "jest --coverage", 14 | "version": "node version.mjs", 15 | "install-plugin": "npm run build && mkdir -p \"${OBSIDIAN_VAULT:-$HOME/Documents/github/notes}/.obsidian/plugins/obsidian-hoarder\" && cp -r dist/* \"${OBSIDIAN_VAULT:-$HOME/Documents/github/notes}/.obsidian/plugins/obsidian-hoarder/\"" 16 | }, 17 | "keywords": [ 18 | "obsidian", 19 | "hoarder", 20 | "bookmarks" 21 | ], 22 | "author": "Jordan Hofker ", 23 | "license": "MIT", 24 | "devDependencies": { 25 | "@trivago/prettier-plugin-sort-imports": "^5", 26 | "@types/jest": "^30", 27 | "@types/node": "^22", 28 | "@typescript-eslint/eslint-plugin": "^8", 29 | "@typescript-eslint/parser": "^8", 30 | "builtin-modules": "^5", 31 | "esbuild": "^0.25", 32 | "jest": "^30", 33 | "obsidian": "^1.10", 34 | "prettier": "^3", 35 | "prompt-sync": "^4", 36 | "ts-jest": "^29", 37 | "tslib": "^2", 38 | "typescript": "^5" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/formatting-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Escapes a string value for use in YAML frontmatter. 3 | * 4 | * Handles special YAML characters, newlines, and quotes to ensure 5 | * the value is properly formatted as a YAML scalar. 6 | * 7 | * @param str - The string to escape for YAML 8 | * @returns A properly escaped YAML value (may be quoted, block scalar, or plain) 9 | */ 10 | export function escapeYaml(str: string | null | undefined): string { 11 | if (!str) return ""; 12 | 13 | // If string contains newlines or special characters, use block scalar 14 | if (str.includes("\n") || /[:#{}\[\],&*?|<>=!%@`]/.test(str)) { 15 | return `|\n ${str.replace(/\n/g, "\n ")}`; 16 | } 17 | 18 | // For simple strings, just wrap in quotes if needed 19 | if (str.includes('"')) { 20 | return `'${str}'`; 21 | } 22 | 23 | if (str.includes("'") || /^[ \t]|[ \t]$/.test(str)) { 24 | return `"${str.replace(/\"/g, '\\\"')}"`; 25 | } 26 | 27 | return str; 28 | } 29 | 30 | /** 31 | * Escapes a path string for use in Markdown links. 32 | * 33 | * Wraps paths containing spaces or special characters in angle brackets 34 | * to ensure they work properly in Markdown link syntax. 35 | * 36 | * @param path - The path to escape 37 | * @returns The path, potentially wrapped in angle brackets 38 | */ 39 | export function escapeMarkdownPath(path: string): string { 40 | // If path contains spaces or other special characters, wrap in angle brackets 41 | if (path.includes(" ") || /[<>[\](){}]/.test(path)) { 42 | return `<${path}>`; 43 | } 44 | return path; 45 | } 46 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | import fs from "fs/promises"; 5 | import path from "path"; 6 | 7 | const banner = 8 | `/* 9 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 10 | if you want to view the source, please visit the github repository of this plugin 11 | */ 12 | `; 13 | 14 | const prod = (process.argv[2] === 'production'); 15 | 16 | // Ensure dist directory exists 17 | await fs.mkdir('dist', { recursive: true }); 18 | 19 | // Copy manifest.json to dist 20 | await fs.copyFile('manifest.json', 'dist/manifest.json'); 21 | 22 | // Copy styles.css to dist 23 | await fs.copyFile('styles.css', 'dist/styles.css'); 24 | 25 | const context = await esbuild.context({ 26 | banner: { 27 | js: banner, 28 | }, 29 | entryPoints: ['src/main.ts'], 30 | bundle: true, 31 | external: [ 32 | 'obsidian', 33 | 'electron', 34 | '@codemirror/autocomplete', 35 | '@codemirror/collab', 36 | '@codemirror/commands', 37 | '@codemirror/language', 38 | '@codemirror/lint', 39 | '@codemirror/search', 40 | '@codemirror/state', 41 | '@codemirror/view', 42 | '@lezer/common', 43 | '@lezer/highlight', 44 | '@lezer/lr', 45 | ...builtins], 46 | format: 'cjs', 47 | target: 'es2018', 48 | logLevel: "info", 49 | sourcemap: prod ? false : 'inline', 50 | treeShaking: true, 51 | outfile: 'dist/main.js', 52 | }); 53 | 54 | if (prod) { 55 | await context.rebuild(); 56 | process.exit(0); 57 | } else { 58 | await context.watch(); 59 | } -------------------------------------------------------------------------------- /src/filter-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Result of bookmark filtering evaluation 3 | */ 4 | export interface FilterResult { 5 | /** Whether the bookmark should be included */ 6 | include: boolean; 7 | /** Reason for exclusion, if applicable */ 8 | reason?: "excluded_tag" | "missing_included_tag"; 9 | } 10 | 11 | /** 12 | * Determines if a bookmark should be included based on tag filtering rules. 13 | * 14 | * Rules: 15 | * 1. If includedTags is specified, bookmark must have at least one 16 | * 2. If excludedTags is specified, bookmark must not have any (unless favorited) 17 | * 3. Favorited bookmarks bypass excluded tag filtering 18 | * 19 | * @param bookmarkTags - Tags from the bookmark (already lowercased) 20 | * @param includedTags - Tags that must be present (lowercased) 21 | * @param excludedTags - Tags that must not be present (lowercased) 22 | * @param isFavorited - Whether the bookmark is favorited 23 | * @returns Filter result indicating inclusion and reason 24 | */ 25 | export function shouldIncludeBookmark( 26 | bookmarkTags: string[], 27 | includedTags: string[], 28 | excludedTags: string[], 29 | isFavorited: boolean 30 | ): FilterResult { 31 | // Filter by included tags if specified 32 | if (includedTags.length > 0) { 33 | const hasIncludedTag = includedTags.some((includedTag) => bookmarkTags.includes(includedTag)); 34 | if (!hasIncludedTag) { 35 | return { include: false, reason: "missing_included_tag" }; 36 | } 37 | } 38 | 39 | // Skip excluded tag check if bookmark is favorited 40 | if (!isFavorited && excludedTags.length > 0) { 41 | const hasExcludedTag = excludedTags.some((excludedTag) => bookmarkTags.includes(excludedTag)); 42 | if (hasExcludedTag) { 43 | return { include: false, reason: "excluded_tag" }; 44 | } 45 | } 46 | 47 | return { include: true }; 48 | } 49 | -------------------------------------------------------------------------------- /src/filename-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Sanitizes a filename by removing invalid characters and limiting length. 3 | * 4 | * Creates filenames in the format: YYYY-MM-DD-sanitized-title 5 | * Maximum total length is kept under 50 characters to avoid filesystem issues. 6 | * 7 | * @param title - The title to use in the filename 8 | * @param createdAt - ISO 8601 date string or Date object 9 | * @returns A sanitized filename without extension (e.g., "2024-01-15-my-title") 10 | */ 11 | export function sanitizeFileName(title: string, createdAt: string | Date): string { 12 | // Format the date as YYYY-MM-DD 13 | const date = typeof createdAt === "string" ? new Date(createdAt) : createdAt; 14 | const dateStr = date.toISOString().split("T")[0]; // This is 10 characters 15 | 16 | // Sanitize the title 17 | let sanitizedTitle = title 18 | .replace(/[\\/:*?"<>|]/g, "-") // Replace invalid filesystem characters with dash 19 | .replace(/\s+/g, "-") // Replace spaces with dash 20 | .replace(/-+/g, "-") // Replace multiple dashes with single dash 21 | .replace(/^-|-$/g, ""); // Remove dashes from start and end 22 | 23 | // Calculate how much space we have for the title 24 | // 50 (max) - 10 (date) - 1 (dash) - 3 (.md) = 36 characters for title 25 | const maxTitleLength = 36; 26 | 27 | if (sanitizedTitle.length > maxTitleLength) { 28 | // If title is too long, try to cut at a word boundary 29 | const truncated = sanitizedTitle.substring(0, maxTitleLength); 30 | const lastDash = truncated.lastIndexOf("-"); 31 | if (lastDash > maxTitleLength / 2) { 32 | // If we can find a reasonable word break, use it 33 | sanitizedTitle = truncated.substring(0, lastDash); 34 | } else { 35 | // Otherwise just truncate 36 | sanitizedTitle = truncated; 37 | } 38 | } 39 | 40 | return `${dateStr}-${sanitizedTitle}`; 41 | } 42 | -------------------------------------------------------------------------------- /src/tag-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Sanitizes a tag string to conform to Obsidian's tag requirements. 3 | * 4 | * Obsidian tag rules: 5 | * - Allowed characters: letters, numbers, underscore (_), hyphen (-), forward slash (/) 6 | * - Must contain at least one non-numerical character 7 | * - No blank spaces (converted to hyphens) 8 | * - Case-insensitive 9 | * 10 | * @param tag - The tag string to sanitize 11 | * @returns The sanitized tag string, or null if the tag is invalid/empty after sanitization 12 | */ 13 | export function sanitizeTag(tag: string): string | null { 14 | // Remove leading/trailing whitespace 15 | let sanitized = tag.trim(); 16 | 17 | // Return null if empty 18 | if (!sanitized) return null; 19 | 20 | // Replace spaces with hyphens (kebab-case) 21 | sanitized = sanitized.replace(/\s+/g, "-"); 22 | 23 | // Remove any characters that aren't letters, numbers, underscore, hyphen, or forward slash 24 | sanitized = sanitized.replace(/[^a-zA-Z0-9_\-/]/g, ""); 25 | 26 | // Return null if after sanitization we have an empty string 27 | if (!sanitized) return null; 28 | 29 | // If tag contains only numbers, prepend with "tag-" to make it valid 30 | if (/^\d+$/.test(sanitized)) { 31 | sanitized = "tag-" + sanitized; 32 | } 33 | 34 | // If tag starts with only numbers followed by invalid characters (edge case), 35 | // prepend with "tag-" to ensure at least one non-numerical character 36 | if (/^[\d\/\-_]+$/.test(sanitized)) { 37 | sanitized = "tag-" + sanitized; 38 | } 39 | 40 | return sanitized; 41 | } 42 | 43 | /** 44 | * Sanitizes an array of tags, filtering out invalid tags. 45 | * 46 | * @param tags - Array of tag strings to sanitize 47 | * @returns Array of valid sanitized tags (empty array if no valid tags) 48 | */ 49 | export function sanitizeTags(tags: string[]): string[] { 50 | return tags.map(sanitizeTag).filter((tag): tag is string => tag !== null); 51 | } 52 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .hoarder-wide-input { 2 | width: var(--size-4-100); 3 | } 4 | 5 | .hoarder-medium-input { 6 | width: var(--size-4-75); 7 | } 8 | 9 | .hoarder-small-input { 10 | width: var(--size-4-50); 11 | } 12 | 13 | .hoarder-suggestion-dropdown { 14 | background: var(--background-primary); 15 | border: 1px solid var(--background-modifier-border); 16 | border-radius: var(--size-4-1); 17 | box-shadow: 0 var(--size-2-1) var(--size-4-2) rgba(0, 0, 0, 0.1); 18 | max-height: var(--size-4-50); 19 | overflow-y: auto; 20 | width: var(--size-4-50); 21 | position: absolute; 22 | z-index: 1000; 23 | display: block; 24 | opacity: 1; 25 | transition: opacity 150ms ease-in-out; 26 | } 27 | 28 | .hoarder-suggestion-dropdown-hidden { 29 | display: none; 30 | opacity: 0; 31 | } 32 | 33 | .hoarder-suggestion-item { 34 | padding: var(--size-4-2) var(--size-4-3); 35 | cursor: var(--cursor); 36 | transition: background-color 100ms ease-in-out; 37 | } 38 | 39 | .hoarder-suggestion-item:hover { 40 | background: var(--background-modifier-hover); 41 | } 42 | 43 | .setting-item-description { 44 | margin-bottom: var(--size-4-4); 45 | } 46 | 47 | /* Karakeep Highlight Callouts */ 48 | .callout[data-callout="karakeep-yellow"] { 49 | --callout-color: 254, 240, 138; 50 | --callout-icon: lucide-highlighter; 51 | } 52 | 53 | .callout[data-callout="karakeep-red"] { 54 | --callout-color: 254, 202, 202; 55 | --callout-icon: lucide-highlighter; 56 | } 57 | 58 | .callout[data-callout="karakeep-green"] { 59 | --callout-color: 187, 247, 208; 60 | --callout-icon: lucide-highlighter; 61 | } 62 | 63 | .callout[data-callout="karakeep-blue"] { 64 | --callout-color: 191, 219, 254; 65 | --callout-icon: lucide-highlighter; 66 | } 67 | 68 | .callout[data-callout^="karakeep-"] { 69 | margin: var(--size-4-2) 0; 70 | } 71 | 72 | .callout[data-callout^="karakeep-"] .callout-content { 73 | padding: var(--size-4-2) var(--size-4-3); 74 | } 75 | -------------------------------------------------------------------------------- /version.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | import { execSync } from "child_process"; 3 | import promptSync from "prompt-sync"; 4 | 5 | const prompt = promptSync(); 6 | 7 | // Get version argument 8 | const newVersion = process.argv[2]; 9 | if (!newVersion) { 10 | console.error( 11 | "Please provide a version number (e.g., npm run version 1.0.1)" 12 | ); 13 | process.exit(1); 14 | } 15 | 16 | // Validate version format 17 | if (!/^\d+\.\d+\.\d+$/.test(newVersion)) { 18 | console.error("Version must be in format x.y.z (e.g., 1.0.1)"); 19 | process.exit(1); 20 | } 21 | 22 | try { 23 | // Update manifest.json 24 | const manifestPath = "manifest.json"; 25 | const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); 26 | manifest.version = newVersion; 27 | writeFileSync(manifestPath, JSON.stringify(manifest, null, 4) + "\n"); 28 | console.log(`Updated ${manifestPath} to version ${newVersion}`); 29 | 30 | // Update package.json 31 | const packagePath = "package.json"; 32 | const pkg = JSON.parse(readFileSync(packagePath, "utf8")); 33 | pkg.version = newVersion; 34 | writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + "\n"); 35 | console.log(`Updated ${packagePath} to version ${newVersion}`); 36 | 37 | // Git commands 38 | execSync("git add manifest.json package.json"); 39 | execSync(`git commit -m "chore: bump version to ${newVersion}"`); 40 | execSync(`git tag -a ${newVersion} -m "Version ${newVersion}"`); 41 | console.log(`Created git tag v${newVersion}`); 42 | 43 | console.log("\nNext steps:"); 44 | console.log("1. Push the changes: git push"); 45 | console.log("2. Push the tag: git push origin --tags"); 46 | 47 | const answer = prompt("Do this automatically? (y/n) ").toLowerCase(); 48 | if (answer === "y" || answer === "yes") { 49 | console.log("\nPushing changes and tags..."); 50 | execSync("git push"); 51 | execSync("git push origin --tags"); 52 | console.log("Done!"); 53 | } 54 | } catch (error) { 55 | console.error("Error:", error.message); 56 | process.exit(1); 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/auto-tag.yml: -------------------------------------------------------------------------------- 1 | name: Create Tag on Version Change 2 | 3 | on: 4 | pull_request: 5 | types: [closed] 6 | branches: 7 | - main 8 | 9 | jobs: 10 | check-version-and-tag: 11 | # Only run if PR was merged (not just closed) 12 | if: github.event.pull_request.merged == true 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v3 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Get version from current package.json 22 | id: current-version 23 | run: | 24 | VERSION=$(node -p "require('./package.json').version") 25 | echo "version=$VERSION" >> $GITHUB_OUTPUT 26 | 27 | - name: Get version from previous commit 28 | id: previous-version 29 | run: | 30 | git checkout HEAD~1 31 | VERSION=$(node -p "require('./package.json').version") 32 | echo "version=$VERSION" >> $GITHUB_OUTPUT 33 | git checkout - 34 | 35 | - name: Check if version changed 36 | id: version-check 37 | run: | 38 | if [ "${{ steps.current-version.outputs.version }}" != "${{ steps.previous-version.outputs.version }}" ]; then 39 | echo "changed=true" >> $GITHUB_OUTPUT 40 | echo "Version changed from ${{ steps.previous-version.outputs.version }} to ${{ steps.current-version.outputs.version }}" 41 | else 42 | echo "changed=false" >> $GITHUB_OUTPUT 43 | echo "Version unchanged: ${{ steps.current-version.outputs.version }}" 44 | fi 45 | 46 | - name: Create and push tag 47 | if: steps.version-check.outputs.changed == 'true' 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | run: | 51 | VERSION=${{ steps.current-version.outputs.version }} 52 | git config user.name "github-actions[bot]" 53 | git config user.email "github-actions[bot]@users.noreply.github.com" 54 | git tag -a "$VERSION" -m "Release $VERSION" 55 | git push origin "$VERSION" 56 | -------------------------------------------------------------------------------- /src/bookmark-utils.ts: -------------------------------------------------------------------------------- 1 | import { HoarderBookmark } from "./hoarder-client"; 2 | 3 | /** 4 | * Extracts a meaningful title from a bookmark. 5 | * 6 | * Priority order: 7 | * 1. bookmark.title (if present) 8 | * 2. For links: content.title, then URL parsing 9 | * 3. For text: first line (truncated if needed) 10 | * 4. For assets: fileName or sourceUrl parsing 11 | * 5. Fallback: "Bookmark-{id}-{date}" 12 | * 13 | * @param bookmark - The bookmark object 14 | * @returns A title string suitable for use as a filename 15 | */ 16 | export function getBookmarkTitle(bookmark: HoarderBookmark): string { 17 | // Try main title first 18 | if (bookmark.title) { 19 | return bookmark.title; 20 | } 21 | 22 | // Try content based on type 23 | if (bookmark.content.type === "link") { 24 | // For links, try content title, then URL 25 | if (bookmark.content.title) { 26 | return bookmark.content.title; 27 | } 28 | if (bookmark.content.url) { 29 | return extractTitleFromUrl(bookmark.content.url); 30 | } 31 | } else if (bookmark.content.type === "text") { 32 | // For text content, use first line or first few words 33 | if (bookmark.content.text) { 34 | return extractTitleFromText(bookmark.content.text); 35 | } 36 | } else if (bookmark.content.type === "asset") { 37 | // For assets, use filename or source URL 38 | if (bookmark.content.fileName) { 39 | return bookmark.content.fileName.replace(/\.[^/.]+$/, ""); // Remove file extension 40 | } 41 | if (bookmark.content.sourceUrl) { 42 | return extractTitleFromUrl(bookmark.content.sourceUrl); 43 | } 44 | } 45 | 46 | // Fallback to ID with timestamp 47 | return `Bookmark-${bookmark.id}-${new Date(bookmark.createdAt).toISOString().split("T")[0]}`; 48 | } 49 | 50 | /** 51 | * Extracts a title from a URL by parsing the pathname or using the hostname. 52 | * 53 | * @param url - The URL string to parse 54 | * @returns A title extracted from the URL 55 | */ 56 | function extractTitleFromUrl(url: string): string { 57 | try { 58 | const parsedUrl = new URL(url); 59 | // Use pathname without extension as title 60 | const pathTitle = parsedUrl.pathname 61 | .split("/") 62 | .pop() 63 | ?.replace(/\.[^/.]+$/, "") // Remove file extension 64 | ?.replace(/-|_/g, " "); // Replace dashes and underscores with spaces 65 | if (pathTitle) { 66 | return pathTitle; 67 | } 68 | // Fallback to hostname 69 | return parsedUrl.hostname.replace(/^www\./, ""); 70 | } catch { 71 | // If URL parsing fails, return the URL as-is 72 | return url; 73 | } 74 | } 75 | 76 | /** 77 | * Extracts a title from text content by using the first line. 78 | * Truncates to 100 characters with ellipsis if needed. 79 | * 80 | * @param text - The text content 81 | * @returns The first line of text, possibly truncated 82 | */ 83 | function extractTitleFromText(text: string): string { 84 | const firstLine = text.split("\n")[0]; 85 | if (firstLine.length <= 100) { 86 | return firstLine; 87 | } 88 | return firstLine.substring(0, 97) + "..."; 89 | } 90 | -------------------------------------------------------------------------------- /src/message-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Statistics from a bookmark sync operation 3 | */ 4 | export interface SyncStats { 5 | /** Number of bookmarks created/updated */ 6 | totalBookmarks: number; 7 | /** Number of existing files skipped (no changes) */ 8 | skippedFiles: number; 9 | /** Number of notes updated in Hoarder */ 10 | updatedInHoarder: number; 11 | /** Number of bookmarks excluded by tag filters */ 12 | excludedByTags: number; 13 | /** Number of bookmarks included by tag filters */ 14 | includedByTags: number; 15 | /** Whether included tags filter is enabled */ 16 | includedTagsEnabled: boolean; 17 | /** Number of bookmarks skipped due to no highlights */ 18 | skippedNoHighlights: number; 19 | /** Results from deletion/archival processing */ 20 | deletionResults: { 21 | deleted: number; 22 | archived: number; 23 | tagged: number; 24 | archivedHandled: number; 25 | }; 26 | } 27 | 28 | /** 29 | * Builds a human-readable sync success message from statistics. 30 | * 31 | * @param stats - Sync operation statistics 32 | * @returns Formatted message string 33 | */ 34 | export function buildSyncMessage(stats: SyncStats): string { 35 | let message = `Successfully synced ${stats.totalBookmarks} bookmark${ 36 | stats.totalBookmarks === 1 ? "" : "s" 37 | }`; 38 | 39 | if (stats.skippedFiles > 0) { 40 | message += ` (skipped ${stats.skippedFiles} existing file${ 41 | stats.skippedFiles === 1 ? "" : "s" 42 | })`; 43 | } 44 | 45 | if (stats.updatedInHoarder > 0) { 46 | message += ` and updated ${stats.updatedInHoarder} note${ 47 | stats.updatedInHoarder === 1 ? "" : "s" 48 | } in Karakeep`; 49 | } 50 | 51 | if (stats.excludedByTags > 0) { 52 | message += `, excluded ${stats.excludedByTags} bookmark${ 53 | stats.excludedByTags === 1 ? "" : "s" 54 | } by tags`; 55 | } 56 | 57 | if (stats.includedByTags > 0 && stats.includedTagsEnabled) { 58 | message += `, included ${stats.includedByTags} bookmark${ 59 | stats.includedByTags === 1 ? "" : "s" 60 | } by tags`; 61 | } 62 | 63 | if (stats.skippedNoHighlights > 0) { 64 | message += `, skipped ${stats.skippedNoHighlights} bookmark${ 65 | stats.skippedNoHighlights === 1 ? "" : "s" 66 | } without highlights`; 67 | } 68 | 69 | // Add deletion results to message 70 | const totalDeleted = 71 | stats.deletionResults.deleted + stats.deletionResults.archived + stats.deletionResults.tagged; 72 | const totalArchived = stats.deletionResults.archivedHandled; 73 | 74 | if (totalDeleted > 0 || totalArchived > 0) { 75 | if (totalDeleted > 0) { 76 | message += `, processed ${totalDeleted} deleted bookmark${totalDeleted === 1 ? "" : "s"}`; 77 | if (stats.deletionResults.deleted > 0) { 78 | message += ` (${stats.deletionResults.deleted} deleted)`; 79 | } 80 | if (stats.deletionResults.archived > 0) { 81 | message += ` (${stats.deletionResults.archived} archived)`; 82 | } 83 | if (stats.deletionResults.tagged > 0) { 84 | message += ` (${stats.deletionResults.tagged} tagged)`; 85 | } 86 | } 87 | if (totalArchived > 0) { 88 | message += `, handled ${totalArchived} archived bookmark${totalArchived === 1 ? "" : "s"}`; 89 | } 90 | } 91 | 92 | return message; 93 | } 94 | -------------------------------------------------------------------------------- /src/deletion-handler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Action to take for a deleted or archived bookmark 3 | */ 4 | export type DeletionAction = "delete" | "archive" | "tag" | "ignore"; 5 | 6 | /** 7 | * Settings related to deletion and archival handling 8 | */ 9 | export interface DeletionSettings { 10 | syncDeletions: boolean; 11 | deletionAction: DeletionAction; 12 | handleArchivedBookmarks: boolean; 13 | archivedBookmarkAction: DeletionAction; 14 | } 15 | 16 | /** 17 | * Instruction for how to handle a single bookmark file 18 | */ 19 | export interface FileHandlingInstruction { 20 | bookmarkId: string; 21 | action: DeletionAction; 22 | reason: "deleted" | "archived"; 23 | } 24 | 25 | /** 26 | * Results from processing deleted and archived bookmarks 27 | */ 28 | export interface DeletionResults { 29 | deleted: number; 30 | archived: number; 31 | tagged: number; 32 | archivedHandled: number; 33 | } 34 | 35 | /** 36 | * Determines what action should be taken for bookmarks that exist locally 37 | * but have been deleted or archived remotely. 38 | * 39 | * @param localBookmarkIds - IDs of bookmarks that exist in local files 40 | * @param activeBookmarkIds - IDs of bookmarks that are active remotely 41 | * @param archivedBookmarkIds - IDs of bookmarks that are archived remotely 42 | * @param settings - Deletion and archival handling settings 43 | * @returns Array of instructions for handling each affected bookmark 44 | */ 45 | export function determineDeletionActions( 46 | localBookmarkIds: string[], 47 | activeBookmarkIds: Set, 48 | archivedBookmarkIds: Set, 49 | settings: DeletionSettings 50 | ): FileHandlingInstruction[] { 51 | const instructions: FileHandlingInstruction[] = []; 52 | 53 | // Early return if both features are disabled 54 | if (!settings.syncDeletions && !settings.handleArchivedBookmarks) { 55 | return instructions; 56 | } 57 | 58 | for (const bookmarkId of localBookmarkIds) { 59 | const isActive = activeBookmarkIds.has(bookmarkId); 60 | const isArchived = archivedBookmarkIds.has(bookmarkId); 61 | 62 | if (!isActive && !isArchived) { 63 | // Bookmark is completely deleted from remote 64 | if (settings.syncDeletions && settings.deletionAction !== "ignore") { 65 | instructions.push({ 66 | bookmarkId, 67 | action: settings.deletionAction, 68 | reason: "deleted", 69 | }); 70 | } 71 | } else if (!isActive && isArchived) { 72 | // Bookmark is archived remotely 73 | if (settings.handleArchivedBookmarks && settings.archivedBookmarkAction !== "ignore") { 74 | instructions.push({ 75 | bookmarkId, 76 | action: settings.archivedBookmarkAction, 77 | reason: "archived", 78 | }); 79 | } 80 | } 81 | } 82 | 83 | return instructions; 84 | } 85 | 86 | /** 87 | * Counts the results from executed file handling instructions. 88 | * 89 | * @param instructions - Array of instructions that were executed 90 | * @returns Counts of each action type 91 | */ 92 | export function countDeletionResults(instructions: FileHandlingInstruction[]): DeletionResults { 93 | const results: DeletionResults = { 94 | deleted: 0, 95 | archived: 0, 96 | tagged: 0, 97 | archivedHandled: 0, 98 | }; 99 | 100 | for (const instruction of instructions) { 101 | if (instruction.reason === "deleted") { 102 | switch (instruction.action) { 103 | case "delete": 104 | results.deleted++; 105 | break; 106 | case "archive": 107 | results.archived++; 108 | break; 109 | case "tag": 110 | results.tagged++; 111 | break; 112 | } 113 | } else if (instruction.reason === "archived") { 114 | results.archivedHandled++; 115 | } 116 | } 117 | 118 | return results; 119 | } 120 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ~Hoarder~ Karakeep Plugin for Obsidian 2 | 3 | This plugin syncs your [Karakeep](https://karakeep.app/) bookmarks with Obsidian, creating markdown notes for each bookmark in a designated folder. 4 | 5 | ## Features 6 | 7 | - Automatically syncs bookmarks from Karakeep every hour (configurable) 8 | - Creates markdown files for each bookmark with metadata 9 | - Configurable sync folder and API settings 10 | - Updates existing bookmarks if they've changed 11 | 12 | ## Installation 13 | 14 | 1. Download the latest release from the releases page 15 | 2. Extract the zip file in your Obsidian vault's `.obsidian/plugins/` directory 16 | 3. Enable the plugin in Obsidian's settings 17 | 18 | ## Configuration 19 | 20 | 1. Open Obsidian Settings 21 | 2. Navigate to "Hoarder Sync" under "Community Plugins" 22 | 3. Enter your Karakeep API key 23 | 4. (Optional) Modify the sync interval and folder settings 24 | 25 | ## Karakeep Configuration 26 | 27 | Ensure your CORS policy is set to allow requests from your Obsidian instance. In Traefik, add the following as a middleware: 28 | 29 | ```yaml 30 | obsidiancors: 31 | headers: 32 | accessControlAllowHeaders: "Authorization, Content-Type, Origin" 33 | accessControlAllowMethods: "GET, PATCH, POST, PUT, DELETE, OPTIONS" 34 | accessControlAllowCredentials: "true" 35 | accessControlAllowOriginList: 36 | - app://obsidian.md 37 | - capacitor://localhost 38 | - http://localhost 39 | ``` 40 | 41 | ## Settings 42 | 43 | - **Api key**: Your Karakeep API key (required) 44 | - **Api endpoint**: The Karakeep API endpoint (default: https://api.karakeep.app/api/v1) 45 | - **Sync folder**: The folder where bookmark notes will be created (default: "Hoarder") 46 | - **Attachments folder**: The folder where bookmark images will be saved (default: "Hoarder/attachments") 47 | - **Sync interval**: How often to sync in minutes (default: 60) 48 | - **Update existing files**: Whether to update or skip existing bookmark files (default: false) 49 | - **Exclude archived**: Exclude archived bookmarks from sync (default: true) 50 | - **Only favorites**: Only sync favorited bookmarks (default: false) 51 | - **Sync notes to Karakeep**: Whether to sync notes back to Karakeep (default: true) 52 | - **Excluded tags**: Bookmarks with these tags will not be synced (comma-separated), unless favorited (default: empty) 53 | - **Sync deletions**: Automatically handle bookmarks that are deleted in Karakeep (default: false) 54 | - **Deletion action**: What to do with local files when bookmarks are deleted in Karakeep - options: "Delete file", "Move to archive folder", or "Add deletion tag" (default: "Delete file") 55 | - **Archive folder**: Folder to move deleted bookmarks to when using "Move to archive folder" action (default: "Archive") 56 | - **Deletion tag**: Tag to add to files when bookmarks are deleted and using "Add deletion tag" action (default: "deleted") 57 | - **Handle archived bookmarks**: Separately handle bookmarks that are archived (not deleted) in Karakeep (default: false) 58 | - **Archived bookmark action**: What to do with local files when bookmarks are archived in Karakeep - options: "Do nothing", "Delete file", "Move to archive folder", or "Add archived tag" (default: "Delete file") 59 | - **Archived bookmark folder**: Folder to move archived bookmarks to when using "Move to archive folder" action (default: "Archive") 60 | - **Archived bookmark tag**: Tag to add to files when bookmarks are archived and using "Add archived tag" action (default: "archived") 61 | 62 | ## Deletion and Archive Sync 63 | 64 | The plugin now properly distinguishes between **deleted** and **archived** bookmarks in Karakeep: 65 | 66 | ### Deleted Bookmarks 67 | When "Sync deletions" is enabled, the plugin detects bookmarks that have been completely deleted from Karakeep and handles them according to your "Deletion action" setting: 68 | 69 | 1. **Delete file**: Permanently removes the markdown file from your vault 70 | 2. **Move to archive folder**: Moves the file to a specified archive folder (useful for keeping a backup) 71 | 3. **Add deletion tag**: Adds a tag to the file's frontmatter to mark it as deleted (useful for manual review) 72 | 73 | ### Archived Bookmarks 74 | When "Handle archived bookmarks" is enabled, the plugin separately handles bookmarks that are archived (but not deleted) in Karakeep: 75 | 76 | 1. **Do nothing**: Leaves the file unchanged (useful if you want to keep archived bookmarks in Obsidian) 77 | 2. **Delete file**: Removes the file from your vault 78 | 3. **Move to archive folder**: Moves the file to a specified archive folder 79 | 4. **Add archived tag**: Adds a tag to mark the file as archived 80 | 81 | This gives you fine-grained control over how your Obsidian vault reflects the state of your Karakeep bookmarks. 82 | 83 | ## Development 84 | 85 | 1. Clone this repository 86 | 2. Install dependencies with `npm install` 87 | 3. Build the plugin with `npm run build` 88 | 4. Copy `main.js` and `manifest.json` to your vault's plugin directory 89 | 90 | ## License 91 | 92 | MIT 93 | -------------------------------------------------------------------------------- /src/hoarder-client.ts: -------------------------------------------------------------------------------- 1 | import { requestUrl } from "obsidian"; 2 | 3 | // Type definitions for the Hoarder API responses 4 | export interface HoarderTag { 5 | id: string; 6 | name: string; 7 | attachedBy: "ai" | "human"; 8 | } 9 | 10 | export interface HoarderHighlight { 11 | id: string; 12 | bookmarkId: string; 13 | startOffset: number; 14 | endOffset: number; 15 | color: "yellow" | "red" | "green" | "blue"; 16 | text: string; 17 | note: string; 18 | userId: string; 19 | createdAt: string; 20 | } 21 | 22 | export interface HoarderBookmarkContent { 23 | type: "link" | "text" | "asset" | "unknown"; 24 | url?: string; 25 | title?: string | null; 26 | description?: string | null; 27 | imageUrl?: string | null; 28 | imageAssetId?: string | null; 29 | screenshotAssetId?: string | null; 30 | fullPageArchiveAssetId?: string | null; 31 | videoAssetId?: string | null; 32 | favicon?: string | null; 33 | htmlContent?: string | null; 34 | crawledAt?: string | null; 35 | text?: string; 36 | sourceUrl?: string | null; 37 | assetType?: "image" | "pdf"; 38 | assetId?: string; 39 | fileName?: string | null; 40 | } 41 | 42 | export interface HoarderBookmark { 43 | id: string; 44 | createdAt: string; 45 | modifiedAt: string | null; 46 | title?: string | null; 47 | archived: boolean; 48 | favourited: boolean; 49 | taggingStatus: "success" | "failure" | "pending" | null; 50 | note?: string | null; 51 | summary?: string | null; 52 | tags: HoarderTag[]; 53 | content: HoarderBookmarkContent; 54 | assets: Array<{ 55 | id: string; 56 | assetType: string; 57 | }>; 58 | } 59 | 60 | export interface PaginatedBookmarks { 61 | bookmarks: HoarderBookmark[]; 62 | nextCursor: string | null; 63 | } 64 | 65 | export interface PaginatedHighlights { 66 | highlights: HoarderHighlight[]; 67 | nextCursor: string | null; 68 | } 69 | 70 | export interface BookmarkQueryParams { 71 | limit?: number; 72 | cursor?: string; 73 | archived?: boolean; 74 | favourited?: boolean; 75 | } 76 | 77 | export class HoarderApiClient { 78 | private baseUrl: string; 79 | private apiKey: string; 80 | private useObsidianRequest: boolean; 81 | 82 | constructor(baseUrl: string, apiKey: string, useObsidianRequest: boolean = false) { 83 | this.baseUrl = baseUrl.replace(/\/$/, ""); // Remove trailing slash 84 | this.apiKey = apiKey; 85 | this.useObsidianRequest = useObsidianRequest; 86 | } 87 | 88 | private async makeRequest( 89 | endpoint: string, 90 | method: "GET" | "POST" | "PATCH" | "DELETE" = "GET", 91 | body?: any, 92 | params?: Record 93 | ): Promise { 94 | const url = new URL(`${this.baseUrl}${endpoint}`); 95 | 96 | // Add query parameters 97 | if (params) { 98 | Object.entries(params).forEach(([key, value]) => { 99 | if (value !== undefined && value !== null) { 100 | url.searchParams.append(key, String(value)); 101 | } 102 | }); 103 | } 104 | 105 | const headers: Record = { 106 | "Content-Type": "application/json", 107 | Authorization: `Bearer ${this.apiKey}`, 108 | }; 109 | 110 | try { 111 | if (this.useObsidianRequest) { 112 | // Use Obsidian's requestUrl to avoid CORS issues 113 | const response = await requestUrl({ 114 | url: url.toString(), 115 | method, 116 | headers, 117 | body: body ? JSON.stringify(body) : undefined, 118 | }); 119 | 120 | if (response.status >= 400) { 121 | throw new Error(`HTTP ${response.status}: ${response.text || "Unknown error"}`); 122 | } 123 | 124 | return response.json; 125 | } else { 126 | // Use standard fetch 127 | const response = await fetch(url.toString(), { 128 | method, 129 | headers, 130 | body: body ? JSON.stringify(body) : undefined, 131 | }); 132 | 133 | if (!response.ok) { 134 | const errorText = await response.text(); 135 | throw new Error(`HTTP ${response.status}: ${errorText || "Unknown error"}`); 136 | } 137 | 138 | return await response.json(); 139 | } 140 | } catch (error) { 141 | console.error("API request failed:", url.toString(), error); 142 | throw error; 143 | } 144 | } 145 | 146 | async getBookmarks(params?: BookmarkQueryParams): Promise { 147 | return this.makeRequest("/bookmarks", "GET", undefined, params); 148 | } 149 | 150 | async updateBookmark( 151 | bookmarkId: string, 152 | data: { note?: string; [key: string]: any } 153 | ): Promise { 154 | return this.makeRequest(`/bookmarks/${bookmarkId}`, "PATCH", data); 155 | } 156 | 157 | async getBookmarkHighlights(bookmarkId: string): Promise<{ highlights: HoarderHighlight[] }> { 158 | return this.makeRequest<{ highlights: HoarderHighlight[] }>( 159 | `/bookmarks/${bookmarkId}/highlights`, 160 | "GET" 161 | ); 162 | } 163 | 164 | async getHighlights(params?: { limit?: number; cursor?: string }): Promise { 165 | return this.makeRequest("/highlights", "GET", undefined, params); 166 | } 167 | 168 | async getAllHighlights(): Promise { 169 | const allHighlights: HoarderHighlight[] = []; 170 | let cursor: string | undefined; 171 | 172 | do { 173 | const data = await this.getHighlights({ 174 | limit: 100, 175 | cursor: cursor || undefined, 176 | }); 177 | 178 | allHighlights.push(...(data.highlights || [])); 179 | cursor = data.nextCursor || undefined; 180 | } while (cursor); 181 | 182 | return allHighlights; 183 | } 184 | 185 | async downloadAsset(assetId: string): Promise { 186 | const url = `${this.baseUrl}/assets/${assetId}`; 187 | const headers: Record = { 188 | Authorization: `Bearer ${this.apiKey}`, 189 | }; 190 | 191 | try { 192 | if (this.useObsidianRequest) { 193 | const response = await requestUrl({ 194 | url, 195 | method: "GET", 196 | headers, 197 | }); 198 | 199 | if (response.status >= 400) { 200 | throw new Error(`HTTP ${response.status}: ${response.text || "Unknown error"}`); 201 | } 202 | 203 | return response.arrayBuffer; 204 | } else { 205 | const response = await fetch(url, { 206 | method: "GET", 207 | headers, 208 | }); 209 | 210 | if (!response.ok) { 211 | const errorText = await response.text(); 212 | throw new Error(`HTTP ${response.status}: ${errorText || "Unknown error"}`); 213 | } 214 | 215 | return await response.arrayBuffer(); 216 | } 217 | } catch (error) { 218 | console.error("Asset download failed:", url, error); 219 | throw error; 220 | } 221 | } 222 | 223 | getAssetUrl(assetId: string): string { 224 | const baseUrl = this.baseUrl.replace(/\/api\/v1\/?$/, ""); 225 | return `${baseUrl}/assets/${assetId}`; 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/tag-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { sanitizeTag, sanitizeTags } from "./tag-utils"; 2 | 3 | describe("sanitizeTag", () => { 4 | describe("valid tags", () => { 5 | it("should return simple alphanumeric tags unchanged", () => { 6 | expect(sanitizeTag("meeting")).toBe("meeting"); 7 | expect(sanitizeTag("project123")).toBe("project123"); 8 | expect(sanitizeTag("y1984")).toBe("y1984"); 9 | }); 10 | 11 | it("should preserve underscores", () => { 12 | expect(sanitizeTag("snake_case")).toBe("snake_case"); 13 | expect(sanitizeTag("my_tag_name")).toBe("my_tag_name"); 14 | }); 15 | 16 | it("should preserve hyphens", () => { 17 | expect(sanitizeTag("kebab-case")).toBe("kebab-case"); 18 | expect(sanitizeTag("my-tag-name")).toBe("my-tag-name"); 19 | }); 20 | 21 | it("should preserve forward slashes for nested tags", () => { 22 | expect(sanitizeTag("inbox/to-read")).toBe("inbox/to-read"); 23 | expect(sanitizeTag("project/web/frontend")).toBe("project/web/frontend"); 24 | }); 25 | 26 | it("should preserve mixed valid characters", () => { 27 | expect(sanitizeTag("tag_2024-01")).toBe("tag_2024-01"); 28 | expect(sanitizeTag("project/v2_final-draft")).toBe("project/v2_final-draft"); 29 | }); 30 | }); 31 | 32 | describe("space handling", () => { 33 | it("should convert spaces to hyphens", () => { 34 | expect(sanitizeTag("web dev")).toBe("web-dev"); 35 | expect(sanitizeTag("my tag")).toBe("my-tag"); 36 | }); 37 | 38 | it("should convert multiple spaces to single hyphen", () => { 39 | expect(sanitizeTag("web dev")).toBe("web-dev"); 40 | expect(sanitizeTag("my long tag")).toBe("my-long-tag"); 41 | }); 42 | 43 | it("should handle tabs and other whitespace", () => { 44 | expect(sanitizeTag("web\tdev")).toBe("web-dev"); 45 | expect(sanitizeTag("tag\nwith\nnewlines")).toBe("tag-with-newlines"); 46 | }); 47 | 48 | it("should trim leading and trailing spaces", () => { 49 | expect(sanitizeTag(" tag ")).toBe("tag"); 50 | expect(sanitizeTag(" web dev ")).toBe("web-dev"); 51 | }); 52 | }); 53 | 54 | describe("invalid character handling", () => { 55 | it("should remove special characters", () => { 56 | expect(sanitizeTag("tag!")).toBe("tag"); 57 | expect(sanitizeTag("tag@email")).toBe("tagemail"); 58 | expect(sanitizeTag("tag#hash")).toBe("taghash"); 59 | expect(sanitizeTag("tag$money")).toBe("tagmoney"); 60 | }); 61 | 62 | it("should remove punctuation", () => { 63 | expect(sanitizeTag("tag.dot")).toBe("tagdot"); 64 | expect(sanitizeTag("tag,comma")).toBe("tagcomma"); 65 | expect(sanitizeTag("tag;semicolon")).toBe("tagsemicolon"); 66 | expect(sanitizeTag("tag:colon")).toBe("tagcolon"); 67 | }); 68 | 69 | it("should remove brackets and parentheses", () => { 70 | expect(sanitizeTag("tag[bracket]")).toBe("tagbracket"); 71 | expect(sanitizeTag("tag{brace}")).toBe("tagbrace"); 72 | expect(sanitizeTag("tag(paren)")).toBe("tagparen"); 73 | }); 74 | 75 | it("should remove quotes", () => { 76 | expect(sanitizeTag('tag"quote')).toBe("tagquote"); 77 | expect(sanitizeTag("tag'apostrophe")).toBe("tagapostrophe"); 78 | }); 79 | 80 | it("should handle mixed invalid characters", () => { 81 | expect(sanitizeTag("my!@#$%tag")).toBe("mytag"); 82 | expect(sanitizeTag("tag<>with|special*chars")).toBe("tagwithspecialchars"); 83 | }); 84 | }); 85 | 86 | describe("numeric tag handling", () => { 87 | it("should prepend 'tag-' to purely numeric tags", () => { 88 | expect(sanitizeTag("1984")).toBe("tag-1984"); 89 | expect(sanitizeTag("123")).toBe("tag-123"); 90 | expect(sanitizeTag("42")).toBe("tag-42"); 91 | }); 92 | 93 | it("should NOT modify tags with letters and numbers", () => { 94 | expect(sanitizeTag("y1984")).toBe("y1984"); 95 | expect(sanitizeTag("tag123")).toBe("tag123"); 96 | expect(sanitizeTag("123abc")).toBe("123abc"); 97 | }); 98 | 99 | it("should prepend 'tag-' to tags with only numbers and separators", () => { 100 | expect(sanitizeTag("2024-01")).toBe("tag-2024-01"); 101 | expect(sanitizeTag("123_456")).toBe("tag-123_456"); 102 | expect(sanitizeTag("2024/01/15")).toBe("tag-2024/01/15"); 103 | }); 104 | }); 105 | 106 | describe("empty and null handling", () => { 107 | it("should return null for empty strings", () => { 108 | expect(sanitizeTag("")).toBe(null); 109 | expect(sanitizeTag(" ")).toBe(null); 110 | expect(sanitizeTag("\t\n")).toBe(null); 111 | }); 112 | 113 | it("should return null if only invalid characters remain", () => { 114 | expect(sanitizeTag("!!!")).toBe(null); 115 | expect(sanitizeTag("@#$%")).toBe(null); 116 | expect(sanitizeTag("...")).toBe(null); 117 | }); 118 | 119 | it("should return null if spaces leave nothing after conversion", () => { 120 | expect(sanitizeTag(" @@@ ")).toBe(null); 121 | }); 122 | }); 123 | 124 | describe("edge cases", () => { 125 | it("should handle very long tags", () => { 126 | const longTag = "a".repeat(1000); 127 | expect(sanitizeTag(longTag)).toBe(longTag); 128 | }); 129 | 130 | it("should handle unicode characters by removing them", () => { 131 | expect(sanitizeTag("tag🚀emoji")).toBe("tagemoji"); 132 | expect(sanitizeTag("café")).toBe("caf"); 133 | expect(sanitizeTag("日本語")).toBe(null); 134 | }); 135 | 136 | it("should handle mixed case", () => { 137 | expect(sanitizeTag("MyTag")).toBe("MyTag"); 138 | expect(sanitizeTag("WEB-DEV")).toBe("WEB-DEV"); 139 | }); 140 | 141 | it("should handle nested tags with invalid characters", () => { 142 | expect(sanitizeTag("inbox / to-read")).toBe("inbox-/-to-read"); 143 | expect(sanitizeTag("project/web dev")).toBe("project/web-dev"); 144 | }); 145 | }); 146 | 147 | describe("real-world examples", () => { 148 | it("should handle common tag patterns", () => { 149 | expect(sanitizeTag("work")).toBe("work"); 150 | expect(sanitizeTag("personal")).toBe("personal"); 151 | expect(sanitizeTag("to-do")).toBe("to-do"); 152 | expect(sanitizeTag("follow_up")).toBe("follow_up"); 153 | }); 154 | 155 | it("should handle tags from various sources", () => { 156 | expect(sanitizeTag("Web Development")).toBe("Web-Development"); 157 | expect(sanitizeTag("JavaScript/React")).toBe("JavaScript/React"); 158 | expect(sanitizeTag("2024 goals")).toBe("2024-goals"); 159 | expect(sanitizeTag("Q1-2024")).toBe("Q1-2024"); 160 | expect(sanitizeTag("men's fashion")).toBe("mens-fashion"); 161 | }); 162 | 163 | it("should handle problematic user inputs", () => { 164 | expect(sanitizeTag("tag!!!")).toBe("tag"); 165 | expect(sanitizeTag(" my tag ")).toBe("my-tag"); 166 | expect(sanitizeTag("#hashtag")).toBe("hashtag"); 167 | expect(sanitizeTag("@mention")).toBe("mention"); 168 | }); 169 | }); 170 | }); 171 | 172 | describe("sanitizeTags", () => { 173 | it("should sanitize array of valid tags", () => { 174 | expect(sanitizeTags(["tag1", "tag2", "tag3"])).toEqual(["tag1", "tag2", "tag3"]); 175 | }); 176 | 177 | it("should filter out null results", () => { 178 | expect(sanitizeTags(["valid", "", "also-valid", "!!!"])).toEqual(["valid", "also-valid"]); 179 | }); 180 | 181 | it("should handle empty array", () => { 182 | expect(sanitizeTags([])).toEqual([]); 183 | }); 184 | 185 | it("should handle array with all invalid tags", () => { 186 | expect(sanitizeTags(["!!!", "@@@", " "])).toEqual([]); 187 | }); 188 | 189 | it("should sanitize and filter mixed tags", () => { 190 | const input = ["Web Dev", "1984", "", "project/web", "!!!", "valid"]; 191 | const expected = ["Web-Dev", "tag-1984", "project/web", "valid"]; 192 | expect(sanitizeTags(input)).toEqual(expected); 193 | }); 194 | 195 | it("should preserve order of valid tags", () => { 196 | const input = ["zebra", "apple", "monkey", "banana"]; 197 | const expected = ["zebra", "apple", "monkey", "banana"]; 198 | expect(sanitizeTags(input)).toEqual(expected); 199 | }); 200 | }); 201 | -------------------------------------------------------------------------------- /src/filename-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { sanitizeFileName } from "./filename-utils"; 2 | 3 | describe("sanitizeFileName", () => { 4 | const testDate = "2024-01-15T10:30:00.000Z"; 5 | 6 | describe("basic functionality", () => { 7 | it("should create filename with date prefix", () => { 8 | expect(sanitizeFileName("My Title", testDate)).toBe("2024-01-15-My-Title"); 9 | }); 10 | 11 | it("should handle Date objects", () => { 12 | const date = new Date(testDate); 13 | expect(sanitizeFileName("Test", date)).toBe("2024-01-15-Test"); 14 | }); 15 | 16 | it("should format date correctly", () => { 17 | expect(sanitizeFileName("Title", "2024-12-31T23:59:59.999Z")).toBe("2024-12-31-Title"); 18 | expect(sanitizeFileName("Title", "2024-01-01T00:00:00.000Z")).toBe("2024-01-01-Title"); 19 | }); 20 | }); 21 | 22 | describe("invalid character handling", () => { 23 | it("should replace backslashes with dashes", () => { 24 | expect(sanitizeFileName("My\\Title", testDate)).toBe("2024-01-15-My-Title"); 25 | }); 26 | 27 | it("should replace forward slashes with dashes", () => { 28 | expect(sanitizeFileName("My/Title", testDate)).toBe("2024-01-15-My-Title"); 29 | }); 30 | 31 | it("should replace colons with dashes", () => { 32 | expect(sanitizeFileName("My:Title", testDate)).toBe("2024-01-15-My-Title"); 33 | }); 34 | 35 | it("should replace asterisks with dashes", () => { 36 | expect(sanitizeFileName("My*Title", testDate)).toBe("2024-01-15-My-Title"); 37 | }); 38 | 39 | it("should replace question marks with dashes", () => { 40 | expect(sanitizeFileName("My?Title", testDate)).toBe("2024-01-15-My-Title"); 41 | }); 42 | 43 | it("should replace quotes with dashes", () => { 44 | expect(sanitizeFileName('My"Title', testDate)).toBe("2024-01-15-My-Title"); 45 | }); 46 | 47 | it("should replace angle brackets with dashes", () => { 48 | expect(sanitizeFileName("My", testDate)).toBe("2024-01-15-My-Title"); 49 | }); 50 | 51 | it("should replace pipes with dashes", () => { 52 | expect(sanitizeFileName("My|Title", testDate)).toBe("2024-01-15-My-Title"); 53 | }); 54 | 55 | it("should handle multiple invalid characters", () => { 56 | expect(sanitizeFileName('My\\/:*?"<>|Title', testDate)).toBe("2024-01-15-My-Title"); 57 | }); 58 | }); 59 | 60 | describe("space and dash handling", () => { 61 | it("should replace spaces with dashes", () => { 62 | expect(sanitizeFileName("My Title Here", testDate)).toBe("2024-01-15-My-Title-Here"); 63 | }); 64 | 65 | it("should replace multiple spaces with single dash", () => { 66 | expect(sanitizeFileName("My Title Here", testDate)).toBe("2024-01-15-My-Title-Here"); 67 | }); 68 | 69 | it("should collapse multiple dashes into one", () => { 70 | expect(sanitizeFileName("My---Title", testDate)).toBe("2024-01-15-My-Title"); 71 | }); 72 | 73 | it("should remove leading dashes", () => { 74 | expect(sanitizeFileName("---Title", testDate)).toBe("2024-01-15-Title"); 75 | }); 76 | 77 | it("should remove trailing dashes", () => { 78 | expect(sanitizeFileName("Title---", testDate)).toBe("2024-01-15-Title"); 79 | }); 80 | 81 | it("should handle combination of spaces and invalid chars", () => { 82 | expect(sanitizeFileName("My / Title", testDate)).toBe("2024-01-15-My-Title"); 83 | }); 84 | 85 | it("should handle tabs and newlines", () => { 86 | expect(sanitizeFileName("My\tTitle\nHere", testDate)).toBe("2024-01-15-My-Title-Here"); 87 | }); 88 | }); 89 | 90 | describe("length limiting", () => { 91 | it("should truncate titles longer than 36 characters", () => { 92 | const longTitle = "This is a very long title that exceeds the maximum allowed length"; 93 | const result = sanitizeFileName(longTitle, testDate); 94 | expect(result).toMatch(/^2024-01-15-/); 95 | expect(result.length).toBeLessThanOrEqual(50); 96 | }); 97 | 98 | it("should try to break at word boundaries", () => { 99 | // 40 chars: "This-is-a-long-title-with-many-words-here" 100 | const longTitle = "This is a long title with many words here"; 101 | const result = sanitizeFileName(longTitle, testDate); 102 | expect(result).toMatch(/^2024-01-15-/); 103 | expect(result.length).toBeLessThanOrEqual(50); 104 | // Result should be: "2024-01-15-This-is-a-long-title-with-many" 105 | // which is a reasonable word boundary break 106 | expect(result).toBe("2024-01-15-This-is-a-long-title-with-many"); 107 | }); 108 | 109 | it("should truncate if no good word boundary exists", () => { 110 | // One very long word 111 | const longTitle = "Supercalifragilisticexpialidocious-and-more"; 112 | const result = sanitizeFileName(longTitle, testDate); 113 | expect(result).toMatch(/^2024-01-15-/); 114 | expect(result.length).toBeLessThanOrEqual(50); 115 | }); 116 | 117 | it("should handle exactly 36 character titles", () => { 118 | const title = "a".repeat(36); 119 | const result = sanitizeFileName(title, testDate); 120 | expect(result).toBe(`2024-01-15-${"a".repeat(36)}`); 121 | }); 122 | 123 | it("should handle titles just over 36 characters", () => { 124 | const title = "a".repeat(37); 125 | const result = sanitizeFileName(title, testDate); 126 | expect(result.length).toBeLessThanOrEqual(50); 127 | }); 128 | }); 129 | 130 | describe("edge cases", () => { 131 | it("should handle empty string", () => { 132 | expect(sanitizeFileName("", testDate)).toBe("2024-01-15-"); 133 | }); 134 | 135 | it("should handle only spaces", () => { 136 | expect(sanitizeFileName(" ", testDate)).toBe("2024-01-15-"); 137 | }); 138 | 139 | it("should handle only invalid characters", () => { 140 | expect(sanitizeFileName("///***???", testDate)).toBe("2024-01-15-"); 141 | }); 142 | 143 | it("should handle single character", () => { 144 | expect(sanitizeFileName("a", testDate)).toBe("2024-01-15-a"); 145 | }); 146 | 147 | it("should handle unicode characters", () => { 148 | expect(sanitizeFileName("My 日本語 Title", testDate)).toBe("2024-01-15-My-日本語-Title"); 149 | expect(sanitizeFileName("Café ☕ Title", testDate)).toBe("2024-01-15-Café-☕-Title"); 150 | }); 151 | 152 | it("should handle mixed case", () => { 153 | expect(sanitizeFileName("MyTitle", testDate)).toBe("2024-01-15-MyTitle"); 154 | expect(sanitizeFileName("MY-TITLE", testDate)).toBe("2024-01-15-MY-TITLE"); 155 | }); 156 | 157 | it("should handle numbers", () => { 158 | expect(sanitizeFileName("123 Test 456", testDate)).toBe("2024-01-15-123-Test-456"); 159 | }); 160 | }); 161 | 162 | describe("real-world examples", () => { 163 | it("should handle blog post titles", () => { 164 | expect(sanitizeFileName("How to Build a REST API", testDate)).toBe( 165 | "2024-01-15-How-to-Build-a-REST-API" 166 | ); 167 | }); 168 | 169 | it("should handle URL-like titles", () => { 170 | expect(sanitizeFileName("https://example.com/article", testDate)).toBe( 171 | "2024-01-15-https-example.com-article" 172 | ); 173 | }); 174 | 175 | it("should handle titles with version numbers", () => { 176 | expect(sanitizeFileName("Node.js v20.0.0 Release", testDate)).toBe( 177 | "2024-01-15-Node.js-v20.0.0-Release" 178 | ); 179 | }); 180 | 181 | it("should handle titles with parentheses", () => { 182 | expect(sanitizeFileName("My Title (Draft)", testDate)).toBe("2024-01-15-My-Title-(Draft)"); 183 | }); 184 | 185 | it("should handle titles with brackets", () => { 186 | expect(sanitizeFileName("My [Important] Title", testDate)).toBe( 187 | "2024-01-15-My-[Important]-Title" 188 | ); 189 | }); 190 | 191 | it("should handle programming-related titles", () => { 192 | expect(sanitizeFileName("Understanding async/await in JavaScript", testDate)).toBe( 193 | "2024-01-15-Understanding-async-await-in" 194 | ); 195 | }); 196 | 197 | it("should handle article titles with punctuation", () => { 198 | expect(sanitizeFileName("What's New in 2024?", testDate)).toBe( 199 | "2024-01-15-What's-New-in-2024" 200 | ); 201 | }); 202 | 203 | it("should handle book titles", () => { 204 | expect(sanitizeFileName("The Art of War: Ancient Wisdom", testDate)).toBe( 205 | "2024-01-15-The-Art-of-War-Ancient-Wisdom" 206 | ); 207 | }); 208 | }); 209 | 210 | describe("consistency", () => { 211 | it("should produce same result for same input", () => { 212 | const title = "My Test Title"; 213 | const result1 = sanitizeFileName(title, testDate); 214 | const result2 = sanitizeFileName(title, testDate); 215 | expect(result1).toBe(result2); 216 | }); 217 | 218 | it("should handle different dates with same title", () => { 219 | const title = "Same Title"; 220 | const date1 = "2024-01-15T10:00:00.000Z"; 221 | const date2 = "2024-12-31T23:59:59.999Z"; 222 | expect(sanitizeFileName(title, date1)).toBe("2024-01-15-Same-Title"); 223 | expect(sanitizeFileName(title, date2)).toBe("2024-12-31-Same-Title"); 224 | }); 225 | }); 226 | }); 227 | -------------------------------------------------------------------------------- /src/filter-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { shouldIncludeBookmark } from "./filter-utils"; 2 | 3 | describe("shouldIncludeBookmark", () => { 4 | describe("no filters", () => { 5 | it("should include bookmark when no filters specified", () => { 6 | const result = shouldIncludeBookmark(["tag1", "tag2"], [], [], false); 7 | expect(result.include).toBe(true); 8 | expect(result.reason).toBeUndefined(); 9 | }); 10 | 11 | it("should include favorited bookmark with no filters", () => { 12 | const result = shouldIncludeBookmark(["tag1"], [], [], true); 13 | expect(result.include).toBe(true); 14 | }); 15 | 16 | it("should include bookmark with no tags and no filters", () => { 17 | const result = shouldIncludeBookmark([], [], [], false); 18 | expect(result.include).toBe(true); 19 | }); 20 | }); 21 | 22 | describe("included tags filter", () => { 23 | it("should include bookmark with matching included tag", () => { 24 | const result = shouldIncludeBookmark(["work", "important"], ["work"], [], false); 25 | expect(result.include).toBe(true); 26 | }); 27 | 28 | it("should include bookmark with one of multiple included tags", () => { 29 | const result = shouldIncludeBookmark( 30 | ["project", "web"], 31 | ["mobile", "web", "desktop"], 32 | [], 33 | false 34 | ); 35 | expect(result.include).toBe(true); 36 | }); 37 | 38 | it("should exclude bookmark without any included tags", () => { 39 | const result = shouldIncludeBookmark(["personal"], ["work"], [], false); 40 | expect(result.include).toBe(false); 41 | expect(result.reason).toBe("missing_included_tag"); 42 | }); 43 | 44 | it("should exclude bookmark with no tags when included tags specified", () => { 45 | const result = shouldIncludeBookmark([], ["work"], [], false); 46 | expect(result.include).toBe(false); 47 | expect(result.reason).toBe("missing_included_tag"); 48 | }); 49 | 50 | it("should handle multiple included tags", () => { 51 | const result = shouldIncludeBookmark(["dev", "javascript"], ["dev", "python"], [], false); 52 | expect(result.include).toBe(true); 53 | }); 54 | 55 | it("should be case-sensitive (expects already lowercased)", () => { 56 | const result = shouldIncludeBookmark(["work"], ["WORK"], [], false); 57 | expect(result.include).toBe(false); 58 | expect(result.reason).toBe("missing_included_tag"); 59 | }); 60 | }); 61 | 62 | describe("excluded tags filter", () => { 63 | it("should exclude bookmark with excluded tag", () => { 64 | const result = shouldIncludeBookmark(["spam", "newsletter"], [], ["spam"], false); 65 | expect(result.include).toBe(false); 66 | expect(result.reason).toBe("excluded_tag"); 67 | }); 68 | 69 | it("should include bookmark without excluded tags", () => { 70 | const result = shouldIncludeBookmark(["work", "project"], [], ["spam"], false); 71 | expect(result.include).toBe(true); 72 | }); 73 | 74 | it("should exclude if any excluded tag matches", () => { 75 | const result = shouldIncludeBookmark(["work", "spam"], [], ["spam", "junk", "ads"], false); 76 | expect(result.include).toBe(false); 77 | expect(result.reason).toBe("excluded_tag"); 78 | }); 79 | 80 | it("should handle multiple excluded tags", () => { 81 | const result = shouldIncludeBookmark(["newsletter"], [], ["spam", "ads"], false); 82 | expect(result.include).toBe(true); 83 | }); 84 | 85 | it("should be case-sensitive for excluded tags", () => { 86 | const result = shouldIncludeBookmark(["spam"], [], ["SPAM"], false); 87 | expect(result.include).toBe(true); 88 | }); 89 | }); 90 | 91 | describe("favorited bookmark behavior", () => { 92 | it("should bypass excluded tags for favorited bookmarks", () => { 93 | const result = shouldIncludeBookmark(["spam"], [], ["spam"], true); 94 | expect(result.include).toBe(true); 95 | }); 96 | 97 | it("should still check included tags for favorited bookmarks", () => { 98 | const result = shouldIncludeBookmark(["spam"], ["work"], ["spam"], true); 99 | expect(result.include).toBe(false); 100 | expect(result.reason).toBe("missing_included_tag"); 101 | }); 102 | 103 | it("should include favorited with excluded tag but matching included tag", () => { 104 | const result = shouldIncludeBookmark(["work", "spam"], ["work"], ["spam"], true); 105 | expect(result.include).toBe(true); 106 | }); 107 | 108 | it("should bypass multiple excluded tags when favorited", () => { 109 | const result = shouldIncludeBookmark( 110 | ["spam", "ads", "junk"], 111 | [], 112 | ["spam", "ads", "junk"], 113 | true 114 | ); 115 | expect(result.include).toBe(true); 116 | }); 117 | }); 118 | 119 | describe("combined filters", () => { 120 | it("should require included tag and no excluded tags", () => { 121 | const result = shouldIncludeBookmark(["work"], ["work"], ["spam"], false); 122 | expect(result.include).toBe(true); 123 | }); 124 | 125 | it("should exclude if has included but also excluded tag", () => { 126 | const result = shouldIncludeBookmark(["work", "spam"], ["work"], ["spam"], false); 127 | expect(result.include).toBe(false); 128 | expect(result.reason).toBe("excluded_tag"); 129 | }); 130 | 131 | it("should exclude if missing included tag even without excluded", () => { 132 | const result = shouldIncludeBookmark(["personal"], ["work"], ["spam"], false); 133 | expect(result.include).toBe(false); 134 | expect(result.reason).toBe("missing_included_tag"); 135 | }); 136 | 137 | it("should prioritize included tag check over excluded", () => { 138 | // First checks included, so returns missing_included_tag not excluded_tag 139 | const result = shouldIncludeBookmark(["spam"], ["work"], ["spam"], false); 140 | expect(result.include).toBe(false); 141 | expect(result.reason).toBe("missing_included_tag"); 142 | }); 143 | }); 144 | 145 | describe("edge cases", () => { 146 | it("should handle empty bookmark tags", () => { 147 | const result = shouldIncludeBookmark([], [], [], false); 148 | expect(result.include).toBe(true); 149 | }); 150 | 151 | it("should handle bookmark with many tags", () => { 152 | const tags = ["a", "b", "c", "d", "e", "f", "g"]; 153 | const result = shouldIncludeBookmark(tags, ["a"], [], false); 154 | expect(result.include).toBe(true); 155 | }); 156 | 157 | it("should handle many included tags", () => { 158 | const included = ["tag1", "tag2", "tag3", "tag4", "tag5"]; 159 | const result = shouldIncludeBookmark(["tag3"], included, [], false); 160 | expect(result.include).toBe(true); 161 | }); 162 | 163 | it("should handle many excluded tags", () => { 164 | const excluded = ["spam1", "spam2", "spam3", "spam4"]; 165 | const result = shouldIncludeBookmark(["spam2"], [], excluded, false); 166 | expect(result.include).toBe(false); 167 | }); 168 | }); 169 | 170 | describe("real-world scenarios", () => { 171 | it("should filter work-related bookmarks", () => { 172 | const result = shouldIncludeBookmark(["work", "meeting", "urgent"], ["work"], [], false); 173 | expect(result.include).toBe(true); 174 | }); 175 | 176 | it("should exclude newsletters", () => { 177 | const result = shouldIncludeBookmark( 178 | ["tech", "newsletter"], 179 | [], 180 | ["newsletter", "spam"], 181 | false 182 | ); 183 | expect(result.include).toBe(false); 184 | expect(result.reason).toBe("excluded_tag"); 185 | }); 186 | 187 | it("should keep favorited newsletter", () => { 188 | const result = shouldIncludeBookmark( 189 | ["tech", "newsletter"], 190 | [], 191 | ["newsletter", "spam"], 192 | true 193 | ); 194 | expect(result.include).toBe(true); 195 | }); 196 | 197 | it("should filter by project tag", () => { 198 | const result = shouldIncludeBookmark( 199 | ["project-x", "web", "frontend"], 200 | ["project-x"], 201 | ["archived"], 202 | false 203 | ); 204 | expect(result.include).toBe(true); 205 | }); 206 | 207 | it("should exclude archived unless favorited", () => { 208 | const notFavorited = shouldIncludeBookmark(["project", "archived"], [], ["archived"], false); 209 | expect(notFavorited.include).toBe(false); 210 | 211 | const favorited = shouldIncludeBookmark(["project", "archived"], [], ["archived"], true); 212 | expect(favorited.include).toBe(true); 213 | }); 214 | 215 | it("should handle reading list filtering", () => { 216 | const result = shouldIncludeBookmark( 217 | ["to-read", "article", "javascript"], 218 | ["to-read"], 219 | ["read"], 220 | false 221 | ); 222 | expect(result.include).toBe(true); 223 | }); 224 | 225 | it("should exclude already read articles", () => { 226 | const result = shouldIncludeBookmark( 227 | ["article", "read", "javascript"], 228 | ["article"], 229 | ["read"], 230 | false 231 | ); 232 | expect(result.include).toBe(false); 233 | expect(result.reason).toBe("excluded_tag"); 234 | }); 235 | }); 236 | }); 237 | -------------------------------------------------------------------------------- /src/markdown-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { extractNotesSection } from "./markdown-utils"; 2 | 3 | describe("extractNotesSection", () => { 4 | describe("basic extraction", () => { 5 | it("should extract simple notes", () => { 6 | const content = "## Notes\n\nThis is my note"; 7 | expect(extractNotesSection(content)).toBe("This is my note"); 8 | }); 9 | 10 | it("should extract multi-line notes", () => { 11 | const content = "## Notes\n\nLine one\nLine two\nLine three"; 12 | expect(extractNotesSection(content)).toBe("Line one\nLine two\nLine three"); 13 | }); 14 | 15 | it("should trim whitespace", () => { 16 | const content = "## Notes\n\n My note \n\n"; 17 | expect(extractNotesSection(content)).toBe("My note"); 18 | }); 19 | 20 | it("should return null if no notes section", () => { 21 | const content = "# Title\n\nSome content"; 22 | expect(extractNotesSection(content)).toBe(null); 23 | }); 24 | 25 | it("should return null for empty notes section", () => { 26 | const content = "## Notes\n\n"; 27 | expect(extractNotesSection(content)).toBe(""); 28 | }); 29 | }); 30 | 31 | describe("notes followed by sections", () => { 32 | it("should stop at next section header", () => { 33 | const content = "## Notes\n\nMy note\n\n## Summary\n\nSome summary"; 34 | expect(extractNotesSection(content)).toBe("My note"); 35 | }); 36 | 37 | it("should stop at description section", () => { 38 | const content = "## Notes\n\nMy note\n\n## Description\n\nSome description"; 39 | expect(extractNotesSection(content)).toBe("My note"); 40 | }); 41 | 42 | it("should stop at highlights section", () => { 43 | const content = "## Notes\n\nMy note\n\n## Highlights\n\n> Some highlight"; 44 | expect(extractNotesSection(content)).toBe("My note"); 45 | }); 46 | 47 | it("should handle multiple sections after notes", () => { 48 | const content = "## Notes\n\nMy note\n\n## Summary\n\nSummary\n\n## Description\n\nDesc"; 49 | expect(extractNotesSection(content)).toBe("My note"); 50 | }); 51 | }); 52 | 53 | describe("notes followed by links", () => { 54 | it("should stop at link", () => { 55 | const content = "## Notes\n\nMy note\n\n[Visit Link](https://example.com)"; 56 | expect(extractNotesSection(content)).toBe("My note"); 57 | }); 58 | 59 | it("should stop at view in hoarder link", () => { 60 | const content = "## Notes\n\nMy note\n\n[View in Hoarder](https://hoarder.com)"; 61 | expect(extractNotesSection(content)).toBe("My note"); 62 | }); 63 | 64 | it("should handle multiple links", () => { 65 | const content = "## Notes\n\nMy note\n\n[Link 1](url1)\n[Link 2](url2)"; 66 | expect(extractNotesSection(content)).toBe("My note"); 67 | }); 68 | }); 69 | 70 | describe("notes at end of file", () => { 71 | it("should extract notes at end", () => { 72 | const content = "# Title\n\n## Summary\n\nSome text\n\n## Notes\n\nMy note at end"; 73 | expect(extractNotesSection(content)).toBe("My note at end"); 74 | }); 75 | 76 | it("should handle notes as last section with trailing newlines", () => { 77 | const content = "## Notes\n\nMy note\n\n\n"; 78 | expect(extractNotesSection(content)).toBe("My note"); 79 | }); 80 | 81 | it("should extract notes before links at end", () => { 82 | const content = "## Notes\n\nMy note\n\n[Link](url)"; 83 | expect(extractNotesSection(content)).toBe("My note"); 84 | }); 85 | }); 86 | 87 | describe("notes with special content", () => { 88 | it("should handle notes with markdown formatting", () => { 89 | const content = "## Notes\n\n**Bold** and *italic* text"; 90 | expect(extractNotesSection(content)).toBe("**Bold** and *italic* text"); 91 | }); 92 | 93 | it("should handle notes with lists", () => { 94 | const content = "## Notes\n\n- Item 1\n- Item 2\n- Item 3\n\n## Summary"; 95 | expect(extractNotesSection(content)).toBe("- Item 1\n- Item 2\n- Item 3"); 96 | }); 97 | 98 | it("should handle notes with code blocks", () => { 99 | const content = "## Notes\n\n```js\nconst x = 1;\n```\n\n## Summary"; 100 | expect(extractNotesSection(content)).toBe("```js\nconst x = 1;\n```"); 101 | }); 102 | 103 | it("should handle notes with quotes", () => { 104 | const content = '## Notes\n\n"This is a quote"\n\n## Summary'; 105 | expect(extractNotesSection(content)).toBe('"This is a quote"'); 106 | }); 107 | 108 | it("should handle notes with special characters", () => { 109 | const content = "## Notes\n\nText with #hashtags and @mentions!\n\n## Summary"; 110 | expect(extractNotesSection(content)).toBe("Text with #hashtags and @mentions!"); 111 | }); 112 | 113 | it("should handle notes with URLs", () => { 114 | const content = "## Notes\n\nCheck out https://example.com for more\n\n## Summary"; 115 | expect(extractNotesSection(content)).toBe("Check out https://example.com for more"); 116 | }); 117 | 118 | it("should handle notes with inline links", () => { 119 | const content = "## Notes\n\nRead more at [website](https://example.com) here\n\n## Summary"; 120 | expect(extractNotesSection(content)).toBe("Read more at [website](https://example.com) here"); 121 | }); 122 | }); 123 | 124 | describe("edge cases with headers", () => { 125 | it("should match ### Notes (level 3 contains ##)", () => { 126 | // The regex matches "## Notes" which is contained in "### Notes" 127 | const content = "### Notes\n\nSome text"; 128 | expect(extractNotesSection(content)).toBe("Some text"); 129 | }); 130 | 131 | it("should not match # Notes (level 1)", () => { 132 | const content = "# Notes\n\nSome text"; 133 | expect(extractNotesSection(content)).toBe(null); 134 | }); 135 | 136 | it("should match first ## Notes occurrence", () => { 137 | // Matches the first "## Notes" it finds (which is in "### Notes") 138 | const content = "# Title\n\n### Notes\n\nWrong\n\n## Notes\n\nCorrect\n\n## Summary"; 139 | expect(extractNotesSection(content)).toBe("Wrong"); 140 | }); 141 | 142 | it("should handle notes with no space after ##", () => { 143 | const content = "##Notes\n\nSome text"; 144 | expect(extractNotesSection(content)).toBe(null); 145 | }); 146 | 147 | it("should require exact spacing", () => { 148 | const content = "## Notes\n\nSome text"; 149 | expect(extractNotesSection(content)).toBe(null); 150 | }); 151 | }); 152 | 153 | describe("edge cases with empty lines", () => { 154 | it("should handle multiple empty lines in notes", () => { 155 | const content = "## Notes\n\nLine 1\n\n\n\nLine 2\n\n## Summary"; 156 | expect(extractNotesSection(content)).toBe("Line 1\n\n\n\nLine 2"); 157 | }); 158 | 159 | it("should handle tabs and spaces", () => { 160 | const content = "## Notes\n\n\tIndented text\n Spaces\n\n## Summary"; 161 | // trim() removes leading tabs 162 | expect(extractNotesSection(content)).toBe("Indented text\n Spaces"); 163 | }); 164 | }); 165 | 166 | describe("real-world examples", () => { 167 | it("should extract from typical bookmark format", () => { 168 | const content = `--- 169 | title: My Bookmark 170 | --- 171 | 172 | # My Bookmark 173 | 174 | ## Summary 175 | 176 | This is a summary 177 | 178 | ## Description 179 | 180 | This is a description 181 | 182 | ## Notes 183 | 184 | These are my personal notes 185 | With multiple lines 186 | And some thoughts 187 | 188 | [Visit Link](https://example.com) 189 | [View in Hoarder](https://hoarder.com)`; 190 | 191 | expect(extractNotesSection(content)).toBe( 192 | "These are my personal notes\nWith multiple lines\nAnd some thoughts" 193 | ); 194 | }); 195 | 196 | it("should extract from bookmark with highlights", () => { 197 | const content = `## Highlights 198 | 199 | > Some highlighted text 200 | 201 | ## Notes 202 | 203 | My thoughts on this article 204 | 205 | ## More Info`; 206 | 207 | expect(extractNotesSection(content)).toBe("My thoughts on this article"); 208 | }); 209 | 210 | it("should handle empty notes in real format", () => { 211 | const content = `## Summary 212 | 213 | Summary text 214 | 215 | ## Notes 216 | 217 | 218 | [Visit Link](https://example.com)`; 219 | 220 | expect(extractNotesSection(content)).toBe(""); 221 | }); 222 | 223 | it("should extract notes before multiple links", () => { 224 | const content = `## Notes 225 | 226 | Important: Remember to follow up on this! 227 | 228 | [Visit Link](https://example.com) 229 | 230 | [View in Hoarder](https://hoarder.com/dashboard/preview/abc123)`; 231 | 232 | expect(extractNotesSection(content)).toBe("Important: Remember to follow up on this!"); 233 | }); 234 | }); 235 | 236 | describe("unicode and international content", () => { 237 | it("should handle notes with unicode", () => { 238 | const content = "## Notes\n\n日本語のノート 📝\n\n## Summary"; 239 | expect(extractNotesSection(content)).toBe("日本語のノート 📝"); 240 | }); 241 | 242 | it("should handle notes with emojis", () => { 243 | const content = "## Notes\n\n🚀 Great article! 👍\n\n## Summary"; 244 | expect(extractNotesSection(content)).toBe("🚀 Great article! 👍"); 245 | }); 246 | 247 | it("should handle mixed languages", () => { 248 | const content = "## Notes\n\nEnglish and 中文 mixed\n\n## Summary"; 249 | expect(extractNotesSection(content)).toBe("English and 中文 mixed"); 250 | }); 251 | }); 252 | }); 253 | -------------------------------------------------------------------------------- /src/formatting-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { escapeMarkdownPath, escapeYaml } from "./formatting-utils"; 2 | 3 | describe("escapeYaml", () => { 4 | describe("null and empty handling", () => { 5 | it("should return empty string for null", () => { 6 | expect(escapeYaml(null)).toBe(""); 7 | }); 8 | 9 | it("should return empty string for undefined", () => { 10 | expect(escapeYaml(undefined)).toBe(""); 11 | }); 12 | 13 | it("should return empty string for empty string", () => { 14 | expect(escapeYaml("")).toBe(""); 15 | }); 16 | }); 17 | 18 | describe("simple strings", () => { 19 | it("should return plain strings unchanged", () => { 20 | expect(escapeYaml("hello")).toBe("hello"); 21 | expect(escapeYaml("simple text")).toBe("simple text"); 22 | expect(escapeYaml("text-with-dashes")).toBe("text-with-dashes"); 23 | }); 24 | 25 | it("should handle numbers as strings", () => { 26 | expect(escapeYaml("123")).toBe("123"); 27 | expect(escapeYaml("3.14")).toBe("3.14"); 28 | }); 29 | }); 30 | 31 | describe("newline handling", () => { 32 | it("should use block scalar for strings with newlines", () => { 33 | const input = "line one\nline two"; 34 | const expected = "|\n line one\n line two"; 35 | expect(escapeYaml(input)).toBe(expected); 36 | }); 37 | 38 | it("should indent all lines in block scalar", () => { 39 | const input = "first\nsecond\nthird"; 40 | const expected = "|\n first\n second\n third"; 41 | expect(escapeYaml(input)).toBe(expected); 42 | }); 43 | 44 | it("should handle multiple consecutive newlines", () => { 45 | const input = "line\n\n\nline"; 46 | const expected = "|\n line\n \n \n line"; 47 | expect(escapeYaml(input)).toBe(expected); 48 | }); 49 | }); 50 | 51 | describe("special YAML characters", () => { 52 | it("should use block scalar for colons", () => { 53 | expect(escapeYaml("key: value")).toMatch(/^\|/); 54 | }); 55 | 56 | it("should use block scalar for hash/pound signs", () => { 57 | expect(escapeYaml("text # comment")).toMatch(/^\|/); 58 | }); 59 | 60 | it("should use block scalar for curly braces", () => { 61 | expect(escapeYaml("text {data}")).toMatch(/^\|/); 62 | }); 63 | 64 | it("should use block scalar for square brackets", () => { 65 | expect(escapeYaml("text [data]")).toMatch(/^\|/); 66 | }); 67 | 68 | it("should use block scalar for commas", () => { 69 | expect(escapeYaml("one, two, three")).toMatch(/^\|/); 70 | }); 71 | 72 | it("should use block scalar for ampersands", () => { 73 | expect(escapeYaml("text & more")).toMatch(/^\|/); 74 | }); 75 | 76 | it("should use block scalar for asterisks", () => { 77 | expect(escapeYaml("*important*")).toMatch(/^\|/); 78 | }); 79 | 80 | it("should use block scalar for question marks", () => { 81 | expect(escapeYaml("is this? yes")).toMatch(/^\|/); 82 | }); 83 | 84 | it("should use block scalar for pipes", () => { 85 | expect(escapeYaml("text | more")).toMatch(/^\|/); 86 | }); 87 | 88 | it("should use block scalar for angle brackets", () => { 89 | expect(escapeYaml("<html>")).toMatch(/^\|/); 90 | expect(escapeYaml("text > more")).toMatch(/^\|/); 91 | }); 92 | 93 | it("should use block scalar for equals signs", () => { 94 | expect(escapeYaml("x = y")).toMatch(/^\|/); 95 | }); 96 | 97 | it("should use block scalar for exclamation marks", () => { 98 | expect(escapeYaml("text!")).toMatch(/^\|/); 99 | }); 100 | 101 | it("should use block scalar for percent signs", () => { 102 | expect(escapeYaml("100%")).toMatch(/^\|/); 103 | }); 104 | 105 | it("should use block scalar for at signs", () => { 106 | expect(escapeYaml("user@example.com")).toMatch(/^\|/); 107 | }); 108 | 109 | it("should use block scalar for backticks", () => { 110 | expect(escapeYaml("`code`")).toMatch(/^\|/); 111 | }); 112 | }); 113 | 114 | describe("quote handling", () => { 115 | it("should wrap in single quotes if string contains double quotes", () => { 116 | expect(escapeYaml('text "quoted" text')).toBe("'text \"quoted\" text'"); 117 | }); 118 | 119 | it("should wrap in double quotes and escape if string contains single quotes", () => { 120 | expect(escapeYaml("text's here")).toBe('"text\'s here"'); 121 | }); 122 | 123 | it("should handle leading/trailing spaces with quotes", () => { 124 | expect(escapeYaml(" text ")).toBe('" text "'); 125 | }); 126 | 127 | it("should handle leading tabs with quotes", () => { 128 | expect(escapeYaml("\ttext")).toBe('"\ttext"'); 129 | }); 130 | 131 | it("should handle trailing tabs with quotes", () => { 132 | expect(escapeYaml("text\t")).toBe('"text\t"'); 133 | }); 134 | }); 135 | 136 | describe("complex cases", () => { 137 | it("should handle URLs", () => { 138 | expect(escapeYaml("https://example.com/path?query=value")).toMatch(/^\|/); 139 | }); 140 | 141 | it("should handle email addresses", () => { 142 | expect(escapeYaml("user@example.com")).toMatch(/^\|/); 143 | }); 144 | 145 | it("should handle markdown formatting", () => { 146 | expect(escapeYaml("**bold** and *italic*")).toMatch(/^\|/); 147 | }); 148 | 149 | it("should handle JSON-like strings", () => { 150 | expect(escapeYaml('{"key": "value"}')).toMatch(/^\|/); 151 | }); 152 | 153 | it("should handle code snippets", () => { 154 | const code = "function test() {\n return true;\n}"; 155 | const result = escapeYaml(code); 156 | expect(result).toMatch(/^\|/); 157 | expect(result).toContain("function test()"); 158 | }); 159 | }); 160 | 161 | describe("real-world examples", () => { 162 | it("should handle article titles", () => { 163 | expect(escapeYaml("How to Build a REST API")).toBe("How to Build a REST API"); 164 | }); 165 | 166 | it("should handle titles with colons", () => { 167 | expect(escapeYaml("React: A JavaScript Library")).toMatch(/^\|/); 168 | }); 169 | 170 | it("should handle quoted speech", () => { 171 | // Contains '!' which is a YAML special character, so uses block scalar 172 | const result = escapeYaml('"Hello, World!" he said'); 173 | expect(result).toMatch(/^\|/); 174 | expect(result).toContain('"Hello, World!" he said'); 175 | }); 176 | 177 | it("should handle book titles with apostrophes", () => { 178 | expect(escapeYaml("The Programmer's Guide")).toBe('"The Programmer\'s Guide"'); 179 | }); 180 | 181 | it("should handle descriptions with special chars", () => { 182 | const desc = "Learn React, Vue & Angular in 2024!"; 183 | expect(escapeYaml(desc)).toMatch(/^\|/); 184 | }); 185 | }); 186 | }); 187 | 188 | describe("escapeMarkdownPath", () => { 189 | describe("simple paths", () => { 190 | it("should return simple paths unchanged", () => { 191 | expect(escapeMarkdownPath("https://example.com")).toBe("https://example.com"); 192 | expect(escapeMarkdownPath("/path/to/file")).toBe("/path/to/file"); 193 | }); 194 | 195 | it("should handle paths with dashes and underscores", () => { 196 | expect(escapeMarkdownPath("https://example.com/my-page_name")).toBe( 197 | "https://example.com/my-page_name" 198 | ); 199 | }); 200 | 201 | it("should handle query parameters without spaces", () => { 202 | expect(escapeMarkdownPath("https://example.com?query=value")).toBe( 203 | "https://example.com?query=value" 204 | ); 205 | }); 206 | }); 207 | 208 | describe("paths with spaces", () => { 209 | it("should wrap paths with spaces in angle brackets", () => { 210 | expect(escapeMarkdownPath("path with spaces")).toBe("<path with spaces>"); 211 | }); 212 | 213 | it("should wrap URLs with spaces in angle brackets", () => { 214 | expect(escapeMarkdownPath("https://example.com/my page")).toBe( 215 | "<https://example.com/my page>" 216 | ); 217 | }); 218 | 219 | it("should wrap file paths with spaces", () => { 220 | expect(escapeMarkdownPath("/path/to/my file.pdf")).toBe("</path/to/my file.pdf>"); 221 | }); 222 | }); 223 | 224 | describe("paths with special characters", () => { 225 | it("should wrap paths with angle brackets", () => { 226 | expect(escapeMarkdownPath("<path>")).toBe("<<path>>"); 227 | }); 228 | 229 | it("should wrap paths with square brackets", () => { 230 | expect(escapeMarkdownPath("path[test]")).toBe("<path[test]>"); 231 | }); 232 | 233 | it("should wrap paths with parentheses", () => { 234 | expect(escapeMarkdownPath("path(test)")).toBe("<path(test)>"); 235 | }); 236 | 237 | it("should wrap paths with curly braces", () => { 238 | expect(escapeMarkdownPath("path{test}")).toBe("<path{test}>"); 239 | }); 240 | }); 241 | 242 | describe("real-world examples", () => { 243 | it("should handle normal URLs", () => { 244 | expect(escapeMarkdownPath("https://github.com/user/repo")).toBe( 245 | "https://github.com/user/repo" 246 | ); 247 | }); 248 | 249 | it("should handle URLs with query parameters", () => { 250 | expect(escapeMarkdownPath("https://example.com?id=123&name=test")).toBe( 251 | "https://example.com?id=123&name=test" 252 | ); 253 | }); 254 | 255 | it("should handle local file paths", () => { 256 | expect(escapeMarkdownPath("./local/file.pdf")).toBe("./local/file.pdf"); 257 | }); 258 | 259 | it("should handle Windows paths with spaces", () => { 260 | expect(escapeMarkdownPath("C:\\Program Files\\App\\file.exe")).toBe( 261 | "<C:\\Program Files\\App\\file.exe>" 262 | ); 263 | }); 264 | 265 | it("should handle SharePoint URLs with spaces", () => { 266 | expect(escapeMarkdownPath("https://sharepoint.com/sites/My Site/Documents/File.docx")).toBe( 267 | "<https://sharepoint.com/sites/My Site/Documents/File.docx>" 268 | ); 269 | }); 270 | 271 | it("should handle Obsidian wikilinks", () => { 272 | expect(escapeMarkdownPath("[[My Note]]")).toBe("<[[My Note]]>"); 273 | }); 274 | }); 275 | 276 | describe("edge cases", () => { 277 | it("should handle empty string", () => { 278 | expect(escapeMarkdownPath("")).toBe(""); 279 | }); 280 | 281 | it("should handle single character", () => { 282 | expect(escapeMarkdownPath("a")).toBe("a"); 283 | }); 284 | 285 | it("should handle only spaces", () => { 286 | expect(escapeMarkdownPath(" ")).toBe("< >"); 287 | }); 288 | 289 | it("should handle unicode characters", () => { 290 | expect(escapeMarkdownPath("https://example.com/日本語")).toBe("https://example.com/日本語"); 291 | }); 292 | 293 | it("should handle unicode with spaces", () => { 294 | expect(escapeMarkdownPath("https://example.com/日本語 page")).toBe( 295 | "<https://example.com/日本語 page>" 296 | ); 297 | }); 298 | }); 299 | }); 300 | -------------------------------------------------------------------------------- /src/asset-handler.ts: -------------------------------------------------------------------------------- 1 | import { App } from "obsidian"; 2 | 3 | import { HoarderApiClient, HoarderBookmark } from "./hoarder-client"; 4 | import { HoarderSettings } from "./settings"; 5 | 6 | export type AssetFrontmatter = { 7 | image?: string; // wikilink [[path]] 8 | banner?: string; // wikilink [[path]] 9 | screenshot?: string; // wikilink [[path]] 10 | full_page_archive?: string; // wikilink [[path]] 11 | video?: string; // wikilink [[path]] (only if downloaded, typically omitted) 12 | additional?: string[]; // array of wikilinks 13 | }; 14 | 15 | function getAssetUrl( 16 | assetId: string, 17 | client: HoarderApiClient | null, 18 | settings: HoarderSettings 19 | ): string { 20 | if (client) { 21 | return client.getAssetUrl(assetId); 22 | } 23 | // Fallback if client is not initialized 24 | const baseUrl = settings.apiEndpoint.replace(/\/v1\/?$/, ""); 25 | return `${baseUrl}/assets/${assetId}`; 26 | } 27 | 28 | function sanitizeAssetFileName(title: string): string { 29 | // Sanitize the title 30 | let sanitizedTitle = title 31 | .replace(/[\\/:*?"<>|]/g, "-") // Replace invalid characters with dash 32 | .replace(/\s+/g, "-") // Replace spaces with dash 33 | .replace(/-+/g, "-") // Replace multiple dashes with single dash 34 | .replace(/^-|-$/g, ""); // Remove dashes from start and end 35 | 36 | // Use a shorter max length for asset filenames 37 | const maxTitleLength = 30; 38 | 39 | if (sanitizedTitle.length > maxTitleLength) { 40 | // If title is too long, try to cut at a word boundary 41 | const truncated = sanitizedTitle.substring(0, maxTitleLength); 42 | const lastDash = truncated.lastIndexOf("-"); 43 | if (lastDash > maxTitleLength / 2) { 44 | // If we can find a reasonable word break, use it 45 | sanitizedTitle = truncated.substring(0, lastDash); 46 | } else { 47 | // Otherwise just truncate 48 | sanitizedTitle = truncated; 49 | } 50 | } 51 | 52 | return sanitizedTitle; 53 | } 54 | 55 | async function downloadImage( 56 | app: App, 57 | url: string, 58 | assetId: string, 59 | title: string, 60 | client: HoarderApiClient | null, 61 | settings: HoarderSettings 62 | ): Promise<string | null> { 63 | try { 64 | // Create attachments folder if it doesn't exist 65 | if (!(await app.vault.adapter.exists(settings.attachmentsFolder))) { 66 | await app.vault.createFolder(settings.attachmentsFolder); 67 | } 68 | 69 | // Get file extension from URL or default to jpg 70 | const extension = url.split(".").pop()?.toLowerCase() || "jpg"; 71 | const safeExtension = ["jpg", "jpeg", "png", "gif", "webp"].includes(extension) 72 | ? extension 73 | : "jpg"; 74 | 75 | // Create a safe filename using just the assetId and a short title 76 | const safeTitle = sanitizeAssetFileName(title); 77 | const fileName = `${assetId}${safeTitle ? "-" + safeTitle : ""}.${safeExtension}`; 78 | const filePath = `${settings.attachmentsFolder}/${fileName}`; 79 | 80 | // Check if file already exists with any extension 81 | const files = await app.vault.adapter.list(settings.attachmentsFolder); 82 | const existingFile = files.files.find((filePathItem: string) => 83 | filePathItem.startsWith(`${settings.attachmentsFolder}/${assetId}`) 84 | ); 85 | if (existingFile) { 86 | return existingFile; 87 | } 88 | 89 | // Download the image 90 | let buffer: ArrayBuffer; 91 | 92 | // Check if this is a Hoarder asset URL by checking if it's from the same domain 93 | const apiDomain = new URL(settings.apiEndpoint).origin; 94 | if (url.startsWith(apiDomain) && client) { 95 | // Use the client's downloadAsset method for Hoarder assets 96 | buffer = await client.downloadAsset(assetId); 97 | } else { 98 | // Use fetch for external URLs 99 | const headers: Record<string, string> = {}; 100 | if (url.startsWith(apiDomain)) { 101 | headers["Authorization"] = `Bearer ${settings.apiKey}`; 102 | } 103 | 104 | const response = await fetch(url, { headers }); 105 | if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); 106 | 107 | buffer = await response.arrayBuffer(); 108 | } 109 | await app.vault.adapter.writeBinary(filePath, buffer); 110 | 111 | return filePath; 112 | } catch (error) { 113 | console.error("Error downloading image:", url, error); 114 | return null; 115 | } 116 | } 117 | 118 | function escapeMarkdownPath(path: string): string { 119 | // If path contains spaces or other special characters, wrap in angle brackets 120 | if (path.includes(" ") || /[<>\[\](){}]/.test(path)) { 121 | return `<${path}>`; 122 | } 123 | return path; 124 | } 125 | 126 | const toWikilink = (path: string): string => `"[[${path}]]"`; 127 | 128 | export async function processBookmarkAssets( 129 | app: App, 130 | bookmark: HoarderBookmark, 131 | title: string, 132 | client: HoarderApiClient | null, 133 | settings: HoarderSettings 134 | ): Promise<{ content: string; frontmatter: AssetFrontmatter | null }> { 135 | let content = ""; 136 | const fm: AssetFrontmatter = {}; 137 | 138 | // Handle images for asset type bookmarks 139 | if (bookmark.content.type === "asset" && bookmark.content.assetType === "image") { 140 | if (bookmark.content.assetId) { 141 | const assetUrl = getAssetUrl(bookmark.content.assetId, client, settings); 142 | let imagePath: string | null = null; 143 | if (settings.downloadAssets) { 144 | imagePath = await downloadImage( 145 | app, 146 | assetUrl, 147 | bookmark.content.assetId, 148 | title, 149 | client, 150 | settings 151 | ); 152 | } 153 | if (imagePath) { 154 | content += `\n![${title}](${escapeMarkdownPath(imagePath)})\n`; 155 | fm.image = toWikilink(imagePath); 156 | } else { 157 | content += `\n![${title}](${escapeMarkdownPath(assetUrl)})\n`; 158 | } 159 | } else if (bookmark.content.sourceUrl) { 160 | content += `\n![${title}](${escapeMarkdownPath(bookmark.content.sourceUrl)})\n`; 161 | // No local path -> no wikilink in frontmatter 162 | } 163 | } else if (bookmark.content.type === "link") { 164 | // For link types, handle all available assets 165 | const assetIds: string[] = []; 166 | const assetLabels: string[] = []; 167 | 168 | // Collect all asset IDs and their labels 169 | if (bookmark.content.imageAssetId) { 170 | assetIds.push(bookmark.content.imageAssetId); 171 | assetLabels.push("Banner Image"); 172 | } 173 | if (bookmark.content.screenshotAssetId) { 174 | assetIds.push(bookmark.content.screenshotAssetId); 175 | assetLabels.push("Screenshot"); 176 | } 177 | if (bookmark.content.fullPageArchiveAssetId) { 178 | assetIds.push(bookmark.content.fullPageArchiveAssetId); 179 | assetLabels.push("Full Page Archive"); 180 | } 181 | if (bookmark.content.videoAssetId) { 182 | assetIds.push(bookmark.content.videoAssetId); 183 | assetLabels.push("Video"); 184 | } 185 | 186 | // Handle each asset 187 | for (let i = 0; i < assetIds.length; i++) { 188 | const assetId = assetIds[i]; 189 | const label = assetLabels[i]; 190 | const assetUrl = getAssetUrl(assetId, client, settings); 191 | 192 | // Handle videos differently - just embed as links, don't download 193 | if (label === "Video") { 194 | content += `\n[${title} - ${label}](${escapeMarkdownPath(assetUrl)})\n`; 195 | // Not downloaded -> no wikilink in frontmatter 196 | } else { 197 | // Handle images normally 198 | let imagePath: string | null = null; 199 | if (settings.downloadAssets) { 200 | imagePath = await downloadImage( 201 | app, 202 | assetUrl, 203 | assetId, 204 | `${title}-${label}`, 205 | client, 206 | settings 207 | ); 208 | } 209 | if (imagePath) { 210 | content += `\n![${title} - ${label}](${escapeMarkdownPath(imagePath)})\n`; 211 | if (label === "Banner Image") { 212 | fm.banner = toWikilink(imagePath); 213 | } else if (label === "Screenshot") { 214 | fm.screenshot = toWikilink(imagePath); 215 | } else if (label === "Full Page Archive") { 216 | fm.full_page_archive = toWikilink(imagePath); 217 | } 218 | } else { 219 | content += `\n![${title} - ${label}](${escapeMarkdownPath(assetUrl)})\n`; 220 | } 221 | } 222 | } 223 | 224 | // Handle external image URL if no asset IDs but imageUrl exists 225 | if (assetIds.length === 0 && bookmark.content.imageUrl) { 226 | content += `\n![${title}](${escapeMarkdownPath(bookmark.content.imageUrl)})\n`; 227 | // No local path -> no wikilink in frontmatter 228 | } 229 | } 230 | 231 | // Handle any additional assets from the bookmark.assets array 232 | if (bookmark.assets && bookmark.assets.length > 0) { 233 | const processedAssetIds = new Set<string>(); 234 | 235 | // Track which assets we've already processed from content fields 236 | if (bookmark.content.type === "asset" && bookmark.content.assetId) { 237 | processedAssetIds.add(bookmark.content.assetId); 238 | } 239 | if (bookmark.content.type === "link") { 240 | if (bookmark.content.imageAssetId) processedAssetIds.add(bookmark.content.imageAssetId); 241 | if (bookmark.content.screenshotAssetId) 242 | processedAssetIds.add(bookmark.content.screenshotAssetId); 243 | if (bookmark.content.fullPageArchiveAssetId) 244 | processedAssetIds.add(bookmark.content.fullPageArchiveAssetId); 245 | if (bookmark.content.videoAssetId) processedAssetIds.add(bookmark.content.videoAssetId); 246 | } 247 | 248 | // Process any remaining assets 249 | for (const asset of bookmark.assets) { 250 | if (!processedAssetIds.has(asset.id)) { 251 | const assetUrl = getAssetUrl(asset.id, client, settings); 252 | const label = asset.assetType === "image" ? "Additional Image" : asset.assetType; 253 | 254 | if (asset.assetType === "video") { 255 | content += `\n[${title} - ${label}](${escapeMarkdownPath(assetUrl)})\n`; 256 | // Not downloaded -> no wikilink in frontmatter 257 | } else { 258 | let imagePath: string | null = null; 259 | if (settings.downloadAssets) { 260 | imagePath = await downloadImage( 261 | app, 262 | assetUrl, 263 | asset.id, 264 | `${title}-${label}`, 265 | client, 266 | settings 267 | ); 268 | } 269 | if (imagePath) { 270 | content += `\n![${title} - ${label}](${escapeMarkdownPath(imagePath)})\n`; 271 | fm.additional = fm.additional || []; 272 | fm.additional.push(toWikilink(imagePath)); 273 | } else { 274 | content += `\n![${title} - ${label}](${escapeMarkdownPath(assetUrl)})\n`; 275 | } 276 | } 277 | } 278 | } 279 | } 280 | 281 | return { content, frontmatter: Object.keys(fm).length > 0 ? fm : null }; 282 | } 283 | -------------------------------------------------------------------------------- /src/bookmark-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { getBookmarkTitle } from "./bookmark-utils"; 2 | import { HoarderBookmark } from "./hoarder-client"; 3 | 4 | describe("getBookmarkTitle", () => { 5 | const baseBookmark = { 6 | id: "test-id-123", 7 | createdAt: "2024-01-15T10:30:00.000Z", 8 | tags: [], 9 | } as HoarderBookmark; 10 | 11 | describe("main title priority", () => { 12 | it("should use bookmark.title if present", () => { 13 | const bookmark = { 14 | ...baseBookmark, 15 | title: "Main Title", 16 | content: { type: "link" as const, url: "https://example.com", title: "Content Title" }, 17 | } as HoarderBookmark; 18 | expect(getBookmarkTitle(bookmark)).toBe("Main Title"); 19 | }); 20 | 21 | it("should prefer bookmark.title over content.title", () => { 22 | const bookmark = { 23 | ...baseBookmark, 24 | title: "Bookmark Title", 25 | content: { type: "link" as const, url: "https://example.com", title: "Content Title" }, 26 | } as HoarderBookmark; 27 | expect(getBookmarkTitle(bookmark)).toBe("Bookmark Title"); 28 | }); 29 | }); 30 | 31 | describe("link bookmarks", () => { 32 | it("should use content.title for links", () => { 33 | const bookmark = { 34 | ...baseBookmark, 35 | content: { type: "link" as const, url: "https://example.com", title: "Article Title" }, 36 | } as HoarderBookmark; 37 | expect(getBookmarkTitle(bookmark)).toBe("Article Title"); 38 | }); 39 | 40 | it("should extract title from URL pathname", () => { 41 | const bookmark = { 42 | ...baseBookmark, 43 | content: { type: "link" as const, url: "https://example.com/my-article" }, 44 | } as HoarderBookmark; 45 | expect(getBookmarkTitle(bookmark)).toBe("my article"); 46 | }); 47 | 48 | it("should remove file extensions from URL paths", () => { 49 | const bookmark = { 50 | ...baseBookmark, 51 | content: { type: "link" as const, url: "https://example.com/article.html" }, 52 | } as HoarderBookmark; 53 | expect(getBookmarkTitle(bookmark)).toBe("article"); 54 | }); 55 | 56 | it("should replace dashes with spaces in URL paths", () => { 57 | const bookmark = { 58 | ...baseBookmark, 59 | content: { type: "link" as const, url: "https://example.com/my-great-article" }, 60 | } as HoarderBookmark; 61 | expect(getBookmarkTitle(bookmark)).toBe("my great article"); 62 | }); 63 | 64 | it("should replace underscores with spaces in URL paths", () => { 65 | const bookmark = { 66 | ...baseBookmark, 67 | content: { type: "link" as const, url: "https://example.com/my_great_article" }, 68 | } as HoarderBookmark; 69 | expect(getBookmarkTitle(bookmark)).toBe("my great article"); 70 | }); 71 | 72 | it("should use hostname when path is empty", () => { 73 | const bookmark = { 74 | ...baseBookmark, 75 | content: { type: "link" as const, url: "https://example.com/" }, 76 | } as HoarderBookmark; 77 | expect(getBookmarkTitle(bookmark)).toBe("example.com"); 78 | }); 79 | 80 | it("should remove www. prefix from hostname", () => { 81 | const bookmark = { 82 | ...baseBookmark, 83 | content: { type: "link" as const, url: "https://www.example.com/" }, 84 | } as HoarderBookmark; 85 | expect(getBookmarkTitle(bookmark)).toBe("example.com"); 86 | }); 87 | 88 | it("should handle URLs with query parameters", () => { 89 | const bookmark = { 90 | ...baseBookmark, 91 | content: { type: "link" as const, url: "https://example.com/article?id=123" }, 92 | } as HoarderBookmark; 93 | expect(getBookmarkTitle(bookmark)).toBe("article"); 94 | }); 95 | 96 | it("should handle URLs with fragments", () => { 97 | const bookmark = { 98 | ...baseBookmark, 99 | content: { type: "link" as const, url: "https://example.com/article#section" }, 100 | } as HoarderBookmark; 101 | expect(getBookmarkTitle(bookmark)).toBe("article"); 102 | }); 103 | 104 | it("should return URL as-is if parsing fails", () => { 105 | const bookmark = { 106 | ...baseBookmark, 107 | content: { type: "link" as const, url: "not-a-valid-url" }, 108 | } as HoarderBookmark; 109 | expect(getBookmarkTitle(bookmark)).toBe("not-a-valid-url"); 110 | }); 111 | }); 112 | 113 | describe("text bookmarks", () => { 114 | it("should use first line for short text", () => { 115 | const bookmark = { 116 | ...baseBookmark, 117 | content: { type: "text" as const, text: "This is a short note" }, 118 | } as HoarderBookmark; 119 | expect(getBookmarkTitle(bookmark)).toBe("This is a short note"); 120 | }); 121 | 122 | it("should use first line for multi-line text", () => { 123 | const bookmark = { 124 | ...baseBookmark, 125 | content: { 126 | type: "text" as const, 127 | text: "First line\nSecond line\nThird line", 128 | }, 129 | } as HoarderBookmark; 130 | expect(getBookmarkTitle(bookmark)).toBe("First line"); 131 | }); 132 | 133 | it("should truncate long first lines to 100 chars", () => { 134 | const longText = "a".repeat(150); 135 | const bookmark = { 136 | ...baseBookmark, 137 | content: { type: "text" as const, text: longText }, 138 | } as HoarderBookmark; 139 | const result = getBookmarkTitle(bookmark); 140 | expect(result).toBe("a".repeat(97) + "..."); 141 | expect(result.length).toBe(100); 142 | }); 143 | 144 | it("should not truncate text at exactly 100 chars", () => { 145 | const text = "a".repeat(100); 146 | const bookmark = { 147 | ...baseBookmark, 148 | content: { type: "text" as const, text }, 149 | } as HoarderBookmark; 150 | expect(getBookmarkTitle(bookmark)).toBe(text); 151 | }); 152 | 153 | it("should handle text with 99 chars", () => { 154 | const text = "a".repeat(99); 155 | const bookmark = { 156 | ...baseBookmark, 157 | content: { type: "text" as const, text }, 158 | } as HoarderBookmark; 159 | expect(getBookmarkTitle(bookmark)).toBe(text); 160 | }); 161 | 162 | it("should handle empty text", () => { 163 | const bookmark = { 164 | ...baseBookmark, 165 | content: { type: "text" as const, text: "" }, 166 | } as HoarderBookmark; 167 | expect(getBookmarkTitle(bookmark)).toBe("Bookmark-test-id-123-2024-01-15"); 168 | }); 169 | }); 170 | 171 | describe("asset bookmarks", () => { 172 | it("should use fileName without extension", () => { 173 | const bookmark = { 174 | ...baseBookmark, 175 | content: { 176 | type: "asset" as const, 177 | assetType: "image" as const, 178 | assetId: "asset-123", 179 | fileName: "my-image.png", 180 | }, 181 | } as HoarderBookmark; 182 | expect(getBookmarkTitle(bookmark)).toBe("my-image"); 183 | }); 184 | 185 | it("should handle filenames with multiple dots", () => { 186 | const bookmark = { 187 | ...baseBookmark, 188 | content: { 189 | type: "asset" as const, 190 | assetType: "image" as const, 191 | assetId: "asset-123", 192 | fileName: "my.file.name.jpg", 193 | }, 194 | } as HoarderBookmark; 195 | expect(getBookmarkTitle(bookmark)).toBe("my.file.name"); 196 | }); 197 | 198 | it("should extract title from sourceUrl if no fileName", () => { 199 | const bookmark = { 200 | ...baseBookmark, 201 | content: { 202 | type: "asset" as const, 203 | assetType: "image" as const, 204 | assetId: "asset-123", 205 | sourceUrl: "https://example.com/image.jpg", 206 | }, 207 | } as HoarderBookmark; 208 | expect(getBookmarkTitle(bookmark)).toBe("image"); 209 | }); 210 | 211 | it("should prefer fileName over sourceUrl", () => { 212 | const bookmark = { 213 | ...baseBookmark, 214 | content: { 215 | type: "asset" as const, 216 | assetType: "image" as const, 217 | assetId: "asset-123", 218 | fileName: "my-image.png", 219 | sourceUrl: "https://example.com/other-image.jpg", 220 | }, 221 | } as HoarderBookmark; 222 | expect(getBookmarkTitle(bookmark)).toBe("my-image"); 223 | }); 224 | }); 225 | 226 | describe("fallback behavior", () => { 227 | it("should use fallback format when no title sources available", () => { 228 | const bookmark = { 229 | ...baseBookmark, 230 | content: { type: "link" as const }, 231 | } as HoarderBookmark; 232 | expect(getBookmarkTitle(bookmark)).toBe("Bookmark-test-id-123-2024-01-15"); 233 | }); 234 | 235 | it("should format fallback date correctly", () => { 236 | const bookmark = { 237 | ...baseBookmark, 238 | id: "abc-123", 239 | createdAt: "2024-12-31T23:59:59.999Z", 240 | content: { type: "link" as const }, 241 | } as HoarderBookmark; 242 | expect(getBookmarkTitle(bookmark)).toBe("Bookmark-abc-123-2024-12-31"); 243 | }); 244 | }); 245 | 246 | describe("real-world examples", () => { 247 | it("should handle GitHub URLs", () => { 248 | const bookmark = { 249 | ...baseBookmark, 250 | content: { 251 | type: "link" as const, 252 | url: "https://github.com/user/repo", 253 | title: "user/repo: Description", 254 | }, 255 | } as HoarderBookmark; 256 | expect(getBookmarkTitle(bookmark)).toBe("user/repo: Description"); 257 | }); 258 | 259 | it("should handle blog post URLs", () => { 260 | const bookmark = { 261 | ...baseBookmark, 262 | content: { 263 | type: "link" as const, 264 | url: "https://blog.example.com/2024/01/my-post", 265 | }, 266 | } as HoarderBookmark; 267 | expect(getBookmarkTitle(bookmark)).toBe("my post"); 268 | }); 269 | 270 | it("should handle Wikipedia URLs", () => { 271 | const bookmark = { 272 | ...baseBookmark, 273 | content: { 274 | type: "link" as const, 275 | url: "https://en.wikipedia.org/wiki/Machine_learning", 276 | }, 277 | } as HoarderBookmark; 278 | expect(getBookmarkTitle(bookmark)).toBe("Machine learning"); 279 | }); 280 | 281 | it("should handle YouTube URLs", () => { 282 | const bookmark = { 283 | ...baseBookmark, 284 | content: { 285 | type: "link" as const, 286 | url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", 287 | }, 288 | } as HoarderBookmark; 289 | // YouTube URLs have query params, so it extracts "watch" from the path 290 | expect(getBookmarkTitle(bookmark)).toBe("watch"); 291 | }); 292 | 293 | it("should handle documentation URLs", () => { 294 | const bookmark = { 295 | ...baseBookmark, 296 | content: { 297 | type: "link" as const, 298 | url: "https://docs.example.com/guides/getting-started.html", 299 | }, 300 | } as HoarderBookmark; 301 | expect(getBookmarkTitle(bookmark)).toBe("getting started"); 302 | }); 303 | 304 | it("should handle quote notes", () => { 305 | const bookmark = { 306 | ...baseBookmark, 307 | content: { 308 | type: "text" as const, 309 | text: '"The only way to do great work is to love what you do." - Steve Jobs', 310 | }, 311 | } as HoarderBookmark; 312 | expect(getBookmarkTitle(bookmark)).toBe( 313 | '"The only way to do great work is to love what you do." - Steve Jobs' 314 | ); 315 | }); 316 | 317 | it("should handle todo list text", () => { 318 | const bookmark = { 319 | ...baseBookmark, 320 | content: { 321 | type: "text" as const, 322 | text: "- [ ] Buy groceries\n- [ ] Walk the dog\n- [ ] Write report", 323 | }, 324 | } as HoarderBookmark; 325 | expect(getBookmarkTitle(bookmark)).toBe("- [ ] Buy groceries"); 326 | }); 327 | }); 328 | }); 329 | -------------------------------------------------------------------------------- /src/message-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { SyncStats, buildSyncMessage } from "./message-utils"; 2 | 3 | describe("buildSyncMessage", () => { 4 | const baseStats: SyncStats = { 5 | totalBookmarks: 0, 6 | skippedFiles: 0, 7 | updatedInHoarder: 0, 8 | excludedByTags: 0, 9 | includedByTags: 0, 10 | includedTagsEnabled: false, 11 | skippedNoHighlights: 0, 12 | deletionResults: { 13 | deleted: 0, 14 | archived: 0, 15 | tagged: 0, 16 | archivedHandled: 0, 17 | }, 18 | }; 19 | 20 | describe("basic sync results", () => { 21 | it("should handle zero bookmarks", () => { 22 | expect(buildSyncMessage(baseStats)).toBe("Successfully synced 0 bookmarks"); 23 | }); 24 | 25 | it("should handle one bookmark (singular)", () => { 26 | const stats = { ...baseStats, totalBookmarks: 1 }; 27 | expect(buildSyncMessage(stats)).toBe("Successfully synced 1 bookmark"); 28 | }); 29 | 30 | it("should handle multiple bookmarks (plural)", () => { 31 | const stats = { ...baseStats, totalBookmarks: 5 }; 32 | expect(buildSyncMessage(stats)).toBe("Successfully synced 5 bookmarks"); 33 | }); 34 | 35 | it("should handle large numbers", () => { 36 | const stats = { ...baseStats, totalBookmarks: 1234 }; 37 | expect(buildSyncMessage(stats)).toBe("Successfully synced 1234 bookmarks"); 38 | }); 39 | }); 40 | 41 | describe("skipped files", () => { 42 | it("should include one skipped file (singular)", () => { 43 | const stats = { ...baseStats, totalBookmarks: 5, skippedFiles: 1 }; 44 | expect(buildSyncMessage(stats)).toBe( 45 | "Successfully synced 5 bookmarks (skipped 1 existing file)" 46 | ); 47 | }); 48 | 49 | it("should include multiple skipped files (plural)", () => { 50 | const stats = { ...baseStats, totalBookmarks: 10, skippedFiles: 7 }; 51 | expect(buildSyncMessage(stats)).toBe( 52 | "Successfully synced 10 bookmarks (skipped 7 existing files)" 53 | ); 54 | }); 55 | 56 | it("should not show skipped files when zero", () => { 57 | const stats = { ...baseStats, totalBookmarks: 5, skippedFiles: 0 }; 58 | expect(buildSyncMessage(stats)).toBe("Successfully synced 5 bookmarks"); 59 | }); 60 | }); 61 | 62 | describe("Hoarder updates", () => { 63 | it("should include one note update (singular)", () => { 64 | const stats = { ...baseStats, totalBookmarks: 3, updatedInHoarder: 1 }; 65 | expect(buildSyncMessage(stats)).toBe( 66 | "Successfully synced 3 bookmarks and updated 1 note in Karakeep" 67 | ); 68 | }); 69 | 70 | it("should include multiple note updates (plural)", () => { 71 | const stats = { ...baseStats, totalBookmarks: 5, updatedInHoarder: 3 }; 72 | expect(buildSyncMessage(stats)).toBe( 73 | "Successfully synced 5 bookmarks and updated 3 notes in Karakeep" 74 | ); 75 | }); 76 | 77 | it("should combine skipped files and note updates", () => { 78 | const stats = { ...baseStats, totalBookmarks: 10, skippedFiles: 5, updatedInHoarder: 2 }; 79 | expect(buildSyncMessage(stats)).toBe( 80 | "Successfully synced 10 bookmarks (skipped 5 existing files) and updated 2 notes in Karakeep" 81 | ); 82 | }); 83 | }); 84 | 85 | describe("tag filtering", () => { 86 | it("should show one excluded bookmark (singular)", () => { 87 | const stats = { ...baseStats, totalBookmarks: 4, excludedByTags: 1 }; 88 | expect(buildSyncMessage(stats)).toBe( 89 | "Successfully synced 4 bookmarks, excluded 1 bookmark by tags" 90 | ); 91 | }); 92 | 93 | it("should show multiple excluded bookmarks (plural)", () => { 94 | const stats = { ...baseStats, totalBookmarks: 10, excludedByTags: 5 }; 95 | expect(buildSyncMessage(stats)).toBe( 96 | "Successfully synced 10 bookmarks, excluded 5 bookmarks by tags" 97 | ); 98 | }); 99 | 100 | it("should show included bookmarks when filter enabled", () => { 101 | const stats = { 102 | ...baseStats, 103 | totalBookmarks: 3, 104 | includedByTags: 3, 105 | includedTagsEnabled: true, 106 | }; 107 | expect(buildSyncMessage(stats)).toBe( 108 | "Successfully synced 3 bookmarks, included 3 bookmarks by tags" 109 | ); 110 | }); 111 | 112 | it("should show one included bookmark (singular)", () => { 113 | const stats = { 114 | ...baseStats, 115 | totalBookmarks: 1, 116 | includedByTags: 1, 117 | includedTagsEnabled: true, 118 | }; 119 | expect(buildSyncMessage(stats)).toBe( 120 | "Successfully synced 1 bookmark, included 1 bookmark by tags" 121 | ); 122 | }); 123 | 124 | it("should not show included count when filter disabled", () => { 125 | const stats = { 126 | ...baseStats, 127 | totalBookmarks: 5, 128 | includedByTags: 5, 129 | includedTagsEnabled: false, 130 | }; 131 | expect(buildSyncMessage(stats)).toBe("Successfully synced 5 bookmarks"); 132 | }); 133 | 134 | it("should show both excluded and included", () => { 135 | const stats = { 136 | ...baseStats, 137 | totalBookmarks: 5, 138 | excludedByTags: 2, 139 | includedByTags: 5, 140 | includedTagsEnabled: true, 141 | }; 142 | expect(buildSyncMessage(stats)).toBe( 143 | "Successfully synced 5 bookmarks, excluded 2 bookmarks by tags, included 5 bookmarks by tags" 144 | ); 145 | }); 146 | }); 147 | 148 | describe("highlight filtering", () => { 149 | it("should show one skipped bookmark without highlights (singular)", () => { 150 | const stats = { ...baseStats, totalBookmarks: 2, skippedNoHighlights: 1 }; 151 | expect(buildSyncMessage(stats)).toBe( 152 | "Successfully synced 2 bookmarks, skipped 1 bookmark without highlights" 153 | ); 154 | }); 155 | 156 | it("should show multiple skipped bookmarks without highlights (plural)", () => { 157 | const stats = { ...baseStats, totalBookmarks: 5, skippedNoHighlights: 3 }; 158 | expect(buildSyncMessage(stats)).toBe( 159 | "Successfully synced 5 bookmarks, skipped 3 bookmarks without highlights" 160 | ); 161 | }); 162 | }); 163 | 164 | describe("deletion results", () => { 165 | it("should show deleted bookmarks", () => { 166 | const stats = { 167 | ...baseStats, 168 | totalBookmarks: 5, 169 | deletionResults: { deleted: 2, archived: 0, tagged: 0, archivedHandled: 0 }, 170 | }; 171 | expect(buildSyncMessage(stats)).toBe( 172 | "Successfully synced 5 bookmarks, processed 2 deleted bookmarks (2 deleted)" 173 | ); 174 | }); 175 | 176 | it("should show archived bookmarks", () => { 177 | const stats = { 178 | ...baseStats, 179 | totalBookmarks: 5, 180 | deletionResults: { deleted: 0, archived: 3, tagged: 0, archivedHandled: 0 }, 181 | }; 182 | expect(buildSyncMessage(stats)).toBe( 183 | "Successfully synced 5 bookmarks, processed 3 deleted bookmarks (3 archived)" 184 | ); 185 | }); 186 | 187 | it("should show tagged bookmarks", () => { 188 | const stats = { 189 | ...baseStats, 190 | totalBookmarks: 5, 191 | deletionResults: { deleted: 0, archived: 0, tagged: 1, archivedHandled: 0 }, 192 | }; 193 | expect(buildSyncMessage(stats)).toBe( 194 | "Successfully synced 5 bookmarks, processed 1 deleted bookmark (1 tagged)" 195 | ); 196 | }); 197 | 198 | it("should combine multiple deletion types", () => { 199 | const stats = { 200 | ...baseStats, 201 | totalBookmarks: 10, 202 | deletionResults: { deleted: 2, archived: 3, tagged: 1, archivedHandled: 0 }, 203 | }; 204 | expect(buildSyncMessage(stats)).toBe( 205 | "Successfully synced 10 bookmarks, processed 6 deleted bookmarks (2 deleted) (3 archived) (1 tagged)" 206 | ); 207 | }); 208 | 209 | it("should show archived handled bookmarks", () => { 210 | const stats = { 211 | ...baseStats, 212 | totalBookmarks: 5, 213 | deletionResults: { deleted: 0, archived: 0, tagged: 0, archivedHandled: 2 }, 214 | }; 215 | expect(buildSyncMessage(stats)).toBe( 216 | "Successfully synced 5 bookmarks, handled 2 archived bookmarks" 217 | ); 218 | }); 219 | 220 | it("should show both deleted and archived handled", () => { 221 | const stats = { 222 | ...baseStats, 223 | totalBookmarks: 10, 224 | deletionResults: { deleted: 1, archived: 0, tagged: 0, archivedHandled: 3 }, 225 | }; 226 | expect(buildSyncMessage(stats)).toBe( 227 | "Successfully synced 10 bookmarks, processed 1 deleted bookmark (1 deleted), handled 3 archived bookmarks" 228 | ); 229 | }); 230 | 231 | it("should use singular for one deleted", () => { 232 | const stats = { 233 | ...baseStats, 234 | totalBookmarks: 5, 235 | deletionResults: { deleted: 1, archived: 0, tagged: 0, archivedHandled: 0 }, 236 | }; 237 | expect(buildSyncMessage(stats)).toContain("1 deleted bookmark"); 238 | }); 239 | 240 | it("should use singular for one archived handled", () => { 241 | const stats = { 242 | ...baseStats, 243 | totalBookmarks: 5, 244 | deletionResults: { deleted: 0, archived: 0, tagged: 0, archivedHandled: 1 }, 245 | }; 246 | expect(buildSyncMessage(stats)).toContain("1 archived bookmark"); 247 | }); 248 | }); 249 | 250 | describe("comprehensive scenarios", () => { 251 | it("should handle all features enabled", () => { 252 | const stats: SyncStats = { 253 | totalBookmarks: 20, 254 | skippedFiles: 10, 255 | updatedInHoarder: 3, 256 | excludedByTags: 5, 257 | includedByTags: 20, 258 | includedTagsEnabled: true, 259 | skippedNoHighlights: 2, 260 | deletionResults: { deleted: 1, archived: 2, tagged: 1, archivedHandled: 1 }, 261 | }; 262 | const message = buildSyncMessage(stats); 263 | expect(message).toContain("20 bookmarks"); 264 | expect(message).toContain("skipped 10 existing files"); 265 | expect(message).toContain("updated 3 notes"); 266 | expect(message).toContain("excluded 5 bookmarks"); 267 | expect(message).toContain("included 20 bookmarks"); 268 | expect(message).toContain("skipped 2 bookmarks without highlights"); 269 | expect(message).toContain("processed 4 deleted bookmarks"); 270 | expect(message).toContain("handled 1 archived bookmark"); 271 | }); 272 | 273 | it("should handle minimal sync (only synced bookmarks)", () => { 274 | const stats = { ...baseStats, totalBookmarks: 1 }; 275 | expect(buildSyncMessage(stats)).toBe("Successfully synced 1 bookmark"); 276 | }); 277 | 278 | it("should handle sync with no new bookmarks but updates", () => { 279 | const stats = { 280 | ...baseStats, 281 | totalBookmarks: 0, 282 | skippedFiles: 5, 283 | updatedInHoarder: 2, 284 | }; 285 | expect(buildSyncMessage(stats)).toBe( 286 | "Successfully synced 0 bookmarks (skipped 5 existing files) and updated 2 notes in Karakeep" 287 | ); 288 | }); 289 | }); 290 | 291 | describe("edge cases", () => { 292 | it("should handle all zeros", () => { 293 | expect(buildSyncMessage(baseStats)).toBe("Successfully synced 0 bookmarks"); 294 | }); 295 | 296 | it("should handle very large numbers", () => { 297 | const stats = { 298 | ...baseStats, 299 | totalBookmarks: 9999, 300 | skippedFiles: 8888, 301 | excludedByTags: 7777, 302 | }; 303 | const message = buildSyncMessage(stats); 304 | expect(message).toContain("9999 bookmarks"); 305 | expect(message).toContain("8888 existing files"); 306 | expect(message).toContain("7777 bookmarks by tags"); 307 | }); 308 | 309 | it("should maintain order of message components", () => { 310 | const stats: SyncStats = { 311 | totalBookmarks: 10, 312 | skippedFiles: 5, 313 | updatedInHoarder: 2, 314 | excludedByTags: 3, 315 | includedByTags: 10, 316 | includedTagsEnabled: true, 317 | skippedNoHighlights: 1, 318 | deletionResults: { deleted: 1, archived: 0, tagged: 0, archivedHandled: 0 }, 319 | }; 320 | const message = buildSyncMessage(stats); 321 | const parts = [ 322 | "Successfully synced", 323 | "skipped", 324 | "updated", 325 | "excluded", 326 | "included", 327 | "without highlights", 328 | "processed", 329 | ]; 330 | let lastIndex = -1; 331 | parts.forEach((part) => { 332 | const index = message.indexOf(part); 333 | expect(index).toBeGreaterThan(lastIndex); 334 | lastIndex = index; 335 | }); 336 | }); 337 | }); 338 | }); 339 | -------------------------------------------------------------------------------- /src/deletion-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DeletionSettings, 3 | FileHandlingInstruction, 4 | countDeletionResults, 5 | determineDeletionActions, 6 | } from "./deletion-handler"; 7 | 8 | describe("determineDeletionActions", () => { 9 | const defaultSettings: DeletionSettings = { 10 | syncDeletions: false, 11 | deletionAction: "delete", 12 | handleArchivedBookmarks: false, 13 | archivedBookmarkAction: "archive", 14 | }; 15 | 16 | describe("disabled features", () => { 17 | it("should return empty array when both features disabled", () => { 18 | const result = determineDeletionActions( 19 | ["bookmark1", "bookmark2"], 20 | new Set(["bookmark3"]), 21 | new Set(["bookmark4"]), 22 | defaultSettings 23 | ); 24 | expect(result).toEqual([]); 25 | }); 26 | 27 | it("should not process deletions when syncDeletions is false", () => { 28 | const settings = { ...defaultSettings, handleArchivedBookmarks: true }; 29 | const result = determineDeletionActions(["deleted-bookmark"], new Set(), new Set(), settings); 30 | expect(result).toEqual([]); 31 | }); 32 | 33 | it("should not process archived when handleArchivedBookmarks is false", () => { 34 | const settings = { ...defaultSettings, syncDeletions: true }; 35 | const result = determineDeletionActions( 36 | ["archived-bookmark"], 37 | new Set(), 38 | new Set(["archived-bookmark"]), 39 | settings 40 | ); 41 | expect(result).toEqual([]); 42 | }); 43 | }); 44 | 45 | describe("deleted bookmarks", () => { 46 | const settings: DeletionSettings = { 47 | syncDeletions: true, 48 | deletionAction: "delete", 49 | handleArchivedBookmarks: false, 50 | archivedBookmarkAction: "ignore", 51 | }; 52 | 53 | it("should detect completely deleted bookmark", () => { 54 | const result = determineDeletionActions(["bookmark1"], new Set(), new Set(), settings); 55 | expect(result).toEqual([{ bookmarkId: "bookmark1", action: "delete", reason: "deleted" }]); 56 | }); 57 | 58 | it("should handle multiple deleted bookmarks", () => { 59 | const result = determineDeletionActions( 60 | ["bookmark1", "bookmark2", "bookmark3"], 61 | new Set(), 62 | new Set(), 63 | settings 64 | ); 65 | expect(result).toHaveLength(3); 66 | expect(result.every((r) => r.action === "delete" && r.reason === "deleted")).toBe(true); 67 | }); 68 | 69 | it("should not include active bookmarks", () => { 70 | const result = determineDeletionActions( 71 | ["active1", "deleted1"], 72 | new Set(["active1"]), 73 | new Set(), 74 | settings 75 | ); 76 | expect(result).toEqual([{ bookmarkId: "deleted1", action: "delete", reason: "deleted" }]); 77 | }); 78 | 79 | it("should not include archived bookmarks in deleted", () => { 80 | const result = determineDeletionActions( 81 | ["archived1", "deleted1"], 82 | new Set(), 83 | new Set(["archived1"]), 84 | settings 85 | ); 86 | expect(result).toEqual([{ bookmarkId: "deleted1", action: "delete", reason: "deleted" }]); 87 | }); 88 | }); 89 | 90 | describe("deletion actions", () => { 91 | it("should use delete action", () => { 92 | const settings: DeletionSettings = { 93 | syncDeletions: true, 94 | deletionAction: "delete", 95 | handleArchivedBookmarks: false, 96 | archivedBookmarkAction: "ignore", 97 | }; 98 | const result = determineDeletionActions(["bookmark1"], new Set(), new Set(), settings); 99 | expect(result[0].action).toBe("delete"); 100 | }); 101 | 102 | it("should use archive action", () => { 103 | const settings: DeletionSettings = { 104 | syncDeletions: true, 105 | deletionAction: "archive", 106 | handleArchivedBookmarks: false, 107 | archivedBookmarkAction: "ignore", 108 | }; 109 | const result = determineDeletionActions(["bookmark1"], new Set(), new Set(), settings); 110 | expect(result[0].action).toBe("archive"); 111 | }); 112 | 113 | it("should use tag action", () => { 114 | const settings: DeletionSettings = { 115 | syncDeletions: true, 116 | deletionAction: "tag", 117 | handleArchivedBookmarks: false, 118 | archivedBookmarkAction: "ignore", 119 | }; 120 | const result = determineDeletionActions(["bookmark1"], new Set(), new Set(), settings); 121 | expect(result[0].action).toBe("tag"); 122 | }); 123 | 124 | it("should not create instruction for ignore action", () => { 125 | const settings: DeletionSettings = { 126 | syncDeletions: true, 127 | deletionAction: "ignore", 128 | handleArchivedBookmarks: false, 129 | archivedBookmarkAction: "ignore", 130 | }; 131 | const result = determineDeletionActions(["bookmark1"], new Set(), new Set(), settings); 132 | expect(result).toEqual([]); 133 | }); 134 | }); 135 | 136 | describe("archived bookmarks", () => { 137 | const settings: DeletionSettings = { 138 | syncDeletions: false, 139 | deletionAction: "ignore", 140 | handleArchivedBookmarks: true, 141 | archivedBookmarkAction: "archive", 142 | }; 143 | 144 | it("should detect archived bookmark", () => { 145 | const result = determineDeletionActions( 146 | ["bookmark1"], 147 | new Set(), 148 | new Set(["bookmark1"]), 149 | settings 150 | ); 151 | expect(result).toEqual([{ bookmarkId: "bookmark1", action: "archive", reason: "archived" }]); 152 | }); 153 | 154 | it("should handle multiple archived bookmarks", () => { 155 | const result = determineDeletionActions( 156 | ["bookmark1", "bookmark2"], 157 | new Set(), 158 | new Set(["bookmark1", "bookmark2"]), 159 | settings 160 | ); 161 | expect(result).toHaveLength(2); 162 | expect(result.every((r) => r.reason === "archived")).toBe(true); 163 | }); 164 | 165 | it("should not include active bookmarks", () => { 166 | const result = determineDeletionActions( 167 | ["active1", "archived1"], 168 | new Set(["active1"]), 169 | new Set(["archived1"]), 170 | settings 171 | ); 172 | expect(result).toEqual([{ bookmarkId: "archived1", action: "archive", reason: "archived" }]); 173 | }); 174 | }); 175 | 176 | describe("archived actions", () => { 177 | it("should use delete action for archived", () => { 178 | const settings: DeletionSettings = { 179 | syncDeletions: false, 180 | deletionAction: "ignore", 181 | handleArchivedBookmarks: true, 182 | archivedBookmarkAction: "delete", 183 | }; 184 | const result = determineDeletionActions( 185 | ["bookmark1"], 186 | new Set(), 187 | new Set(["bookmark1"]), 188 | settings 189 | ); 190 | expect(result[0].action).toBe("delete"); 191 | }); 192 | 193 | it("should use archive action for archived", () => { 194 | const settings: DeletionSettings = { 195 | syncDeletions: false, 196 | deletionAction: "ignore", 197 | handleArchivedBookmarks: true, 198 | archivedBookmarkAction: "archive", 199 | }; 200 | const result = determineDeletionActions( 201 | ["bookmark1"], 202 | new Set(), 203 | new Set(["bookmark1"]), 204 | settings 205 | ); 206 | expect(result[0].action).toBe("archive"); 207 | }); 208 | 209 | it("should use tag action for archived", () => { 210 | const settings: DeletionSettings = { 211 | syncDeletions: false, 212 | deletionAction: "ignore", 213 | handleArchivedBookmarks: true, 214 | archivedBookmarkAction: "tag", 215 | }; 216 | const result = determineDeletionActions( 217 | ["bookmark1"], 218 | new Set(), 219 | new Set(["bookmark1"]), 220 | settings 221 | ); 222 | expect(result[0].action).toBe("tag"); 223 | }); 224 | 225 | it("should not create instruction for ignore action on archived", () => { 226 | const settings: DeletionSettings = { 227 | syncDeletions: false, 228 | deletionAction: "ignore", 229 | handleArchivedBookmarks: true, 230 | archivedBookmarkAction: "ignore", 231 | }; 232 | const result = determineDeletionActions( 233 | ["bookmark1"], 234 | new Set(), 235 | new Set(["bookmark1"]), 236 | settings 237 | ); 238 | expect(result).toEqual([]); 239 | }); 240 | }); 241 | 242 | describe("combined scenarios", () => { 243 | const settings: DeletionSettings = { 244 | syncDeletions: true, 245 | deletionAction: "delete", 246 | handleArchivedBookmarks: true, 247 | archivedBookmarkAction: "archive", 248 | }; 249 | 250 | it("should handle both deleted and archived bookmarks", () => { 251 | const result = determineDeletionActions( 252 | ["deleted1", "archived1"], 253 | new Set(), 254 | new Set(["archived1"]), 255 | settings 256 | ); 257 | expect(result).toHaveLength(2); 258 | expect(result.find((r) => r.bookmarkId === "deleted1")).toEqual({ 259 | bookmarkId: "deleted1", 260 | action: "delete", 261 | reason: "deleted", 262 | }); 263 | expect(result.find((r) => r.bookmarkId === "archived1")).toEqual({ 264 | bookmarkId: "archived1", 265 | action: "archive", 266 | reason: "archived", 267 | }); 268 | }); 269 | 270 | it("should handle mix of active, deleted, and archived", () => { 271 | const result = determineDeletionActions( 272 | ["active1", "deleted1", "archived1"], 273 | new Set(["active1"]), 274 | new Set(["archived1"]), 275 | settings 276 | ); 277 | expect(result).toHaveLength(2); 278 | expect(result.find((r) => r.bookmarkId === "active1")).toBeUndefined(); 279 | }); 280 | 281 | it("should handle empty local bookmarks", () => { 282 | const result = determineDeletionActions([], new Set(["active1"]), new Set(), settings); 283 | expect(result).toEqual([]); 284 | }); 285 | 286 | it("should handle all active bookmarks", () => { 287 | const result = determineDeletionActions( 288 | ["bookmark1", "bookmark2"], 289 | new Set(["bookmark1", "bookmark2"]), 290 | new Set(), 291 | settings 292 | ); 293 | expect(result).toEqual([]); 294 | }); 295 | }); 296 | }); 297 | 298 | describe("countDeletionResults", () => { 299 | it("should count zero results for empty array", () => { 300 | const result = countDeletionResults([]); 301 | expect(result).toEqual({ 302 | deleted: 0, 303 | archived: 0, 304 | tagged: 0, 305 | archivedHandled: 0, 306 | }); 307 | }); 308 | 309 | it("should count deleted actions", () => { 310 | const instructions: FileHandlingInstruction[] = [ 311 | { bookmarkId: "1", action: "delete", reason: "deleted" }, 312 | { bookmarkId: "2", action: "delete", reason: "deleted" }, 313 | ]; 314 | const result = countDeletionResults(instructions); 315 | expect(result.deleted).toBe(2); 316 | expect(result.archived).toBe(0); 317 | expect(result.tagged).toBe(0); 318 | expect(result.archivedHandled).toBe(0); 319 | }); 320 | 321 | it("should count archived actions", () => { 322 | const instructions: FileHandlingInstruction[] = [ 323 | { bookmarkId: "1", action: "archive", reason: "deleted" }, 324 | { bookmarkId: "2", action: "archive", reason: "deleted" }, 325 | { bookmarkId: "3", action: "archive", reason: "deleted" }, 326 | ]; 327 | const result = countDeletionResults(instructions); 328 | expect(result.archived).toBe(3); 329 | expect(result.deleted).toBe(0); 330 | }); 331 | 332 | it("should count tagged actions", () => { 333 | const instructions: FileHandlingInstruction[] = [ 334 | { bookmarkId: "1", action: "tag", reason: "deleted" }, 335 | ]; 336 | const result = countDeletionResults(instructions); 337 | expect(result.tagged).toBe(1); 338 | }); 339 | 340 | it("should count archivedHandled actions", () => { 341 | const instructions: FileHandlingInstruction[] = [ 342 | { bookmarkId: "1", action: "delete", reason: "archived" }, 343 | { bookmarkId: "2", action: "archive", reason: "archived" }, 344 | { bookmarkId: "3", action: "tag", reason: "archived" }, 345 | ]; 346 | const result = countDeletionResults(instructions); 347 | expect(result.archivedHandled).toBe(3); 348 | expect(result.deleted).toBe(0); 349 | expect(result.archived).toBe(0); 350 | expect(result.tagged).toBe(0); 351 | }); 352 | 353 | it("should count mixed deleted and archived reasons", () => { 354 | const instructions: FileHandlingInstruction[] = [ 355 | { bookmarkId: "1", action: "delete", reason: "deleted" }, 356 | { bookmarkId: "2", action: "archive", reason: "deleted" }, 357 | { bookmarkId: "3", action: "tag", reason: "deleted" }, 358 | { bookmarkId: "4", action: "delete", reason: "archived" }, 359 | { bookmarkId: "5", action: "archive", reason: "archived" }, 360 | ]; 361 | const result = countDeletionResults(instructions); 362 | expect(result.deleted).toBe(1); 363 | expect(result.archived).toBe(1); 364 | expect(result.tagged).toBe(1); 365 | expect(result.archivedHandled).toBe(2); 366 | }); 367 | 368 | it("should handle large number of instructions", () => { 369 | const instructions: FileHandlingInstruction[] = Array.from({ length: 100 }, (_, i) => ({ 370 | bookmarkId: `bookmark${i}`, 371 | action: i % 2 === 0 ? ("delete" as const) : ("archive" as const), 372 | reason: i % 3 === 0 ? ("archived" as const) : ("deleted" as const), 373 | })); 374 | const result = countDeletionResults(instructions); 375 | expect(result.deleted + result.archived + result.tagged + result.archivedHandled).toBe(100); 376 | }); 377 | }); 378 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { AbstractInputSuggest, App, Notice, PluginSettingTab, Setting, TFolder } from "obsidian"; 2 | 3 | import HoarderPlugin from "./main"; 4 | 5 | export interface HoarderSettings { 6 | apiKey: string; 7 | apiEndpoint: string; 8 | syncFolder: string; 9 | attachmentsFolder: string; 10 | syncIntervalMinutes: number; 11 | lastSyncTimestamp: number; 12 | updateExistingFiles: boolean; 13 | excludeArchived: boolean; 14 | onlyFavorites: boolean; 15 | syncNotesToHoarder: boolean; 16 | syncHighlights: boolean; 17 | onlyBookmarksWithHighlights: boolean; 18 | excludedTags: string[]; 19 | includedTags: string[]; 20 | downloadAssets: boolean; 21 | syncDeletions: boolean; 22 | deletionAction: "delete" | "archive" | "tag"; 23 | deletionTag: string; 24 | archiveFolder: string; 25 | handleArchivedBookmarks: boolean; 26 | archivedBookmarkAction: "delete" | "archive" | "tag" | "ignore"; 27 | archivedBookmarkTag: string; 28 | archivedBookmarkFolder: string; 29 | useObsidianRequest: boolean; 30 | } 31 | 32 | export const DEFAULT_SETTINGS: HoarderSettings = { 33 | apiKey: "", 34 | apiEndpoint: "https://api.hoarder.app/api/v1", 35 | syncFolder: "Hoarder", 36 | attachmentsFolder: "Hoarder/attachments", 37 | syncIntervalMinutes: 60, 38 | lastSyncTimestamp: 0, 39 | updateExistingFiles: false, 40 | excludeArchived: true, 41 | onlyFavorites: false, 42 | syncNotesToHoarder: true, 43 | syncHighlights: true, 44 | onlyBookmarksWithHighlights: false, 45 | excludedTags: [], 46 | includedTags: [], 47 | downloadAssets: true, 48 | syncDeletions: false, 49 | deletionAction: "delete", 50 | deletionTag: "deleted", 51 | archiveFolder: "Hoarder/deleted", 52 | handleArchivedBookmarks: false, 53 | archivedBookmarkAction: "delete", 54 | archivedBookmarkTag: "archived", 55 | archivedBookmarkFolder: "Hoarder/archived", 56 | useObsidianRequest: false, 57 | }; 58 | 59 | class FolderSuggest extends AbstractInputSuggest<TFolder> { 60 | private folders: TFolder[]; 61 | private inputEl: HTMLInputElement; 62 | 63 | constructor(app: App, inputEl: HTMLInputElement) { 64 | super(app, inputEl); 65 | this.folders = this.getFolders(); 66 | this.inputEl = inputEl; 67 | } 68 | 69 | getSuggestions(inputStr: string): TFolder[] { 70 | const lowerCaseInputStr = inputStr.toLowerCase(); 71 | return this.folders.filter((folder) => folder.path.toLowerCase().contains(lowerCaseInputStr)); 72 | } 73 | 74 | renderSuggestion(folder: TFolder, el: HTMLElement): void { 75 | el.setText(folder.path); 76 | } 77 | selectSuggestion(folder: TFolder): void { 78 | const value = folder.path; 79 | this.inputEl.value = value; 80 | this.inputEl.trigger("input"); 81 | this.close(); 82 | } 83 | 84 | private getFolders(): TFolder[] { 85 | const folders: TFolder[] = []; 86 | this.app.vault.getAllLoadedFiles().forEach((file) => { 87 | if (file instanceof TFolder) { 88 | folders.push(file); 89 | } 90 | }); 91 | return folders.sort((a, b) => a.path.localeCompare(b.path)); 92 | } 93 | } 94 | 95 | export class HoarderSettingTab extends PluginSettingTab { 96 | plugin: HoarderPlugin; 97 | syncButton: any; 98 | 99 | constructor(app: App, plugin: HoarderPlugin) { 100 | super(app, plugin); 101 | this.plugin = plugin; 102 | } 103 | 104 | onunload() { 105 | // Clean up event listener 106 | this.plugin.events.off("sync-state-change", this.updateSyncButton); 107 | } 108 | 109 | private updateSyncButton = (isSyncing: boolean) => { 110 | if (this.syncButton) { 111 | this.syncButton.setButtonText(isSyncing ? "Syncing..." : "Sync Now"); 112 | this.syncButton.setDisabled(isSyncing); 113 | } 114 | }; 115 | 116 | display(): void { 117 | const { containerEl } = this; 118 | containerEl.empty(); 119 | 120 | // ================= 121 | // API Configuration 122 | // ================= 123 | containerEl.createEl("h3", { text: "API Configuration" }); 124 | containerEl.createEl("div", { 125 | text: "Connection settings for your Karakeep instance", 126 | cls: "setting-item-description", 127 | }); 128 | 129 | new Setting(containerEl) 130 | .setName("Api key") 131 | .setDesc("Your Hoarder API key") 132 | .addText((text) => 133 | text 134 | .setPlaceholder("Enter your API key") 135 | .setValue(this.plugin.settings.apiKey) 136 | .onChange(async (value) => { 137 | this.plugin.settings.apiKey = value; 138 | await this.plugin.saveSettings(); 139 | }) 140 | .inputEl.addClass("hoarder-wide-input") 141 | ); 142 | 143 | new Setting(containerEl) 144 | .setName("Api endpoint") 145 | .setDesc("Hoarder API endpoint URL (default: https://api.karakeep.app/api/v1)") 146 | .addText((text) => 147 | text 148 | .setPlaceholder("Enter API endpoint") 149 | .setValue(this.plugin.settings.apiEndpoint) 150 | .onChange(async (value) => { 151 | this.plugin.settings.apiEndpoint = value; 152 | await this.plugin.saveSettings(); 153 | }) 154 | .inputEl.addClass("hoarder-wide-input") 155 | ); 156 | 157 | new Setting(containerEl) 158 | .setName("Bypass CORS") 159 | .setDesc( 160 | "Use Obsidian's internal request method to avoid CORS issues. Enable this if you're experiencing connection problems." 161 | ) 162 | .addToggle((toggle) => 163 | toggle.setValue(this.plugin.settings.useObsidianRequest).onChange(async (value) => { 164 | this.plugin.settings.useObsidianRequest = value; 165 | await this.plugin.saveSettings(); 166 | }) 167 | ); 168 | 169 | // ================= 170 | // File Organization 171 | // ================= 172 | containerEl.createEl("h3", { text: "File Organization" }); 173 | containerEl.createEl("div", { 174 | text: "Configure where your bookmarks and assets are stored", 175 | cls: "setting-item-description", 176 | }); 177 | 178 | new Setting(containerEl) 179 | .setName("Sync folder") 180 | .setDesc("Folder where bookmarks will be saved") 181 | .addText((text) => { 182 | text 183 | .setPlaceholder("Example: folder1/folder2") 184 | .setValue(this.plugin.settings.syncFolder) 185 | .onChange(async (value) => { 186 | this.plugin.settings.syncFolder = value; 187 | await this.plugin.saveSettings(); 188 | }); 189 | 190 | text.inputEl.addClass("hoarder-medium-input"); 191 | new FolderSuggest(this.app, text.inputEl); 192 | return text; 193 | }); 194 | 195 | new Setting(containerEl) 196 | .setName("Attachments folder") 197 | .setDesc("Folder where bookmark images will be saved") 198 | .addText((text) => { 199 | text 200 | .setPlaceholder("Example: folder1/attachments") 201 | .setValue(this.plugin.settings.attachmentsFolder) 202 | .onChange(async (value) => { 203 | this.plugin.settings.attachmentsFolder = value; 204 | await this.plugin.saveSettings(); 205 | }); 206 | 207 | text.inputEl.addClass("hoarder-medium-input"); 208 | new FolderSuggest(this.app, text.inputEl); 209 | return text; 210 | }); 211 | 212 | // ================= 213 | // Sync Behavior 214 | // ================= 215 | containerEl.createEl("h3", { text: "Sync Behavior" }); 216 | containerEl.createEl("div", { 217 | text: "Control how synchronization works", 218 | cls: "setting-item-description", 219 | }); 220 | 221 | new Setting(containerEl) 222 | .setName("Sync interval") 223 | .setDesc("How often to sync (in minutes)") 224 | .addText((text) => 225 | text 226 | .setPlaceholder("60") 227 | .setValue(String(this.plugin.settings.syncIntervalMinutes)) 228 | .onChange(async (value) => { 229 | const numValue = parseInt(value); 230 | if (!isNaN(numValue) && numValue > 0) { 231 | this.plugin.settings.syncIntervalMinutes = numValue; 232 | await this.plugin.saveSettings(); 233 | this.plugin.startPeriodicSync(); 234 | } 235 | }) 236 | .inputEl.addClass("hoarder-small-input") 237 | ); 238 | 239 | new Setting(containerEl) 240 | .setName("Update existing files") 241 | .setDesc( 242 | "Whether to update existing bookmark files when remote data changes. When disabled, only new bookmarks will be created." 243 | ) 244 | .addToggle((toggle) => 245 | toggle.setValue(this.plugin.settings.updateExistingFiles).onChange(async (value) => { 246 | this.plugin.settings.updateExistingFiles = value; 247 | await this.plugin.saveSettings(); 248 | }) 249 | ); 250 | 251 | new Setting(containerEl) 252 | .setName("Sync notes to Karakeep") 253 | .setDesc("Whether to sync notes to Karakeep") 254 | .addToggle((toggle) => 255 | toggle.setValue(this.plugin.settings.syncNotesToHoarder).onChange(async (value) => { 256 | this.plugin.settings.syncNotesToHoarder = value; 257 | await this.plugin.saveSettings(); 258 | }) 259 | ); 260 | 261 | new Setting(containerEl) 262 | .setName("Sync highlights") 263 | .setDesc("Whether to sync highlights from Karakeep into bookmark files") 264 | .addToggle((toggle) => 265 | toggle.setValue(this.plugin.settings.syncHighlights).onChange(async (value) => { 266 | this.plugin.settings.syncHighlights = value; 267 | await this.plugin.saveSettings(); 268 | }) 269 | ); 270 | 271 | new Setting(containerEl) 272 | .setName("Download assets") 273 | .setDesc( 274 | "Download images and other assets locally (if disabled, assets will be embedded using their source URLs)" 275 | ) 276 | .addToggle((toggle) => 277 | toggle.setValue(this.plugin.settings.downloadAssets).onChange(async (value) => { 278 | this.plugin.settings.downloadAssets = value; 279 | await this.plugin.saveSettings(); 280 | }) 281 | ); 282 | 283 | // ================= 284 | // Sync Filtering 285 | // ================= 286 | containerEl.createEl("h3", { text: "Sync Filtering" }); 287 | containerEl.createEl("div", { 288 | text: "Control which bookmarks are synchronized", 289 | cls: "setting-item-description", 290 | }); 291 | 292 | new Setting(containerEl) 293 | .setName("Exclude archived") 294 | .setDesc("Exclude archived bookmarks from sync") 295 | .addToggle((toggle) => 296 | toggle.setValue(this.plugin.settings.excludeArchived).onChange(async (value) => { 297 | this.plugin.settings.excludeArchived = value; 298 | await this.plugin.saveSettings(); 299 | }) 300 | ); 301 | 302 | new Setting(containerEl) 303 | .setName("Only favorites") 304 | .setDesc("Only sync favorited bookmarks") 305 | .addToggle((toggle) => 306 | toggle.setValue(this.plugin.settings.onlyFavorites).onChange(async (value) => { 307 | this.plugin.settings.onlyFavorites = value; 308 | await this.plugin.saveSettings(); 309 | }) 310 | ); 311 | 312 | new Setting(containerEl) 313 | .setName("Only bookmarks with highlights") 314 | .setDesc( 315 | "Only sync bookmarks that have highlights (requires 'Sync highlights' to be enabled)" 316 | ) 317 | .addToggle((toggle) => 318 | toggle 319 | .setValue(this.plugin.settings.onlyBookmarksWithHighlights) 320 | .onChange(async (value) => { 321 | this.plugin.settings.onlyBookmarksWithHighlights = value; 322 | await this.plugin.saveSettings(); 323 | }) 324 | ); 325 | 326 | new Setting(containerEl) 327 | .setName("Excluded tags") 328 | .setDesc("Bookmarks with these tags will not be synced (comma-separated), unless favorited") 329 | .addText((text) => 330 | text 331 | .setPlaceholder("private, secret, draft") 332 | .setValue(this.plugin.settings.excludedTags.join(", ")) 333 | .onChange(async (value) => { 334 | // Split by comma, trim whitespace, and filter out empty strings 335 | this.plugin.settings.excludedTags = value 336 | .split(",") 337 | .map((tag) => tag.trim()) 338 | .filter((tag) => tag.length > 0); 339 | await this.plugin.saveSettings(); 340 | }) 341 | .inputEl.addClass("hoarder-wide-input") 342 | ); 343 | 344 | new Setting(containerEl) 345 | .setName("Included tags") 346 | .setDesc("Bookmarks with these tags will be synced (comma-separated)") 347 | .addText((text) => 348 | text 349 | .setPlaceholder("public, shared") 350 | .setValue(this.plugin.settings.includedTags.join(", ")) 351 | .onChange(async (value) => { 352 | // Split by comma, trim whitespace, and filter out empty strings 353 | this.plugin.settings.includedTags = value 354 | .split(",") 355 | .map((tag) => tag.trim()) 356 | .filter((tag) => tag.length > 0); 357 | await this.plugin.saveSettings(); 358 | }) 359 | .inputEl.addClass("hoarder-wide-input") 360 | ); 361 | 362 | // ================= 363 | // Deletion Handling 364 | // ================= 365 | containerEl.createEl("h3", { text: "Deletion Handling" }); 366 | containerEl.createEl("div", { 367 | text: "Configure what happens when bookmarks are deleted in Karakeep", 368 | cls: "setting-item-description", 369 | }); 370 | 371 | const syncDeletionsToggle = new Setting(containerEl) 372 | .setName("Sync deletions") 373 | .setDesc("Automatically handle bookmarks that are deleted in Karakeep") 374 | .addToggle((toggle) => 375 | toggle.setValue(this.plugin.settings.syncDeletions).onChange(async (value) => { 376 | this.plugin.settings.syncDeletions = value; 377 | await this.plugin.saveSettings(); 378 | this.display(); // Refresh the display to show/hide conditional settings 379 | }) 380 | ); 381 | 382 | if (this.plugin.settings.syncDeletions) { 383 | const deletionActionSetting = new Setting(containerEl) 384 | .setName("Deletion action") 385 | .setDesc("What to do with local files when bookmarks are deleted in Karakeep") 386 | .addDropdown((dropdown) => 387 | dropdown 388 | .addOption("delete", "Delete file") 389 | .addOption("archive", "Move to archive folder") 390 | .addOption("tag", "Add deletion tag") 391 | .setValue(this.plugin.settings.deletionAction) 392 | .onChange(async (value: "delete" | "archive" | "tag") => { 393 | this.plugin.settings.deletionAction = value; 394 | await this.plugin.saveSettings(); 395 | this.display(); // Refresh the display to show/hide conditional settings 396 | }) 397 | ); 398 | 399 | if (this.plugin.settings.deletionAction === "archive") { 400 | new Setting(containerEl) 401 | .setName("Archive folder") 402 | .setDesc("Folder to move deleted bookmarks to") 403 | .addText((text) => { 404 | text 405 | .setPlaceholder("Example: Hoarder/deleted") 406 | .setValue(this.plugin.settings.archiveFolder) 407 | .onChange(async (value) => { 408 | this.plugin.settings.archiveFolder = value; 409 | await this.plugin.saveSettings(); 410 | }); 411 | 412 | text.inputEl.addClass("hoarder-medium-input"); 413 | new FolderSuggest(this.app, text.inputEl); 414 | return text; 415 | }); 416 | } 417 | 418 | if (this.plugin.settings.deletionAction === "tag") { 419 | new Setting(containerEl) 420 | .setName("Deletion tag") 421 | .setDesc("Tag to add to files when bookmarks are deleted") 422 | .addText((text) => 423 | text 424 | .setPlaceholder("deleted") 425 | .setValue(this.plugin.settings.deletionTag) 426 | .onChange(async (value) => { 427 | this.plugin.settings.deletionTag = value; 428 | await this.plugin.saveSettings(); 429 | }) 430 | .inputEl.addClass("hoarder-medium-input") 431 | ); 432 | } 433 | } 434 | 435 | // ================= 436 | // Archive Handling 437 | // ================= 438 | containerEl.createEl("h3", { text: "Archive Handling" }); 439 | containerEl.createEl("div", { 440 | text: "Configure what happens when bookmarks are archived in Karakeep", 441 | cls: "setting-item-description", 442 | }); 443 | 444 | const handleArchivedToggle = new Setting(containerEl) 445 | .setName("Handle archived bookmarks") 446 | .setDesc("Separately handle bookmarks that are archived (not deleted) in Karakeep") 447 | .addToggle((toggle) => 448 | toggle.setValue(this.plugin.settings.handleArchivedBookmarks).onChange(async (value) => { 449 | this.plugin.settings.handleArchivedBookmarks = value; 450 | await this.plugin.saveSettings(); 451 | this.display(); // Refresh the display to show/hide conditional settings 452 | }) 453 | ); 454 | 455 | if (this.plugin.settings.handleArchivedBookmarks) { 456 | const archivedActionSetting = new Setting(containerEl) 457 | .setName("Archived bookmark action") 458 | .setDesc("What to do with local files when bookmarks are archived in Karakeep") 459 | .addDropdown((dropdown) => 460 | dropdown 461 | .addOption("ignore", "Do nothing") 462 | .addOption("delete", "Delete file") 463 | .addOption("archive", "Move to archive folder") 464 | .addOption("tag", "Add archived tag") 465 | .setValue(this.plugin.settings.archivedBookmarkAction) 466 | .onChange(async (value: "delete" | "archive" | "tag" | "ignore") => { 467 | this.plugin.settings.archivedBookmarkAction = value; 468 | await this.plugin.saveSettings(); 469 | this.display(); // Refresh the display to show/hide conditional settings 470 | }) 471 | ); 472 | 473 | if (this.plugin.settings.archivedBookmarkAction === "archive") { 474 | new Setting(containerEl) 475 | .setName("Archived bookmark folder") 476 | .setDesc("Folder to move archived bookmarks to") 477 | .addText((text) => { 478 | text 479 | .setPlaceholder("Example: Hoarder/archived") 480 | .setValue(this.plugin.settings.archivedBookmarkFolder) 481 | .onChange(async (value) => { 482 | this.plugin.settings.archivedBookmarkFolder = value; 483 | await this.plugin.saveSettings(); 484 | }); 485 | 486 | text.inputEl.addClass("hoarder-medium-input"); 487 | new FolderSuggest(this.app, text.inputEl); 488 | return text; 489 | }); 490 | } 491 | 492 | if (this.plugin.settings.archivedBookmarkAction === "tag") { 493 | new Setting(containerEl) 494 | .setName("Archived bookmark tag") 495 | .setDesc("Tag to add to files when bookmarks are archived") 496 | .addText((text) => 497 | text 498 | .setPlaceholder("archived") 499 | .setValue(this.plugin.settings.archivedBookmarkTag) 500 | .onChange(async (value) => { 501 | this.plugin.settings.archivedBookmarkTag = value; 502 | await this.plugin.saveSettings(); 503 | }) 504 | .inputEl.addClass("hoarder-medium-input") 505 | ); 506 | } 507 | } 508 | 509 | // ================= 510 | // Manual Actions & Status 511 | // ================= 512 | containerEl.createEl("h3", { text: "Manual Actions & Status" }); 513 | containerEl.createEl("div", { 514 | text: "Manual sync controls and synchronization status", 515 | cls: "setting-item-description", 516 | }); 517 | 518 | // Add Sync Now button 519 | new Setting(containerEl) 520 | .setName("Manual sync") 521 | .setDesc("Sync bookmarks now") 522 | .addButton((button) => { 523 | this.syncButton = button 524 | .setButtonText(this.plugin.isSyncing ? "Syncing..." : "Sync Now") 525 | .setDisabled(this.plugin.isSyncing) 526 | .onClick(async () => { 527 | const result = await this.plugin.syncBookmarks(); 528 | new Notice(result.message); 529 | }); 530 | 531 | // Subscribe to sync state changes 532 | this.plugin.events.on("sync-state-change", this.updateSyncButton); 533 | 534 | return button; 535 | }); 536 | 537 | // Add Last Sync Time 538 | if (this.plugin.settings.lastSyncTimestamp > 0) { 539 | containerEl.createEl("div", { 540 | text: `Last synced: ${new Date(this.plugin.settings.lastSyncTimestamp).toLocaleString()}`, 541 | cls: "setting-item-description", 542 | }); 543 | } 544 | } 545 | } 546 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Events, Notice, Plugin, TFile } from "obsidian"; 2 | 3 | import { processBookmarkAssets } from "./asset-handler"; 4 | import { getBookmarkTitle } from "./bookmark-utils"; 5 | import { 6 | DeletionSettings, 7 | countDeletionResults, 8 | determineDeletionActions, 9 | } from "./deletion-handler"; 10 | import { sanitizeFileName } from "./filename-utils"; 11 | import { shouldIncludeBookmark } from "./filter-utils"; 12 | import { escapeMarkdownPath, escapeYaml } from "./formatting-utils"; 13 | import { 14 | HoarderApiClient, 15 | HoarderBookmark, 16 | HoarderHighlight, 17 | PaginatedBookmarks, 18 | } from "./hoarder-client"; 19 | import { extractNotesSection } from "./markdown-utils"; 20 | import { SyncStats, buildSyncMessage } from "./message-utils"; 21 | import { DEFAULT_SETTINGS, HoarderSettingTab, HoarderSettings } from "./settings"; 22 | import { sanitizeTags } from "./tag-utils"; 23 | 24 | export default class HoarderPlugin extends Plugin { 25 | settings: HoarderSettings; 26 | syncIntervalId: number; 27 | isSyncing: boolean = false; 28 | skippedFiles: number = 0; 29 | events: Events = new Events(); 30 | private modificationTimeout: number | null = null; 31 | private lastSyncedNotes: string | null = null; 32 | private client: HoarderApiClient | null = null; 33 | 34 | async onload() { 35 | await this.loadSettings(); 36 | 37 | // Initialize the SDK client 38 | this.initializeClient(); 39 | 40 | // Add settings tab 41 | this.addSettingTab(new HoarderSettingTab(this.app, this)); 42 | 43 | // Add command to trigger sync 44 | this.addCommand({ 45 | id: "trigger-hoarder-sync", 46 | name: "Sync Bookmarks", 47 | callback: async () => { 48 | const result = await this.syncBookmarks(); 49 | new Notice(result.message); 50 | }, 51 | }); 52 | 53 | // Register file modification event 54 | this.registerEvent( 55 | this.app.vault.on("modify", async (file) => { 56 | // Check if it's a markdown file in our sync folder 57 | if ( 58 | this.settings.syncNotesToHoarder && 59 | file.path.startsWith(this.settings.syncFolder) && 60 | file.path.endsWith(".md") && 61 | file instanceof TFile 62 | ) { 63 | // Clear any existing timeout 64 | if (this.modificationTimeout) { 65 | window.clearTimeout(this.modificationTimeout); 66 | } 67 | 68 | // Set a new timeout 69 | this.modificationTimeout = window.setTimeout(async () => { 70 | await this.handleFileModification(file); 71 | }, 2000); // Wait 2 seconds after last modification 72 | } 73 | }) 74 | ); 75 | 76 | // Start periodic sync 77 | this.startPeriodicSync(); 78 | } 79 | 80 | onunload() { 81 | // Clear the sync interval when plugin is disabled 82 | if (this.syncIntervalId) { 83 | window.clearInterval(this.syncIntervalId); 84 | } 85 | // Clear any pending modification timeout 86 | if (this.modificationTimeout) { 87 | window.clearTimeout(this.modificationTimeout); 88 | } 89 | } 90 | 91 | async loadSettings() { 92 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 93 | } 94 | 95 | async saveSettings() { 96 | await this.saveData(this.settings); 97 | // Reinitialize client when settings change 98 | this.initializeClient(); 99 | } 100 | 101 | startPeriodicSync() { 102 | // Clear existing interval if any 103 | if (this.syncIntervalId) { 104 | window.clearInterval(this.syncIntervalId); 105 | } 106 | 107 | // Convert minutes to milliseconds 108 | const interval = this.settings.syncIntervalMinutes * 60 * 1000; 109 | 110 | // Perform initial sync 111 | this.syncBookmarks(); 112 | 113 | // Set up periodic sync 114 | this.syncIntervalId = window.setInterval(() => { 115 | this.syncBookmarks(); 116 | }, interval); 117 | } 118 | 119 | async fetchBookmarks(cursor?: string, limit: number = 100): Promise<PaginatedBookmarks> { 120 | if (!this.client) { 121 | throw new Error("Client not initialized"); 122 | } 123 | 124 | return await this.client.getBookmarks({ 125 | limit, 126 | cursor: cursor || undefined, 127 | archived: this.settings.excludeArchived ? false : undefined, 128 | favourited: this.settings.onlyFavorites ? true : undefined, 129 | }); 130 | } 131 | 132 | async fetchAllBookmarks(includeArchived: boolean = false): Promise<HoarderBookmark[]> { 133 | if (!this.client) { 134 | throw new Error("Client not initialized"); 135 | } 136 | 137 | const allBookmarks: HoarderBookmark[] = []; 138 | let cursor: string | undefined; 139 | 140 | do { 141 | const data = await this.client.getBookmarks({ 142 | limit: 100, 143 | cursor: cursor || undefined, 144 | archived: includeArchived ? undefined : false, 145 | favourited: this.settings.onlyFavorites ? true : undefined, 146 | }); 147 | 148 | allBookmarks.push(...(data.bookmarks || [])); 149 | cursor = data.nextCursor || undefined; 150 | } while (cursor); 151 | 152 | return allBookmarks; 153 | } 154 | 155 | async extractNotesFromFile( 156 | filePath: string 157 | ): Promise<{ currentNotes: string | null; originalNotes: string | null }> { 158 | try { 159 | const file = this.app.vault.getAbstractFileByPath(filePath); 160 | if (!(file instanceof TFile)) { 161 | return { currentNotes: null, originalNotes: null }; 162 | } 163 | 164 | const content = await this.app.vault.adapter.read(filePath); 165 | 166 | // Extract notes from the content 167 | const currentNotes = extractNotesSection(content); 168 | 169 | // Use MetadataCache to get frontmatter 170 | const metadata = this.app.metadataCache.getFileCache(file)?.frontmatter; 171 | const originalNotes = metadata?.original_note ?? null; 172 | 173 | return { currentNotes, originalNotes }; 174 | } catch (error) { 175 | console.error("Error reading file:", error); 176 | return { currentNotes: null, originalNotes: null }; 177 | } 178 | } 179 | 180 | async updateBookmarkInHoarder(bookmarkId: string, note: string): Promise<boolean> { 181 | try { 182 | if (!this.client) { 183 | throw new Error("Client not initialized"); 184 | } 185 | 186 | await this.client.updateBookmark(bookmarkId, { note }); 187 | return true; 188 | } catch (error) { 189 | console.error("Error updating bookmark in Hoarder:", error); 190 | return false; 191 | } 192 | } 193 | 194 | private setSyncing(value: boolean) { 195 | this.isSyncing = value; 196 | this.events.trigger("sync-state-change", value); 197 | } 198 | 199 | async getLocalBookmarkFiles(): Promise<Map<string, string>> { 200 | const bookmarkFiles = new Map<string, string>(); 201 | const folderPath = this.settings.syncFolder; 202 | 203 | if (!(await this.app.vault.adapter.exists(folderPath))) { 204 | return bookmarkFiles; 205 | } 206 | 207 | const files = this.app.vault.getMarkdownFiles(); 208 | for (const file of files) { 209 | if (file.path.startsWith(folderPath) && file.path.endsWith(".md")) { 210 | const metadata = this.app.metadataCache.getFileCache(file)?.frontmatter; 211 | const bookmarkId = metadata?.bookmark_id; 212 | if (bookmarkId) { 213 | bookmarkFiles.set(bookmarkId, file.path); 214 | } 215 | } 216 | } 217 | 218 | return bookmarkFiles; 219 | } 220 | 221 | async handleDeletedAndArchivedBookmarks( 222 | localBookmarkFiles: Map<string, string>, 223 | activeBookmarkIds: Set<string>, 224 | archivedBookmarkIds: Set<string> 225 | ): Promise<{ deleted: number; archived: number; tagged: number; archivedHandled: number }> { 226 | const deletionSettings: DeletionSettings = { 227 | syncDeletions: this.settings.syncDeletions, 228 | deletionAction: this.settings.deletionAction, 229 | handleArchivedBookmarks: this.settings.handleArchivedBookmarks, 230 | archivedBookmarkAction: this.settings.archivedBookmarkAction, 231 | }; 232 | 233 | const localBookmarkIds = Array.from(localBookmarkFiles.keys()); 234 | const instructions = determineDeletionActions( 235 | localBookmarkIds, 236 | activeBookmarkIds, 237 | archivedBookmarkIds, 238 | deletionSettings 239 | ); 240 | 241 | // Execute each instruction 242 | for (const instruction of instructions) { 243 | const filePath = localBookmarkFiles.get(instruction.bookmarkId); 244 | if (!filePath) continue; 245 | 246 | const file = this.app.vault.getAbstractFileByPath(filePath); 247 | if (!(file instanceof TFile)) continue; 248 | 249 | try { 250 | switch (instruction.action) { 251 | case "delete": 252 | await this.app.vault.delete(file); 253 | break; 254 | 255 | case "archive": 256 | const archiveFolder = 257 | instruction.reason === "deleted" 258 | ? this.settings.archiveFolder 259 | : this.settings.archivedBookmarkFolder; 260 | await this.moveToArchiveFolder(file, archiveFolder); 261 | break; 262 | 263 | case "tag": 264 | const tag = 265 | instruction.reason === "deleted" 266 | ? this.settings.deletionTag 267 | : this.settings.archivedBookmarkTag; 268 | await this.addDeletionTag(file, tag); 269 | break; 270 | } 271 | } catch (error) { 272 | console.error(`Error handling bookmark ${instruction.bookmarkId}:`, error); 273 | } 274 | } 275 | 276 | return countDeletionResults(instructions); 277 | } 278 | 279 | async moveToArchiveFolder(file: TFile, archiveFolder: string): Promise<void> { 280 | if (!archiveFolder) { 281 | throw new Error("Archive folder not configured"); 282 | } 283 | 284 | // Create archive folder if it doesn't exist 285 | if (!(await this.app.vault.adapter.exists(archiveFolder))) { 286 | await this.app.vault.createFolder(archiveFolder); 287 | } 288 | 289 | // Generate new path in archive folder 290 | const fileName = file.name; 291 | const newPath = `${archiveFolder}/${fileName}`; 292 | 293 | // Handle name conflicts 294 | let finalPath = newPath; 295 | let counter = 1; 296 | while (await this.app.vault.adapter.exists(finalPath)) { 297 | const nameWithoutExt = fileName.replace(/\.md$/, ""); 298 | finalPath = `${archiveFolder}/${nameWithoutExt}-${counter}.md`; 299 | counter++; 300 | } 301 | 302 | await this.app.fileManager.renameFile(file, finalPath); 303 | } 304 | 305 | async addDeletionTag(file: TFile, tag: string): Promise<void> { 306 | if (!tag) { 307 | throw new Error("Tag not configured"); 308 | } 309 | 310 | await this.app.fileManager.processFrontMatter(file, (frontmatter) => { 311 | if (!frontmatter.tags) { 312 | frontmatter.tags = []; 313 | } 314 | 315 | // Ensure tags is an array 316 | if (typeof frontmatter.tags === "string") { 317 | frontmatter.tags = [frontmatter.tags]; 318 | } 319 | 320 | // Add tag if not already present 321 | if (!frontmatter.tags.includes(tag)) { 322 | frontmatter.tags.push(tag); 323 | } 324 | }); 325 | } 326 | 327 | async syncBookmarks(): Promise<{ success: boolean; message: string }> { 328 | if (this.isSyncing) { 329 | console.error("[Hoarder] Sync already in progress"); 330 | return { success: false, message: "Sync already in progress" }; 331 | } 332 | 333 | if (!this.settings.apiKey) { 334 | console.log("[Hoarder] API key not configured"); 335 | return { success: false, message: "Hoarder API key not configured" }; 336 | } 337 | 338 | console.log("[Hoarder] Starting sync..."); 339 | console.log( 340 | `[Hoarder] Settings: syncNotesToHoarder=${this.settings.syncNotesToHoarder}, updateExistingFiles=${this.settings.updateExistingFiles}` 341 | ); 342 | this.setSyncing(true); 343 | let totalBookmarks = 0; 344 | this.skippedFiles = 0; 345 | let updatedInHoarder = 0; 346 | let excludedByTags = 0; 347 | let includedByTags = 0; 348 | let totalBookmarksProcessed = 0; 349 | let skippedNoHighlights = 0; 350 | 351 | try { 352 | // Create sync folder if it doesn't exist 353 | const folderPath = this.settings.syncFolder; 354 | if (!(await this.app.vault.adapter.exists(folderPath))) { 355 | await this.app.vault.createFolder(folderPath); 356 | } 357 | 358 | // Get existing local bookmark files for deletion detection 359 | const localBookmarkFiles = await this.getLocalBookmarkFiles(); 360 | 361 | // Fetch all bookmarks to distinguish between active, archived, and deleted 362 | const activeBookmarks = await this.fetchAllBookmarks(false); // Only active bookmarks 363 | const allBookmarks = await this.fetchAllBookmarks(true); // All bookmarks including archived 364 | 365 | const activeBookmarkIds = new Set(activeBookmarks.map((b) => b.id)); 366 | const allBookmarkIds = new Set(allBookmarks.map((b) => b.id)); 367 | const archivedBookmarkIds = new Set( 368 | allBookmarks.filter((b) => b.archived && !activeBookmarkIds.has(b.id)).map((b) => b.id) 369 | ); 370 | 371 | // Fetch all highlights in bulk if enabled or if filtering by highlights 372 | let highlightsByBookmarkId = new Map<string, HoarderHighlight[]>(); 373 | let bookmarkIdsWithHighlights = new Set<string>(); 374 | if ( 375 | (this.settings.syncHighlights || this.settings.onlyBookmarksWithHighlights) && 376 | this.client 377 | ) { 378 | try { 379 | const allHighlights = await this.client.getAllHighlights(); 380 | 381 | // Group highlights by bookmark ID 382 | for (const highlight of allHighlights) { 383 | if (!highlightsByBookmarkId.has(highlight.bookmarkId)) { 384 | highlightsByBookmarkId.set(highlight.bookmarkId, []); 385 | } 386 | highlightsByBookmarkId.get(highlight.bookmarkId)!.push(highlight); 387 | bookmarkIdsWithHighlights.add(highlight.bookmarkId); 388 | } 389 | } catch (error) { 390 | console.error("Error fetching highlights in bulk:", error); 391 | // Continue without highlights rather than failing the entire sync 392 | } 393 | } 394 | 395 | let cursor: string | undefined; 396 | 397 | do { 398 | const result = await this.fetchBookmarks(cursor); 399 | const bookmarks = result.bookmarks || []; 400 | cursor = result.nextCursor || undefined; 401 | totalBookmarksProcessed += bookmarks.length; 402 | 403 | // Process each bookmark 404 | for (const bookmark of bookmarks) { 405 | // Skip if filtering by highlights and bookmark has no highlights 406 | if ( 407 | this.settings.onlyBookmarksWithHighlights && 408 | !bookmarkIdsWithHighlights.has(bookmark.id) 409 | ) { 410 | skippedNoHighlights++; 411 | continue; 412 | } 413 | 414 | // Get bookmark tags for filtering 415 | const bookmarkTags = bookmark.tags.map((tag) => tag.name.toLowerCase()); 416 | 417 | // Check if bookmark should be included based on tag filters 418 | const filterResult = shouldIncludeBookmark( 419 | bookmarkTags, 420 | this.settings.includedTags.map((t) => t.toLowerCase()), 421 | this.settings.excludedTags.map((t) => t.toLowerCase()), 422 | bookmark.favourited 423 | ); 424 | 425 | if (!filterResult.include) { 426 | excludedByTags++; 427 | continue; 428 | } 429 | 430 | // Count included bookmarks when using include filter 431 | if (this.settings.includedTags.length > 0) { 432 | includedByTags++; 433 | } 434 | 435 | const title = getBookmarkTitle(bookmark); 436 | const fileName = `${folderPath}/${sanitizeFileName(title, bookmark.createdAt)}.md`; 437 | 438 | // Get highlights for this bookmark from pre-fetched map 439 | const highlights = highlightsByBookmarkId.get(bookmark.id) || []; 440 | 441 | const fileExists = await this.app.vault.adapter.exists(fileName); 442 | 443 | if (fileExists) { 444 | // Check if we need to update the file 445 | const file = this.app.vault.getAbstractFileByPath(fileName); 446 | if (file instanceof TFile) { 447 | const metadata = this.app.metadataCache.getFileCache(file)?.frontmatter; 448 | const storedModifiedTime = metadata?.modified 449 | ? new Date(metadata.modified).getTime() 450 | : 0; 451 | const bookmarkModifiedTime = bookmark.modifiedAt 452 | ? new Date(bookmark.modifiedAt).getTime() 453 | : new Date(bookmark.createdAt).getTime(); 454 | 455 | // Check if there are new highlights since last file update 456 | let hasNewHighlights = false; 457 | if (this.settings.syncHighlights && highlights.length > 0) { 458 | const newestHighlightTime = Math.max( 459 | ...highlights.map((h) => new Date(h.createdAt).getTime()) 460 | ); 461 | hasNewHighlights = !storedModifiedTime || newestHighlightTime > storedModifiedTime; 462 | } 463 | 464 | // Check if we should update existing files based on user setting 465 | if (!this.settings.updateExistingFiles) { 466 | // User has disabled updates to existing files 467 | this.skippedFiles++; 468 | continue; 469 | } 470 | 471 | // Check for local changes to notes if bi-directional sync is enabled 472 | if (this.settings.syncNotesToHoarder) { 473 | const { currentNotes, originalNotes } = await this.extractNotesFromFile(fileName); 474 | const remoteNotes = bookmark.note || ""; 475 | 476 | // Initialize original_note if it's missing 477 | if (originalNotes === null && currentNotes !== null) { 478 | console.debug(`[Hoarder] original_note missing for ${fileName}`); 479 | 480 | // If current notes differ from remote, sync them 481 | if (currentNotes !== remoteNotes) { 482 | console.log(`[Hoarder] Local notes differ from remote, syncing to Hoarder`); 483 | const updated = await this.updateBookmarkInHoarder(bookmark.id, currentNotes); 484 | if (updated) { 485 | updatedInHoarder++; 486 | bookmark.note = currentNotes; // Update the bookmark object with local notes 487 | this.lastSyncedNotes = currentNotes; // Track this to avoid re-syncing 488 | 489 | // Now initialize original_note to match the synced version 490 | console.debug( 491 | `[Hoarder] Initializing original_note to synced value for ${fileName}` 492 | ); 493 | await this.app.fileManager.processFrontMatter(file, (frontmatter) => { 494 | frontmatter["original_note"] = currentNotes; 495 | }); 496 | } 497 | } else { 498 | // Current notes match remote, only update frontmatter if they're different 499 | // Since currentNotes === remoteNotes, we can skip the frontmatter update entirely 500 | // to avoid changing mtime. The frontmatter will be initialized on the next actual change. 501 | console.debug( 502 | `[Hoarder] Notes match remote, skipping original_note initialization to preserve mtime` 503 | ); 504 | } 505 | } else if ( 506 | currentNotes !== null && 507 | originalNotes !== null && 508 | currentNotes !== originalNotes && 509 | currentNotes !== remoteNotes 510 | ) { 511 | // Local notes have changed from original, update in Hoarder 512 | console.log(`[Hoarder] Local notes changed for ${fileName}, syncing to Hoarder`); 513 | const updated = await this.updateBookmarkInHoarder(bookmark.id, currentNotes); 514 | if (updated) { 515 | updatedInHoarder++; 516 | bookmark.note = currentNotes; // Update the bookmark object with local notes 517 | this.lastSyncedNotes = currentNotes; // Track this to avoid re-syncing 518 | } 519 | } 520 | } 521 | 522 | // Generate new content and compare with existing 523 | const newContent = await this.formatBookmarkAsMarkdown(bookmark, title, highlights); 524 | const existingContent = await this.app.vault.adapter.read(fileName); 525 | 526 | if (existingContent !== newContent) { 527 | // Content has actually changed, update the file 528 | await this.app.vault.adapter.write(fileName, newContent); 529 | totalBookmarks++; 530 | } else { 531 | // Content is identical, skip writing to preserve modification time 532 | this.skippedFiles++; 533 | } 534 | } 535 | } else { 536 | const content = await this.formatBookmarkAsMarkdown(bookmark, title, highlights); 537 | await this.app.vault.create(fileName, content); 538 | totalBookmarks++; 539 | } 540 | } 541 | } while (cursor); 542 | 543 | // Handle deleted/archived bookmarks 544 | const deletionResults = await this.handleDeletedAndArchivedBookmarks( 545 | localBookmarkFiles, 546 | activeBookmarkIds, 547 | archivedBookmarkIds 548 | ); 549 | 550 | // Update last sync timestamp 551 | this.settings.lastSyncTimestamp = Date.now(); 552 | await this.saveSettings(); 553 | 554 | const stats: SyncStats = { 555 | totalBookmarks, 556 | skippedFiles: this.skippedFiles, 557 | updatedInHoarder, 558 | excludedByTags, 559 | includedByTags, 560 | includedTagsEnabled: this.settings.includedTags.length > 0, 561 | skippedNoHighlights, 562 | deletionResults, 563 | }; 564 | const message = buildSyncMessage(stats); 565 | 566 | return { 567 | success: true, 568 | message, 569 | }; 570 | } catch (error) { 571 | console.error("Error syncing bookmarks:", error); 572 | return { 573 | success: false, 574 | message: `Error syncing: ${error.message}`, 575 | }; 576 | } finally { 577 | this.setSyncing(false); 578 | this.skippedFiles = 0; 579 | } 580 | } 581 | 582 | async formatBookmarkAsMarkdown( 583 | bookmark: HoarderBookmark, 584 | title: string, 585 | highlights?: HoarderHighlight[] 586 | ): Promise<string> { 587 | const url = 588 | bookmark.content.type === "link" ? bookmark.content.url : bookmark.content.sourceUrl; 589 | const description = 590 | bookmark.content.type === "link" ? bookmark.content.description : bookmark.content.text; 591 | const rawTags = bookmark.tags.map((tag) => tag.name); 592 | const tags = sanitizeTags(rawTags); 593 | 594 | // Handle images and assets first to collect frontmatter entries 595 | const { content: assetContent, frontmatter: assetsFm } = await processBookmarkAssets( 596 | this.app, 597 | bookmark, 598 | title, 599 | this.client, 600 | this.settings 601 | ); 602 | 603 | // Build top-level asset YAML entries (wikilinks only) 604 | let assetsYaml = ""; 605 | if (assetsFm) { 606 | const lines: string[] = []; 607 | if (assetsFm.image) lines.push(`image: ${assetsFm.image}`); 608 | if (assetsFm.banner) lines.push(`banner: ${assetsFm.banner}`); 609 | if (assetsFm.screenshot) lines.push(`screenshot: ${assetsFm.screenshot}`); 610 | if (assetsFm.full_page_archive) 611 | lines.push(`full_page_archive: ${assetsFm.full_page_archive}`); 612 | if (assetsFm.video) lines.push(`video: ${assetsFm.video}`); 613 | if (assetsFm.additional && assetsFm.additional.length > 0) { 614 | lines.push("additional:"); 615 | for (const link of assetsFm.additional) { 616 | lines.push(` - ${link}`); 617 | } 618 | } 619 | assetsYaml = lines.join("\n") + "\n"; 620 | } 621 | 622 | // Build tags YAML - only include if there are valid tags 623 | const tagsYaml = tags.length > 0 ? `tags:\n - ${tags.join("\n - ")}\n` : ""; 624 | 625 | let content = `--- 626 | bookmark_id: "${bookmark.id}" 627 | url: ${escapeYaml(url)} 628 | title: ${escapeYaml(title)} 629 | date: ${new Date(bookmark.createdAt).toISOString()} 630 | ${bookmark.modifiedAt ? `modified: ${new Date(bookmark.modifiedAt).toISOString()}\n` : ""}${tagsYaml}note: ${escapeYaml(bookmark.note)} 631 | original_note: ${escapeYaml(bookmark.note)} 632 | summary: ${escapeYaml(bookmark.summary)} 633 | ${assetsYaml} 634 | --- 635 | 636 | # ${title} 637 | `; 638 | 639 | // Append any asset content (images/links embeds) 640 | content += assetContent; 641 | 642 | // Add summary if available 643 | if (bookmark.summary) { 644 | content += `\n## Summary\n\n${bookmark.summary}\n`; 645 | } 646 | 647 | // Add description if available 648 | if (description) { 649 | content += `\n## Description\n\n${description}\n`; 650 | } 651 | 652 | // Add highlights if available and enabled 653 | if (highlights && highlights.length > 0 && this.settings.syncHighlights) { 654 | content += `\n## Highlights\n\n`; 655 | 656 | // Sort highlights by startOffset (position in document) 657 | const sortedHighlights = highlights.sort((a, b) => a.startOffset - b.startOffset); 658 | 659 | for (const highlight of sortedHighlights) { 660 | const date = new Date(highlight.createdAt).toLocaleDateString("en-US", { 661 | year: "numeric", 662 | month: "long", 663 | day: "numeric", 664 | }); 665 | 666 | content += `> [!karakeep-${highlight.color}] ${date}\n`; 667 | 668 | // Handle multi-line highlight text by prefixing each line with '> ' 669 | const highlightLines = highlight.text.split("\n"); 670 | for (const line of highlightLines) { 671 | content += `> ${line}\n`; 672 | } 673 | 674 | if (highlight.note && highlight.note.trim()) { 675 | content += `>\n`; 676 | // Handle multi-line notes by prefixing each line with '> ' 677 | const noteLines = highlight.note.split("\n"); 678 | for (let i = 0; i < noteLines.length; i++) { 679 | if (i === 0) { 680 | content += `> *Note: ${noteLines[i]}*\n`; 681 | } else { 682 | content += `> *${noteLines[i]}*\n`; 683 | } 684 | } 685 | } 686 | 687 | content += `\n`; 688 | } 689 | } 690 | 691 | // Always add Notes section 692 | content += `\n## Notes\n\n${bookmark.note || ""}\n`; 693 | 694 | // Add link if available (and it's not just an image) 695 | if (url && bookmark.content.type !== "asset") { 696 | content += `\n[Visit Link](${escapeMarkdownPath(url)})\n`; 697 | } 698 | const hoarderUrl = `${this.settings.apiEndpoint.replace("/api/v1", "/dashboard/preview")}/${bookmark.id}`; 699 | content += `\n[View in Hoarder](${escapeMarkdownPath(hoarderUrl)})`; 700 | 701 | return content; 702 | } 703 | 704 | private async handleFileModification(file: TFile) { 705 | try { 706 | console.debug(`[Hoarder] File modified: ${file.path}`); 707 | 708 | // Extract current and original notes 709 | const { currentNotes, originalNotes } = await this.extractNotesFromFile(file.path); 710 | 711 | // Convert null to empty string for comparison 712 | const currentNotesStr = currentNotes || ""; 713 | const originalNotesStr = originalNotes || ""; 714 | 715 | console.log( 716 | `[Hoarder] Current notes length: ${currentNotesStr.length}, Original notes length: ${originalNotesStr.length}` 717 | ); 718 | 719 | // Skip if we just synced these exact notes 720 | if (currentNotesStr === this.lastSyncedNotes) { 721 | console.log("[Hoarder] Skipping - notes match last synced version"); 722 | return; 723 | } 724 | 725 | // Get bookmark ID from frontmatter using MetadataCache 726 | const metadata = this.app.metadataCache.getFileCache(file)?.frontmatter; 727 | const bookmarkId = metadata?.bookmark_id; 728 | if (!bookmarkId) { 729 | console.log("[Hoarder] No bookmark_id found in frontmatter"); 730 | return; 731 | } 732 | 733 | console.log(`[Hoarder] Bookmark ID: ${bookmarkId}`); 734 | 735 | // If original_note is null/undefined, initialize it to current value from frontmatter 736 | // This handles files that were created before this fix or when updateExistingFiles was disabled 737 | if (originalNotes === null) { 738 | const frontmatterNote = metadata?.note || ""; 739 | console.log(`[Hoarder] original_note is null for ${file.path}`); 740 | 741 | // Check if current notes differ from what's in frontmatter 742 | if (currentNotesStr !== frontmatterNote) { 743 | console.log("[Hoarder] Notes have changed from frontmatter note"); 744 | const success = await this.updateBookmarkInHoarder(bookmarkId, currentNotesStr); 745 | if (success) { 746 | this.lastSyncedNotes = currentNotesStr; 747 | 748 | // Initialize original_note after successful sync 749 | setTimeout(async () => { 750 | try { 751 | const { currentNotes: latestNotes, originalNotes: currentOriginalNotes } = 752 | await this.extractNotesFromFile(file.path); 753 | // Only update if notes haven't changed AND original_note still needs initialization 754 | if (latestNotes === currentNotesStr && currentOriginalNotes !== currentNotesStr) { 755 | await this.app.fileManager.processFrontMatter(file, (frontmatter) => { 756 | frontmatter["original_note"] = currentNotesStr; 757 | }); 758 | console.debug("[Hoarder] Initialized and updated original_note in frontmatter"); 759 | } else if (currentOriginalNotes === currentNotesStr) { 760 | console.debug( 761 | "[Hoarder] original_note already initialized, skipping frontmatter update" 762 | ); 763 | } 764 | } catch (error) { 765 | console.error("[Hoarder] Error updating frontmatter:", error); 766 | } 767 | }, 5000); 768 | 769 | new Notice("Notes synced to Hoarder"); 770 | } else { 771 | console.error("[Hoarder] Failed to update bookmark in Hoarder"); 772 | } 773 | } else { 774 | // Notes match frontmatter note, skip initialization to preserve mtime 775 | // The field will be initialized on the next actual change 776 | console.debug( 777 | "[Hoarder] Notes match frontmatter, skipping original_note initialization to preserve mtime" 778 | ); 779 | } 780 | return; 781 | } 782 | 783 | // Only update if notes have changed 784 | if (currentNotesStr !== originalNotesStr) { 785 | console.log("[Hoarder] Notes have changed, syncing to Hoarder"); 786 | const updated = await this.updateBookmarkInHoarder(bookmarkId, currentNotesStr); 787 | if (updated) { 788 | // Store these notes as the last synced version 789 | this.lastSyncedNotes = currentNotesStr; 790 | console.log("[Hoarder] Successfully synced notes to Hoarder"); 791 | 792 | // Schedule frontmatter update for later 793 | setTimeout(async () => { 794 | try { 795 | // Re-read the file to get the latest content 796 | const { currentNotes: latestNotes, originalNotes: currentOriginalNotes } = 797 | await this.extractNotesFromFile(file.path); 798 | 799 | // Only update frontmatter if notes haven't changed since sync AND original_note needs updating 800 | if (latestNotes === currentNotesStr && currentOriginalNotes !== currentNotesStr) { 801 | await this.app.fileManager.processFrontMatter(file, (frontmatter) => { 802 | frontmatter["original_note"] = currentNotesStr; 803 | }); 804 | console.debug("[Hoarder] Updated original_note in frontmatter"); 805 | } else if (latestNotes !== currentNotesStr) { 806 | console.debug("[Hoarder] Notes changed again, skipping frontmatter update"); 807 | } else { 808 | console.debug( 809 | "[Hoarder] original_note already up to date, skipping frontmatter update" 810 | ); 811 | } 812 | } catch (error) { 813 | console.error("[Hoarder] Error updating frontmatter:", error); 814 | } 815 | }, 5000); // Wait 5 seconds before updating frontmatter 816 | 817 | new Notice("Notes synced to Hoarder"); 818 | } else { 819 | console.error("[Hoarder] Failed to update bookmark in Hoarder"); 820 | new Notice("Failed to sync notes to Hoarder"); 821 | } 822 | } else { 823 | console.log("[Hoarder] Notes unchanged, no sync needed"); 824 | } 825 | } catch (error) { 826 | console.error("[Hoarder] Error handling file modification:", error); 827 | new Notice("Failed to sync notes to Hoarder"); 828 | } 829 | } 830 | 831 | private initializeClient() { 832 | if (!this.settings.apiKey || !this.settings.apiEndpoint) { 833 | this.client = null; 834 | } else { 835 | this.client = new HoarderApiClient( 836 | this.settings.apiEndpoint, 837 | this.settings.apiKey, 838 | this.settings.useObsidianRequest 839 | ); 840 | } 841 | } 842 | } 843 | --------------------------------------------------------------------------------