├── .npmrc ├── .eslintignore ├── versions.json ├── .editorconfig ├── manifest.json ├── tsconfig.json ├── version-bump.mjs ├── .eslintrc ├── package.json ├── LICENSE ├── esbuild.config.mjs ├── README.md └── main.ts /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.2.2": "0.15.0" 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "floccus-bookmarks-to-markdown", 3 | "name": "Floccus Bookmarks to Markdown", 4 | "version": "0.2.2", 5 | "minAppVersion": "0.15.0", 6 | "description": "Bring your Bookmarks from Floccus to your Obsidian", 7 | "author": "mddevils", 8 | "authorUrl": "https://github.com/mddevils/", 9 | "fundingUrl": "https://ko-fi.com/mddevils", 10 | "isDesktopOnly": true 11 | } 12 | -------------------------------------------------------------------------------- /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 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7" 19 | ] 20 | }, 21 | "include": [ 22 | "**/*.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off", 22 | "@typescript-eslint/no-explicit-any": "off" 23 | } 24 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "floccus-bookmarks-to-markdown", 3 | "version": "0.2.1", 4 | "description": "Bring your Bookmarks from Floccus to your Obsidian", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/node": "^16.11.6", 16 | "@types/xml2js": "^0.4.11", 17 | "@typescript-eslint/eslint-plugin": "5.29.0", 18 | "@typescript-eslint/parser": "5.29.0", 19 | "builtin-modules": "3.3.0", 20 | "esbuild": "0.17.3", 21 | "obsidian": "latest", 22 | "tslib": "2.4.0", 23 | "typescript": "4.7.4" 24 | }, 25 | "dependencies": { 26 | "xml2js": "^0.6.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 mddevils 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 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | "@codemirror/autocomplete", 24 | "@codemirror/collab", 25 | "@codemirror/commands", 26 | "@codemirror/language", 27 | "@codemirror/lint", 28 | "@codemirror/search", 29 | "@codemirror/state", 30 | "@codemirror/view", 31 | "@lezer/common", 32 | "@lezer/highlight", 33 | "@lezer/lr", 34 | ...builtins], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "main.js", 41 | }); 42 | 43 | if (prod) { 44 | await context.rebuild(); 45 | process.exit(0); 46 | } else { 47 | await context.watch(); 48 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GitHub Release](https://img.shields.io/github/release/mddevils/floccus-bookmarks-to-markdown?include_prereleases=&sort=semver&color=blue)](https://github.com/mddevils/floccus-bookmarks-to-markdown/releases/) 2 | [![License](https://img.shields.io/badge/License-MIT-blue)](#license) 3 | # Floccus Bookmarks To Markdown Plugin 4 | 5 | Obsidian Floccus Bookmarks is a plugin for [Obsidian](https://obsidian.md/) that allows you to import and convert bookmarks from an XBEL file into a Markdown file. This plugin is inspired by [Floccus](https://floccus.org/), a browser extension that synchronizes bookmarks across different browsers. 6 | 7 | ## Installation 8 | 9 | 1. Download the plugin files and place them in your Obsidian vault's plugins folder. 10 | 2. Enable the plugin in Obsidian's settings. 11 | 12 | ## Usage 13 | 14 | 1. Configure the plugin settings in the Obsidian settings menu under "Floccus Bookmarks To Markdown Settings". 15 | 2. In the left ribbon of Obsidian, click on the bookmark icon to import and convert the bookmarks from the XBEL file. 16 | 3. The generated Markdown file will be created in the specified folder path with the specified filename. 17 | 18 | ## Plugin Settings 19 | 20 | The plugin settings can be accessed in the Obsidian settings menu under "Floccus Bookmarks To Markdown Settings". The following settings are available: 21 | 22 | - **XBEL Absolute Folder Path**: The absolute folder path of the XBEL file. 23 | - **XBEL Filename**: The filename of the XBEL file. 24 | - **MD Vault Folder Path**: The vault folder for the generated Markdown file. 25 | - **MD Filename**: The filename for the generated Markdown file. 26 | - **Backup Folder Path**: The folder path for the backup files. 27 | - **Number of Backups to Keep**: The number of backup files to keep. 28 | - **Automatic Update Bookmarks**: Enable automatic updating of bookmarks. 29 | - **Update Interval (in seconds)**: Specify the interval for automatic updates. (Automatic Update Bookmarks must be enabled.) 30 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { App, Notice, Plugin, PluginSettingTab, Setting, TFile, TFolder } from 'obsidian'; 2 | import { join, parse } from 'path'; 3 | import { promises as fsPromises } from 'fs'; 4 | import { parseStringPromise } from 'xml2js'; 5 | 6 | interface fbmPluginSettings { 7 | xbelFolderPath: string; 8 | xbelFileName: string; 9 | mdFolderPath: string; 10 | mdFileName: string; 11 | backupFolderPath: string; 12 | keepCount: number; 13 | automaticUpdate: boolean; 14 | updateInterval: number; 15 | } 16 | 17 | const DEFAULT_SETTINGS: fbmPluginSettings = { 18 | xbelFolderPath: '', 19 | xbelFileName: 'bookmarks.xbel', 20 | mdFolderPath: '', 21 | mdFileName: 'bookmarks.md', 22 | backupFolderPath: '', 23 | keepCount: 5, 24 | automaticUpdate: false, 25 | updateInterval: 900, 26 | } 27 | 28 | export default class fbmPlugin extends Plugin { 29 | settings: fbmPluginSettings; 30 | 31 | async onload() { 32 | await this.loadSettings(); 33 | 34 | // This creates an icon in the left ribbon. 35 | const bookmarkIconEl = this.addRibbonIcon('bookmark', 'Floccus Bookmarks to Markdown', (evt: MouseEvent) => { 36 | // Called when the user clicks the icon. 37 | this.processXBELFileData(); 38 | new Notice('Floccus Bookmarks Markdown Updated!'); 39 | }); 40 | 41 | // Perform additional things with the ribbon 42 | bookmarkIconEl.addClass('floccus-bookmarks-to-md-icon'); 43 | 44 | // This adds a settings tab so the user can configure various aspects of the plugin 45 | this.addSettingTab(new FBMSettingTab(this.app, this)); 46 | 47 | // Call the processXBELFileData function based on the automatic update setting 48 | if (this.settings.automaticUpdate) { 49 | const updateInterval = this.settings.updateInterval * 1000; // Convert seconds to milliseconds 50 | this.registerInterval(window.setInterval(() => this.processXBELFileData(), updateInterval)); 51 | } 52 | 53 | // Call the processXBELFileData function 54 | this.processXBELFileData(); 55 | } 56 | 57 | onunload() {} 58 | 59 | async loadSettings() { 60 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 61 | } 62 | 63 | async saveSettings() { 64 | await this.saveData(this.settings); 65 | this.processXBELFileData(); 66 | } 67 | 68 | async processXBELFileData() { 69 | const { 70 | xbelFolderPath, 71 | xbelFileName, 72 | mdFolderPath, 73 | mdFileName, 74 | backupFolderPath, 75 | keepCount, 76 | } = this.settings; 77 | 78 | // Construct the full paths 79 | const xbelFilePath: string = join(xbelFolderPath, xbelFileName); 80 | //const xbelFilePath: string = path.join(xbelFolderPath, xbelFileName); 81 | const mdFilePath = `${mdFolderPath}/${mdFileName}`; 82 | const mdFile = this.app.vault.getAbstractFileByPath(mdFilePath) as TFile; 83 | 84 | // Create the output folder if it doesn't exist 85 | const mdFolder = this.app.vault.getAbstractFileByPath(mdFolderPath) as TFolder; 86 | if (!mdFolder) { 87 | await this.app.vault.createFolder(mdFolderPath); 88 | } 89 | 90 | // Check if the output file already exists and backup if necessary 91 | if (mdFile) { 92 | await this.backupExistingFile(mdFile, backupFolderPath); 93 | } 94 | 95 | // Delete old backups, keeping only the specified number of most recent ones 96 | this.deleteOldBackups(backupFolderPath, keepCount); 97 | 98 | try { 99 | // Read the XBEL file 100 | const xbelData = await fsPromises.readFile(xbelFilePath, 'utf8'); 101 | 102 | // Parse the XBEL file 103 | const result = await parseStringPromise(xbelData); 104 | 105 | // Generate the folder structure 106 | const mdData = this.writeFolderStructure(result.xbel); 107 | 108 | // Create the Markdown file with the generated data 109 | await this.app.vault.create(mdFilePath, mdData); 110 | } catch (error) { 111 | console.error('An error occurred:', error); 112 | } 113 | } 114 | 115 | async backupExistingFile(file: TFile, backupFolderPath: string): Promise { 116 | // Generate a date-time suffix in the format 'yyyymmddHHMMSS' using the current timezone 117 | const now = new Date(); 118 | const timeZoneOffset = now.getTimezoneOffset() * 60000; // Convert minutes to milliseconds 119 | const localTime = new Date(now.getTime() - timeZoneOffset); 120 | const dateSuffix: string = localTime.toISOString().slice(0, 19).replace(/[-T:]/g, ''); 121 | 122 | // Create the backup folder if it doesn't exist 123 | const backupFolder = this.app.vault.getAbstractFileByPath(backupFolderPath) as TFolder; 124 | if (!backupFolder) { 125 | await this.app.vault.createFolder(backupFolderPath); 126 | } 127 | 128 | // Create a new file name with the date-time suffix 129 | const fileName = file.basename; 130 | const fileExtension = file.extension; 131 | const backupFileName = `${parse(fileName).name}-${dateSuffix}.${fileExtension}`; 132 | const backupFilePath = `${backupFolderPath}/${backupFileName}`; 133 | 134 | // Copy the existing file to the backup file 135 | await this.app.vault.rename(file, backupFilePath); 136 | 137 | } 138 | 139 | deleteOldBackups(backupFolderPath: string, keepCount: number): void { 140 | // Get all files in the backup folder 141 | const backupFolder = this.app.vault.getAbstractFileByPath(backupFolderPath) as TFolder; 142 | if (!backupFolder) { 143 | return; 144 | } 145 | 146 | const backupFiles = backupFolder.children as TFile[]; 147 | 148 | // Sort the files by modification time in ascending order 149 | backupFiles.sort((a, b) => { 150 | const statA = a.stat; 151 | const statB = b.stat; 152 | return statA.mtime - statB.mtime; 153 | }); 154 | 155 | // Delete files exceeding the keep count 156 | const filesToDelete = backupFiles.length - keepCount+1; 157 | if (filesToDelete > 0) { 158 | const filesToDeleteList = backupFiles.slice(0, filesToDelete); 159 | filesToDeleteList.forEach(async (file) => { 160 | await this.app.vault.trash(file, false); 161 | }); 162 | } 163 | } 164 | 165 | writeFolderStructure(element: any, level = 0): string { 166 | let data = ''; 167 | 168 | // Process child elements (folders and bookmarks) 169 | if (typeof element === 'object') { 170 | if (element.hasOwnProperty('folder') || element.hasOwnProperty('bookmark')) { 171 | const folderTitle: string = element.title ? element.title[0] : 'Bookmarks'; 172 | 173 | if (level !== 0) { 174 | data += '\n'; 175 | } 176 | 177 | data += '#'.repeat(level+1) + ' ' + folderTitle + '\n'; 178 | } 179 | 180 | if (Array.isArray(element.bookmark)) { 181 | // Process bookmarks 182 | element.bookmark.forEach((bookmark: any) => { 183 | const link: string = bookmark.$.href; 184 | const title: string = bookmark.title[0]; 185 | const linkTitle = `[${title}](${link})`; 186 | data += linkTitle + '\n'; 187 | }); 188 | } 189 | 190 | if (Array.isArray(element.folder)) { 191 | // Recursively process subfolders 192 | element.folder.forEach((subfolder: any) => { 193 | data += this.writeFolderStructure(subfolder, level + 1); 194 | }); 195 | } 196 | } 197 | 198 | return data; 199 | } 200 | 201 | } 202 | 203 | class FBMSettingTab extends PluginSettingTab { 204 | plugin: fbmPlugin; 205 | 206 | constructor(app: App, plugin: fbmPlugin) { 207 | super(app, plugin); 208 | this.plugin = plugin; 209 | } 210 | 211 | display(): void { 212 | const { containerEl } = this; 213 | 214 | containerEl.empty(); 215 | 216 | new Setting(containerEl) 217 | .setName('Xbel absolute folder path') 218 | .setDesc('The absolute folder path of the xbel file.') 219 | .addText((text) => 220 | text 221 | .setValue(this.plugin.settings.xbelFolderPath) 222 | .onChange(async (value) => { 223 | this.plugin.settings.xbelFolderPath = value; 224 | await this.plugin.saveSettings(); 225 | }) 226 | ); 227 | 228 | new Setting(containerEl) 229 | .setName('Xbel filename') 230 | .setDesc('The filename of the xbel file.') 231 | .addText((text) => 232 | text 233 | .setValue(this.plugin.settings.xbelFileName) 234 | .onChange(async (value) => { 235 | this.plugin.settings.xbelFileName = value; 236 | await this.plugin.saveSettings(); 237 | }) 238 | ); 239 | 240 | new Setting(containerEl) 241 | .setName('Markdown vault folder path') 242 | .setDesc('The vault folder for the generated markdown file.') 243 | .addText((text) => 244 | text 245 | .setValue(this.plugin.settings.mdFolderPath) 246 | .onChange(async (value) => { 247 | this.plugin.settings.mdFolderPath = value; 248 | await this.plugin.saveSettings(); 249 | }) 250 | ); 251 | 252 | new Setting(containerEl) 253 | .setName('Markdown file') 254 | .setDesc('The filename for the generated markdown file.') 255 | .addText((text) => 256 | text 257 | .setValue(this.plugin.settings.mdFileName) 258 | .onChange(async (value) => { 259 | this.plugin.settings.mdFileName = value; 260 | await this.plugin.saveSettings(); 261 | }) 262 | ); 263 | 264 | new Setting(containerEl) 265 | .setName('Backup folder path') 266 | .setDesc('The vault folder for the backup files.') 267 | .addText((text) => 268 | text 269 | .setValue(this.plugin.settings.backupFolderPath) 270 | .onChange(async (value) => { 271 | this.plugin.settings.backupFolderPath = value; 272 | await this.plugin.saveSettings(); 273 | }) 274 | ); 275 | 276 | new Setting(containerEl) 277 | .setName('Number of backups to keep') 278 | .setDesc('The number of backup files to keep.') 279 | .addText((text) => 280 | text 281 | .setValue(String(this.plugin.settings.keepCount)) 282 | .onChange(async (value) => { 283 | const keepCount = parseInt(value, 10); 284 | if (!isNaN(keepCount)) { 285 | this.plugin.settings.keepCount = keepCount; 286 | await this.plugin.saveSettings(); 287 | } 288 | }) 289 | ); 290 | 291 | new Setting(containerEl) 292 | .setName('Automatic update bookmarks') 293 | .setDesc('Enable automatic updating of bookmarks.') 294 | .addToggle((toggle) => 295 | toggle 296 | .setValue(this.plugin.settings.automaticUpdate) 297 | .onChange(async (value) => { 298 | this.plugin.settings.automaticUpdate = value; 299 | await this.plugin.saveSettings(); 300 | }) 301 | ); 302 | 303 | new Setting(containerEl) 304 | .setName('Update interval (in seconds)') 305 | .setDesc('Specify the interval for automatic updates. Automatic update bookmarks must be on.') 306 | .addText((text) => 307 | text 308 | .setValue(String(this.plugin.settings.updateInterval)) 309 | .onChange(async (value) => { 310 | const updateInterval = parseInt(value, 10); 311 | if (!isNaN(updateInterval)) { 312 | this.plugin.settings.updateInterval = updateInterval; 313 | await this.plugin.saveSettings(); 314 | } 315 | }) 316 | ); 317 | } 318 | } --------------------------------------------------------------------------------