initial release v1.0.0

This commit is contained in:
Serhii Karavashkin 2026-04-20 20:14:16 +03:00
commit 731850d7ec
34 changed files with 10658 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
dist/
.DS_Store

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Kharizma & Latreia
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

52
README.md Normal file
View file

@ -0,0 +1,52 @@
# Alchemist for Obsidian
Alchemist is a modular super-plugin designed for seamless data transmutation and vault hygiene. It consolidates multiple powerful features into a single, high-performance toolkit for Obsidian power users.
## Features
### TextBundle Export/Import
Preserve the sovereignty of your data. Export your notes with all linked assets (images, audio, PDFs) into a standardized .textbundle or .textpack format. Perfect for sharing notes with other apps or creating portable backups.
- Recursion Depth: Include linked notes up to 5 levels deep.
- Conflict Resolution: Smart hashing to detect identical files and avoid duplicates.
- TextPack Support: Compressed multi-note archives.
### Audio Converter (Desktop Only)
Transform your voice recordings into high-quality MP3s or FLACs without leaving Obsidian.
- Meta-Sync: Automatically extracts ID3 tags (Title, Artist, Date, Genre) from the note's YAML frontmatter.
- Cleanup: Option to delete the original heavy .webm files after successful conversion.
- FFmpeg Powered: Uses the industry-standard FFmpeg engine for crystal-clear quality.
### Dataview Table Export
Turn your dynamic Dataview queries into static, shareable CSV files with a single click.
- One-Click Export: Adds a subtle CSV button to every Dataview table.
- Silent Save: Configure a target folder in your Vault for distraction-free exporting.
- Traceability: Injects source note information and export timestamps into the CSV.
### Smart Paste Hygiene
Keep your Vault clean from the "digital footprint" of the web.
- UTM Stripping: Automatically removes tracking parameters from pasted URLs.
- Pure Interception: Surgical cleaning that doesn't break Obsidian's native formatting or code blocks.
- Manual Cleanup: A dedicated command to clean an entire document from trackers in one go.
---
## Installation
1. Install via Community Plugins in Obsidian (Search for "Alchemist").
2. For Audio Converter: Ensure ffmpeg is installed on your system and available in your PATH.
3. Enable the modules you need in the Alchemist settings tab.
---
## Support & Sponsorship
Alchemist is a labor of love, dedicated to the pursuit of digital purity and knowledge sovereignty. If it helps you in your workflow, consider supporting its continued evolution:
- [Buy me a coffee (Ko-fi)](https://ko-fi.com/kharizma)
- [Support on Boosty](https://boosty.to/kharizma)
---
## License
This project is licensed under the MIT License.

2349
dist/main.js vendored Normal file

File diff suppressed because it is too large Load diff

10
dist/manifest.json vendored Normal file
View file

@ -0,0 +1,10 @@
{
"id": "obsidian-alchemist",
"name": "Alchemist",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "Modular super-plugin for TextBundle, Audio conversion, and Dataview exports.",
"author": "Kharizma & Latreia",
"authorUrl": "https://github.com/bonifat",
"isDesktopOnly": true
}

17
dist/styles.css vendored Normal file
View file

@ -0,0 +1,17 @@
/* Alchemist Styles */
.alchemist-export-btn {
opacity: 0.6;
transition: opacity 0.2s ease-in-out;
background: var(--interactive-accent);
color: var(--text-on-accent);
border: none;
border-radius: 4px;
padding: 2px 6px;
cursor: pointer;
}
.alchemist-export-btn:hover {
opacity: 1;
background: var(--interactive-accent-hover);
}

48
esbuild.config.mjs Normal file
View file

@ -0,0 +1,48 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === "production");
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "dist/main.js",
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

10
jest.config.js Normal file
View file

@ -0,0 +1,10 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.ts'],
moduleNameMapper: {
'^obsidian$': '<rootDir>/../../node_modules/obsidian/obsidian.d.ts'
},
verbose: true
};

67
main.ts Normal file
View file

@ -0,0 +1,67 @@
import { Plugin } from 'obsidian';
import { AlchemistSettings, DEFAULT_SETTINGS, AlchemistSettingTab } from './settings';
import { IAlchemistModule, AlchemistContext } from './src/core/IAlchemistModule';
import { ElectronSystemAdapter } from './src/core/SystemAdapter';
// Modules
import { TextBundleModule } from './src/features/textbundle/TextBundleModule';
import { SmartPasteModule } from './src/features/paste-cleaner/SmartPasteModule';
import { DataviewModule } from './src/features/dataview-export/DataviewModule';
import { AudioModule } from './src/features/audio-converter/AudioModule';
export default class AlchemistPlugin extends Plugin {
settings!: AlchemistSettings;
private modules: IAlchemistModule[] = [];
private systemAdapter = new ElectronSystemAdapter();
async onload() {
console.log('Loading Alchemist (Modular Purity Protocol)...');
await this.loadSettings();
this.addSettingTab(new AlchemistSettingTab(this.app, this));
const context: AlchemistContext = {
app: this.app,
settings: this.settings,
system: this.systemAdapter,
plugin: this
};
// Initialize Modules
this.modules = [
new TextBundleModule(),
new SmartPasteModule(),
new DataviewModule(),
new AudioModule()
];
for (const module of this.modules) {
try {
await module.load(context);
console.log(`Alchemist: Module [${module.id}] loaded`);
} catch (e) {
console.error(`Alchemist: Failed to load module [${module.id}]`, e);
}
}
console.log('Alchemist: Modular Purity Protocol initiated');
}
async onunload() {
for (const module of this.modules) {
await module.unload();
}
console.log('Alchemist: Modular Purity Protocol terminated');
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
// Notify all modules about settings change
for (const module of this.modules) {
await module.onSettingsChange(this.settings);
}
}
}

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "obsidian-alchemist",
"name": "Alchemist",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "Modular super-plugin for TextBundle, Audio conversion, and Dataview exports.",
"author": "Kharizma & Latreia",
"authorUrl": "https://github.com/bonifat",
"isDesktopOnly": true
}

6056
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

39
package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "obsidian-alchemist",
"version": "1.0.0",
"description": "The Alchemist: A modular super-plugin for TextBundle, Audio conversion, and Data purity.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit && node esbuild.config.mjs production && cp manifest.json styles.css dist/",
"test": "jest",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [
"obsidian",
"obsidian-plugin",
"textbundle",
"audio",
"converter",
"dataview",
"cleaner"
],
"author": "Kharizma & Latreia",
"license": "MIT",
"devDependencies": {
"@types/jest": "^30.0.0",
"@types/node": "^16.11.21",
"@typescript-eslint/eslint-plugin": "^5.10.1",
"@typescript-eslint/parser": "^5.10.1",
"builtin-modules": "^3.2.0",
"esbuild": "^0.28.0",
"jest": "^30.3.0",
"obsidian": "latest",
"ts-jest": "^29.4.9",
"tslib": "2.3.1",
"typescript": "^5.9.3"
},
"dependencies": {
"fflate": "^0.7.3"
}
}

405
settings.ts Normal file
View file

@ -0,0 +1,405 @@
import { App, PluginSettingTab, Setting } from 'obsidian';
import AlchemistPlugin from './main';
export interface AlchemistSettings {
// Global Feature Toggles
enableTextBundle: boolean;
enableAudioConverter: boolean;
enableDataviewExport: boolean;
enableSmartPaste: boolean;
// Global Persistence
lastDialogPath: string;
// Module 1: TextBundle
defaultImportPath: string;
includeImages: boolean;
includeAudio: boolean;
includeVideo: boolean;
includePDF: boolean;
recursionDepth: number;
compressionFormat: 'zip' | 'textpack' | 'textbundle';
conflictStrategy: 'skip' | 'overwrite' | 'rename';
restoreFolderStructure: boolean;
// Module 2: Audio Converter
targetFolderStrategy: 'source' | 'specific' | 'dialog';
specificTargetFolder: string;
deleteOriginalWebM: boolean;
defaultAudioOutputFormat: 'mp3' | 'wav' | 'ogg' | 'm4a' | 'flac';
defaultAudioArtist: string;
defaultAudioAlbum: string;
defaultAudioGenre: string;
// Module 3: Dataview
dataviewExportStrategy: 'vault' | 'dialog';
dataviewVaultPath: string;
// Module 4: Smart Paste
stripTrackingParameters: boolean;
cleanMsoHtml: boolean;
}
export const DEFAULT_SETTINGS: AlchemistSettings = {
enableTextBundle: true,
enableAudioConverter: true,
enableDataviewExport: true,
enableSmartPaste: true,
lastDialogPath: '',
defaultImportPath: '',
includeImages: true,
includeAudio: true,
includeVideo: true,
includePDF: true,
recursionDepth: 0,
compressionFormat: 'zip',
conflictStrategy: 'rename',
restoreFolderStructure: true,
targetFolderStrategy: 'source',
specificTargetFolder: '',
deleteOriginalWebM: false,
defaultAudioOutputFormat: 'mp3',
defaultAudioArtist: 'Alchemist',
defaultAudioAlbum: 'Obsidian Vault',
defaultAudioGenre: 'Voice Note',
dataviewExportStrategy: 'dialog',
dataviewVaultPath: 'Exports',
stripTrackingParameters: true,
cleanMsoHtml: true,
}
export class AlchemistSettingTab extends PluginSettingTab {
plugin: AlchemistPlugin;
constructor(app: App, plugin: AlchemistPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'Alchemist: Central Command' });
// --- SECTION: GLOBAL ---
containerEl.createEl('h3', { text: 'Core Modules' });
new Setting(containerEl)
.setName('Enable TextBundle')
.setDesc('Export/Import notes as TextBundle.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableTextBundle)
.onChange(async (value) => {
this.plugin.settings.enableTextBundle = value;
await this.plugin.saveSettings();
this.display();
}));
new Setting(containerEl)
.setName('Enable Audio Converter')
.setDesc('Convert WebM recordings to MP3/WAV/OGG (Desktop).')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableAudioConverter)
.onChange(async (value) => {
this.plugin.settings.enableAudioConverter = value;
await this.plugin.saveSettings();
this.display();
}));
new Setting(containerEl)
.setName('Enable Dataview Export')
.setDesc('Add CSV export button to Dataview tables.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableDataviewExport)
.onChange(async (value) => {
this.plugin.settings.enableDataviewExport = value;
await this.plugin.saveSettings();
this.display();
}));
new Setting(containerEl)
.setName('Enable Smart Paste')
.setDesc('Clean clipboard content from trackers and junk HTML.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableSmartPaste)
.onChange(async (value) => {
this.plugin.settings.enableSmartPaste = value;
await this.plugin.saveSettings();
this.display();
}));
// --- SECTION: TEXTBUNDLE ---
if (this.plugin.settings.enableTextBundle) {
containerEl.createEl('h3', { text: 'TextBundle Strategy' });
new Setting(containerEl)
.setName('Default Import Path')
.setDesc('Folder where TextBundles will be unpacked by default.')
.addText(text => text
.setPlaceholder('e.g. Imports/TextBundle')
.setValue(this.plugin.settings.defaultImportPath)
.onChange(async (value) => {
this.plugin.settings.defaultImportPath = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Include Images')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.includeImages)
.onChange(async (value) => {
this.plugin.settings.includeImages = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Include Audio')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.includeAudio)
.onChange(async (value) => {
this.plugin.settings.includeAudio = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Include Video')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.includeVideo)
.onChange(async (value) => {
this.plugin.settings.includeVideo = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Include PDFs')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.includePDF)
.onChange(async (value) => {
this.plugin.settings.includePDF = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Recursion Depth')
.setDesc('How many levels of linked notes to include (0 = current note only).')
.addSlider(slider => slider
.setLimits(0, 5, 1)
.setValue(this.plugin.settings.recursionDepth)
.setDynamicTooltip()
.onChange(async (value) => {
this.plugin.settings.recursionDepth = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Compression Format')
.setDesc('Target file extension for exports.')
.addDropdown(dropdown => dropdown
.addOption('zip', '.zip (Standard)')
.addOption('textbundle', '.textbundle')
.addOption('textpack', '.textpack')
.setValue(this.plugin.settings.compressionFormat)
.onChange(async (value: any) => {
this.plugin.settings.compressionFormat = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Conflict Strategy')
.setDesc('What to do if a file with the same name already exists during import.')
.addDropdown(dropdown => dropdown
.addOption('rename', 'Auto-rename (Versioning)')
.addOption('skip', 'Skip Existing')
.addOption('overwrite', 'Overwrite (CAUTION)')
.setValue(this.plugin.settings.conflictStrategy)
.onChange(async (value: any) => {
this.plugin.settings.conflictStrategy = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Restore Folder Structure')
.setDesc('Try to recreate the original vault path using metadata.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.restoreFolderStructure)
.onChange(async (value) => {
this.plugin.settings.restoreFolderStructure = value;
await this.plugin.saveSettings();
}));
}
// --- SECTION: AUDIO ---
if (this.plugin.settings.enableAudioConverter) {
containerEl.createEl('h3', { text: 'Audio Transformation' });
new Setting(containerEl)
.setName('Target Folder Strategy')
.setDesc('Where to save converted files.')
.addDropdown(dropdown => dropdown
.addOption('source', 'Same as source')
.addOption('specific', 'Specific folder')
.addOption('dialog', 'Always ask with dialog')
.setValue(this.plugin.settings.targetFolderStrategy)
.onChange(async (value: any) => {
this.plugin.settings.targetFolderStrategy = value;
await this.plugin.saveSettings();
this.display(); // Refresh to show/hide specific folder setting
}));
if (this.plugin.settings.targetFolderStrategy === 'specific') {
new Setting(containerEl)
.setName('Specific Target Folder')
.addText(text => text
.setValue(this.plugin.settings.specificTargetFolder)
.onChange(async (value) => {
this.plugin.settings.specificTargetFolder = value;
await this.plugin.saveSettings();
}));
}
new Setting(containerEl)
.setName('Default Output Format')
.setDesc('Target format for audio conversion.')
.addDropdown(dropdown => dropdown
.addOption('mp3', 'MP3')
.addOption('wav', 'WAV')
.addOption('ogg', 'OGG')
.addOption('m4a', 'M4A')
.addOption('flac', 'FLAC')
.setValue(this.plugin.settings.defaultAudioOutputFormat)
.onChange(async (value: any) => {
this.plugin.settings.defaultAudioOutputFormat = value;
await this.plugin.saveSettings();
this.display();
}));
new Setting(containerEl)
.setName('Delete original WebM')
.setDesc('Remove the original file after successful conversion.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.deleteOriginalWebM)
.onChange(async (value) => {
this.plugin.settings.deleteOriginalWebM = value;
await this.plugin.saveSettings();
}));
containerEl.createEl('h4', { text: 'Default Metadata' });
new Setting(containerEl)
.setName('Default Artist')
.addText(text => text
.setValue(this.plugin.settings.defaultAudioArtist)
.onChange(async (value) => {
this.plugin.settings.defaultAudioArtist = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Default Album')
.addText(text => text
.setValue(this.plugin.settings.defaultAudioAlbum)
.onChange(async (value) => {
this.plugin.settings.defaultAudioAlbum = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Default Genre')
.addText(text => text
.setValue(this.plugin.settings.defaultAudioGenre)
.onChange(async (value) => {
this.plugin.settings.defaultAudioGenre = value;
await this.plugin.saveSettings();
}));
}
// --- SECTION: DATAVIEW ---
if (this.plugin.settings.enableDataviewExport) {
containerEl.createEl('h3', { text: 'Dataview Export' });
new Setting(containerEl)
.setName('Export Strategy')
.setDesc('How to handle CSV generation.')
.addDropdown(dropdown => dropdown
.addOption('dialog', 'System Save Dialog')
.addOption('vault', 'Silent Save to Vault')
.setValue(this.plugin.settings.dataviewExportStrategy)
.onChange(async (value: any) => {
this.plugin.settings.dataviewExportStrategy = value;
await this.plugin.saveSettings();
this.display();
}));
if (this.plugin.settings.dataviewExportStrategy === 'vault') {
new Setting(containerEl)
.setName('Vault Target Folder')
.setDesc('Folder where Dataview CSVs will be saved.')
.addText(text => text
.setPlaceholder('Exports')
.setValue(this.plugin.settings.dataviewVaultPath)
.onChange(async (value) => {
this.plugin.settings.dataviewVaultPath = value;
await this.plugin.saveSettings();
}));
}
}
// --- SECTION: SMART PASTE ---
if (this.plugin.settings.enableSmartPaste) {
containerEl.createEl('h3', { text: 'Hygiene & Sanitization (Paste)' });
new Setting(containerEl)
.setName('Strip Marketing Trackers')
.setDesc('Remove UTM, fbclid, and other trackers from pasted URLs.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.stripTrackingParameters)
.onChange(async (value) => {
this.plugin.settings.stripTrackingParameters = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Clean HTML Artifacts')
.setDesc('Remove proprietary meta-tags from Google Docs/MS Office pastes.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.cleanMsoHtml)
.onChange(async (value) => {
this.plugin.settings.cleanMsoHtml = value;
await this.plugin.saveSettings();
}));
}
// --- SECTION: SUPPORT ---
containerEl.createEl('hr');
const supportDiv = containerEl.createDiv({ cls: 'alchemist-support-container' });
supportDiv.style.textAlign = 'center';
supportDiv.style.padding = '20px';
supportDiv.createEl('h3', { text: 'Support the Alchemist' });
supportDiv.createEl('p', { text: 'If you find this tool useful, consider supporting its development.' });
const buttonsDiv = supportDiv.createDiv();
buttonsDiv.style.display = 'flex';
buttonsDiv.style.justifyContent = 'center';
buttonsDiv.style.gap = '15px';
const kofiBtn = buttonsDiv.createEl('a', {
href: 'https://ko-fi.com/kharizma',
cls: 'alchemist-donate-btn'
});
kofiBtn.innerText = 'Buy me a coffee';
kofiBtn.style.cssText = 'background: #29abe0; color: white; padding: 8px 15px; border-radius: 5px; text-decoration: none; font-weight: bold;';
const boostyBtn = buttonsDiv.createEl('a', {
href: 'https://boosty.to/kharizma',
cls: 'alchemist-donate-btn'
});
boostyBtn.innerText = 'Support on Boosty';
boostyBtn.style.cssText = 'background: #f15f2c; color: white; padding: 8px 15px; border-radius: 5px; text-decoration: none; font-weight: bold;';
}
}

View file

@ -0,0 +1,23 @@
import { AlchemistSettings } from '../../settings';
export interface AlchemistContext {
app: any; // Obsidian App
settings: AlchemistSettings;
system: ISystemAdapter;
plugin: any; // AlchemistPlugin
}
export interface IAlchemistModule {
id: string;
load(context: AlchemistContext): Promise<void>;
unload(): Promise<void>;
onSettingsChange(newSettings: AlchemistSettings): Promise<void>;
}
export interface ISystemAdapter {
fs: any;
path: any;
os: any;
showSaveDialog(options: any): Promise<{ canceled: boolean; filePath?: string }>;
showOpenDialog(options: any): Promise<{ canceled: boolean; filePaths: string[] }>;
}

31
src/core/SystemAdapter.ts Normal file
View file

@ -0,0 +1,31 @@
import { ISystemAdapter } from './IAlchemistModule';
export class ElectronSystemAdapter implements ISystemAdapter {
public fs: any;
public path: any;
public os: any;
private dialog: any;
constructor() {
const electron = (window as any).require('electron');
let remote: any;
try {
remote = (window as any).require('@electron/remote');
} catch (e) {
remote = electron.remote;
}
this.dialog = remote ? remote.dialog : electron.dialog;
this.fs = (window as any).require('fs');
this.path = (window as any).require('path');
this.os = (window as any).require('os');
}
async showSaveDialog(options: any): Promise<{ canceled: boolean; filePath?: string }> {
return await this.dialog.showSaveDialog(options);
}
async showOpenDialog(options: any): Promise<{ canceled: boolean; filePaths: string[] }> {
return await this.dialog.showOpenDialog(options);
}
}

View file

@ -0,0 +1,36 @@
import { ISystemAdapter } from '../IAlchemistModule';
export class MockSystemAdapter implements ISystemAdapter {
public fs = {
writeFileSync: jest.fn(),
readFileSync: jest.fn(),
existsSync: jest.fn(),
mkdirSync: jest.fn(),
};
public path = {
join: (...args: string[]) => args.join('/'),
dirname: (p: string) => p.split('/').slice(0, -1).join('/'),
basename: (p: string) => p.split('/').pop(),
isAbsolute: (p: string) => p.startsWith('/'),
};
public os = {
tmpdir: () => '/tmp',
platform: () => 'linux',
};
async showSaveDialog(options: any) {
return { canceled: false, filePath: '/tmp/test.txt' };
}
async showOpenDialog(options: any) {
return { canceled: false, filePaths: ['/tmp/test.txt'] };
}
}
describe('SystemAdapter Smoke Test', () => {
it('should be able to instantiate MockSystemAdapter', () => {
const adapter = new MockSystemAdapter();
expect(adapter).toBeDefined();
expect(adapter.path.join('a', 'b')).toBe('a/b');
});
});

View file

@ -0,0 +1,222 @@
import { TFile, Notice, normalizePath, FileSystemAdapter } from 'obsidian';
import { spawn } from 'child_process';
import { IAlchemistModule, AlchemistContext } from '../../core/IAlchemistModule';
import { AlchemistSettings } from '../../../settings';
import { getFfmpegArgs, AudioMetadata } from './logic';
export class AudioModule implements IAlchemistModule {
public id = 'audio-converter';
private context!: AlchemistContext;
private statusBarItem: HTMLElement | null = null;
async load(context: AlchemistContext): Promise<void> {
this.context = context;
this.statusBarItem = this.context.plugin.addStatusBarItem();
if (this.statusBarItem) this.statusBarItem.hide();
this.registerEvents();
}
async unload(): Promise<void> {
// Events are cleaned up automatically
}
async onSettingsChange(newSettings: AlchemistSettings): Promise<void> {
this.context.settings = newSettings;
}
private registerEvents() {
// Single file menu
this.context.plugin.registerEvent(
this.context.app.workspace.on('file-menu', (menu: any, abstractFile: any) => {
if (!this.context.settings.enableAudioConverter) return;
const format = this.context.settings.defaultAudioOutputFormat || 'mp3';
if (abstractFile instanceof TFile) {
const ext = abstractFile.extension.toLowerCase();
const audioExtensions = ['webm', 'ogg', 'wav', 'mp3', 'm4a', 'flac', 'aac', 'opus'];
if (audioExtensions.includes(ext) && ext !== format) {
menu.addItem((item: any) => {
item
.setTitle(`Alchemist: Convert to ${format.toUpperCase()}`)
.setIcon('music')
.onClick(() => this.runConversion([abstractFile], format));
});
}
}
})
);
// Multiple files menu
this.context.plugin.registerEvent(
this.context.app.workspace.on('files-menu', (menu: any, files: any[]) => {
if (!this.context.settings.enableAudioConverter) return;
const audioExtensions = ['webm', 'ogg', 'wav', 'mp3', 'm4a', 'flac', 'aac', 'opus'];
const audioFiles = files.filter(f => f instanceof TFile && audioExtensions.includes(f.extension.toLowerCase()) && f.extension.toLowerCase() !== format);
if (audioFiles.length <= 1) return;
const format = this.context.settings.defaultAudioOutputFormat || 'mp3';
menu.addItem((item: any) => {
item
.setTitle(`Alchemist: Convert ${audioFiles.length} files to ${format.toUpperCase()}`)
.setIcon('music')
.onClick(() => this.runConversion(audioFiles, format));
});
})
);
}
private async runConversion(files: TFile[], format: string) {
if (files.length === 0) return;
const total = files.length;
let processed = 0;
if (this.statusBarItem) {
this.statusBarItem.show();
this.updateStatus(processed, total);
}
new Notice(`Alchemist: Converting ${total} file(s)...`);
for (const file of files) {
try {
const metadata = await this.extractMetadata(file);
await this.convert(file, format, metadata);
processed++;
this.updateStatus(processed, total);
} catch (e) {
console.error(`Alchemist Audio Error (${file.name}):`, e);
new Notice(`Conversion failed for ${file.name}: ${(e as Error).message}`);
}
}
if (this.statusBarItem) this.statusBarItem.hide();
new Notice(`Alchemist: Finished processing ${processed}/${total} files.`);
}
private updateStatus(current: number, total: number) {
if (!this.statusBarItem) return;
this.statusBarItem.setText(`🧪 Alchemist: ${current}/${total} [${Math.round((current / total) * 100)}%]`);
}
private async extractMetadata(file: TFile): Promise<AudioMetadata> {
const metadata: AudioMetadata = {
title: file.basename,
artist: this.context.settings.defaultAudioArtist,
album: this.context.settings.defaultAudioAlbum,
genre: this.context.settings.defaultAudioGenre,
date: new Date().getFullYear().toString()
};
// Try to find a companion .md file with the same name
const mdPath = file.path.replace(/\.[^/.]+$/, ".md");
const mdFile = this.context.app.vault.getAbstractFileByPath(mdPath);
if (mdFile instanceof TFile) {
const cache = this.context.app.metadataCache.getFileCache(mdFile);
const frontmatter = cache?.frontmatter;
if (frontmatter) {
if (frontmatter.title) metadata.title = frontmatter.title;
if (frontmatter.artist) metadata.artist = frontmatter.artist;
if (frontmatter.album) metadata.album = frontmatter.album;
if (frontmatter.genre) metadata.genre = frontmatter.genre;
if (frontmatter.date) metadata.date = String(frontmatter.date);
// Fallback for tags -> genre
if (!frontmatter.genre && frontmatter.tags) {
const tags = Array.isArray(frontmatter.tags) ? frontmatter.tags : [frontmatter.tags];
if (tags.length > 0) metadata.genre = tags[0];
}
}
}
return metadata;
}
private async convert(file: TFile, format: string, metadata: AudioMetadata): Promise<void> {
return new Promise(async (resolve, reject) => {
const system = this.context.system;
const app = this.context.app;
const settings = this.context.settings;
const adapter = app.vault.adapter as FileSystemAdapter;
const basePath = adapter.getBasePath();
const inputPath = system.path.join(basePath, file.path);
const tempOutputPath = system.path.join(system.os.tmpdir(), `alchemist_${Date.now()}.${format}`);
const args = getFfmpegArgs(inputPath, tempOutputPath, format, metadata);
const child = spawn('ffmpeg', args);
let stderr = '';
child.stderr.on('data', (data) => { stderr += data.toString(); });
child.on('close', async (code) => {
if (code === 0) {
try {
const data = system.fs.readFileSync(tempOutputPath);
const outputName = file.basename + '.' + format;
let finalVaultPath: string;
switch (settings.targetFolderStrategy) {
case 'specific': {
const folder = settings.specificTargetFolder || '';
finalVaultPath = normalizePath(`${folder}/${outputName}`);
if (folder) {
const existing = app.vault.getAbstractFileByPath(folder);
if (!existing) await app.vault.createFolder(folder);
}
break;
}
case 'dialog': {
const result = await system.showSaveDialog({
title: 'Save Converted Audio',
defaultPath: system.path.join(settings.lastDialogPath, outputName),
filters: [{ name: format.toUpperCase(), extensions: [format] }]
});
if (result.canceled || !result.filePath) {
system.fs.unlinkSync(tempOutputPath);
resolve();
return;
}
settings.lastDialogPath = system.path.dirname(result.filePath);
await this.context.plugin.saveSettings();
system.fs.writeFileSync(result.filePath, data);
system.fs.unlinkSync(tempOutputPath);
if (settings.deleteOriginalWebM && file.extension === 'webm') {
await app.vault.trash(file, true);
}
resolve();
return;
}
default: // 'source'
finalVaultPath = file.path.replace(new RegExp(`\\.${file.extension}$`), `.${format}`);
}
const existingFile = app.vault.getAbstractFileByPath(finalVaultPath);
if (existingFile instanceof TFile) {
await app.vault.modifyBinary(existingFile, data.buffer);
} else {
await app.vault.createBinary(finalVaultPath, data.buffer);
}
if (settings.deleteOriginalWebM && file.extension === 'webm') {
await app.vault.trash(file, true);
}
system.fs.unlinkSync(tempOutputPath);
resolve();
} catch (e) {
reject(e);
}
} else {
reject(new Error(`FFmpeg failed: ${stderr}`));
}
});
child.on('error', (err) => reject(err));
});
}
}

View file

@ -0,0 +1,27 @@
import { getFfmpegArgs } from '../logic';
describe('Audio Logic: getFfmpegArgs', () => {
it('should generate basic args for mp3', () => {
const args = getFfmpegArgs('input.webm', 'output.mp3', 'mp3', {});
expect(args).toContain('input.webm');
expect(args).toContain('output.mp3');
expect(args).toContain('libmp3lame');
});
it('should include metadata args if provided', () => {
const metadata = { title: 'Test Title', artist: 'Test Artist' };
const args = getFfmpegArgs('in.wav', 'out.mp3', 'mp3', metadata);
expect(args).toContain('title=Test Title');
expect(args).toContain('artist=Test Artist');
});
it('should set overwrite flag', () => {
const args = getFfmpegArgs('in.wav', 'out.wav', 'wav', {});
expect(args).toContain('-y');
});
it('should use pcm_s16le for wav', () => {
const args = getFfmpegArgs('in.webm', 'out.wav', 'wav', {});
expect(args).toContain('pcm_s16le');
});
});

View file

@ -0,0 +1,47 @@
/**
* Pure logic for Audio Converter FFmpeg command generation.
* Zero dependencies on Obsidian API or System.
*/
export interface AudioMetadata {
title?: string;
artist?: string;
date?: string;
genre?: string;
album?: string;
}
/**
* Generates the FFmpeg command arguments for conversion and metadata injection.
*/
export function getFfmpegArgs(
inputPath: string,
outputPath: string,
format: string,
metadata: AudioMetadata
): string[] {
const args = ['-i', inputPath];
// Metadata injection
if (metadata.title) args.push('-metadata', `title=${metadata.title}`);
if (metadata.artist) args.push('-metadata', `artist=${metadata.artist}`);
if (metadata.date) args.push('-metadata', `date=${metadata.date}`);
if (metadata.genre) args.push('-metadata', `genre=${metadata.genre}`);
if (metadata.album) args.push('-metadata', `album=${metadata.album}`);
// Codec settings based on target format
if (format === 'mp3') {
args.push('-c:a', 'libmp3lame', '-q:a', '2');
} else if (format === 'wav') {
args.push('-c:a', 'pcm_s16le');
} else if (format === 'ogg') {
args.push('-c:a', 'libvorbis', '-q:a', '4');
} else if (format === 'flac') {
args.push('-c:a', 'flac');
} else if (format === 'm4a') {
args.push('-c:a', 'aac', '-b:a', '192k');
}
args.push('-y', outputPath);
return args;
}

View file

@ -0,0 +1,146 @@
import { Notice, TFile, TFolder, normalizePath } from 'obsidian';
import { IAlchemistModule, AlchemistContext } from '../../core/IAlchemistModule';
import { AlchemistSettings } from '../../../settings';
import { escapeCsvCell } from './logic';
export class DataviewModule implements IAlchemistModule {
public id = 'dataview-export';
private context!: AlchemistContext;
private observer: MutationObserver | null = null;
private injectTimeout: NodeJS.Timeout | null = null;
async load(context: AlchemistContext): Promise<void> {
this.context = context;
this.setupGlobalObserver();
this.start();
}
async unload(): Promise<void> {
this.stop();
}
async onSettingsChange(newSettings: AlchemistSettings): Promise<void> {
const wasEnabled = this.context.settings.enableDataviewExport;
this.context.settings = newSettings;
if (wasEnabled !== newSettings.enableDataviewExport) {
if (newSettings.enableDataviewExport) this.start();
else this.stop();
}
}
private setupGlobalObserver() {
this.observer = new MutationObserver(() => {
if (!this.context.settings.enableDataviewExport) return;
if (this.injectTimeout) clearTimeout(this.injectTimeout);
this.injectTimeout = setTimeout(() => {
this.injectAll();
}, 300);
});
}
private start() {
if (this.context.settings.enableDataviewExport && this.observer) {
this.observer.observe(document.body, { childList: true, subtree: true });
this.injectAll();
}
}
private stop() {
if (this.observer) {
this.observer.disconnect();
}
if (this.injectTimeout) clearTimeout(this.injectTimeout);
document.querySelectorAll('.alchemist-export-btn').forEach(btn => btn.remove());
}
private injectAll() {
const isDataviewEnabled = (this.context.app as any).plugins?.enabledPlugins?.has('dataview');
if (!isDataviewEnabled) return;
const containers = document.querySelectorAll('.block-language-dataview, .dataview.table-view-table, .dataview-container');
containers.forEach(c => this.injectButton(c as HTMLElement));
}
private injectButton(container: HTMLElement) {
if (container.querySelector('.alchemist-export-btn')) return;
if (!container.querySelector('table') && !container.classList.contains('block-language-dataview')) return;
const btn = document.createElement('button');
btn.className = 'alchemist-export-btn clickable-icon';
btn.innerText = '💾 CSV';
btn.style.cssText = 'position: absolute; right: 10px; top: 10px; z-index: 100; font-size: 0.7em; opacity: 0.6;';
btn.addEventListener('mouseenter', () => btn.style.opacity = '1');
btn.addEventListener('mouseleave', () => btn.style.opacity = '0.6');
btn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
this.exportTable(container);
};
container.appendChild(btn);
if (getComputedStyle(container).position === 'static') {
container.style.position = 'relative';
}
}
private async exportTable(el: HTMLElement) {
const table = el.querySelector('table');
if (!table) {
new Notice('No table found to export');
return;
}
const rows = Array.from(table.querySelectorAll('tr'));
const csvRows = rows.map(row => {
const cells = Array.from(row.querySelectorAll('th, td'));
return cells.map(cell => escapeCsvCell((cell as HTMLElement).innerText)).join(',');
});
const sourceFile = this.context.app.workspace.getActiveFile();
csvRows.push(''); // Empty row separation
csvRows.push(`"Source","[[${sourceFile?.path || 'unknown'}]]"`);
csvRows.push(`"Exported","${new Date().toLocaleString()}"`);
await this.handleSave(csvRows.join('\n'));
}
private async handleSave(content: string) {
const settings = this.context.settings;
if (settings.dataviewExportStrategy === 'vault') {
const folderPath = normalizePath(settings.dataviewVaultPath || 'Exports');
await this.ensureFolder(folderPath);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const fileName = `dataview_export_${timestamp}.csv`;
const fullPath = `${folderPath}/${fileName}`;
await this.context.app.vault.create(fullPath, content);
new Notice(`CSV Saved to Vault: ${fullPath}`);
} else {
const result = await this.context.system.showSaveDialog({
title: 'Export Dataview to CSV',
defaultPath: this.context.system.path.join(this.context.settings.lastDialogPath, 'dataview_export.csv'),
filters: [{ name: 'CSV', extensions: ['csv'] }]
});
if (!result.canceled && result.filePath) {
this.context.settings.lastDialogPath = this.context.system.path.dirname(result.filePath);
await this.context.plugin.saveSettings();
this.context.system.fs.writeFileSync(result.filePath, content);
new Notice('CSV Exported Successfully');
}
}
}
private async ensureFolder(path: string) {
const folder = this.context.app.vault.getAbstractFileByPath(path);
if (folder instanceof TFolder) return;
await this.context.app.vault.createFolder(path);
}
}

View file

@ -0,0 +1,31 @@
import { escapeCsvCell, arrayToCsv } from '../logic';
describe('Dataview Logic: escapeCsvCell', () => {
it('should escape double quotes', () => {
expect(escapeCsvCell('Hello "World"')).toBe('"Hello ""World"""');
});
it('should wrap in quotes if contains comma', () => {
expect(escapeCsvCell('Hello, World')).toBe('"Hello, World"');
});
it('should wrap in quotes if contains newline', () => {
expect(escapeCsvCell('Line 1\nLine 2')).toBe('"Line 1\nLine 2"');
});
it('should return empty string for null/undefined', () => {
expect(escapeCsvCell('')).toBe('');
});
});
describe('Dataview Logic: arrayToCsv', () => {
it('should convert 2D array to CSV', () => {
const data = [
['Name', 'Age'],
['Alice', '30'],
['Bob, the Great', '25']
];
const expected = 'Name,Age\nAlice,30\n"Bob, the Great",25';
expect(arrayToCsv(data)).toBe(expected);
});
});

View file

@ -0,0 +1,26 @@
/**
* Pure logic for Dataview CSV export.
* Zero dependencies on DOM or Obsidian API.
*/
/**
* Escapes a cell value for RFC-4180 CSV compliance.
*/
export function escapeCsvCell(text: string): string {
if (!text) return '';
let result = text.trim();
if (result.includes('"')) {
result = result.replace(/"/g, '""');
}
if (result.includes(',') || result.includes('\n') || result.includes('"')) {
result = `"${result}"`;
}
return result;
}
/**
* Converts a 2D array of strings into a CSV string.
*/
export function arrayToCsv(data: string[][]): string {
return data.map(row => row.map(escapeCsvCell).join(',')).join('\n');
}

View file

@ -0,0 +1,68 @@
import { Editor, htmlToMarkdown, Notice } from 'obsidian';
import { IAlchemistModule, AlchemistContext } from '../../core/IAlchemistModule';
import { AlchemistSettings } from '../../../settings';
import { containsTrackers, stripTrackers, purifyHtmlStructure } from './logic';
export class SmartPasteModule implements IAlchemistModule {
public id = 'smart-paste';
private context!: AlchemistContext;
async load(context: AlchemistContext): Promise<void> {
this.context = context;
this.registerPasteHandler();
this.registerCommands();
}
async unload(): Promise<void> {
// Commands and events are automatically cleaned up if registered via plugin
}
async onSettingsChange(newSettings: AlchemistSettings): Promise<void> {
this.context.settings = newSettings;
}
private registerCommands() {
this.context.plugin.addCommand({
id: 'clean-trackers-in-document',
name: 'Clean trackers in current document',
editorCallback: (editor: Editor) => {
const content = editor.getValue();
const cleaned = stripTrackers(content);
if (content !== cleaned) {
editor.setValue(cleaned);
new Notice('Alchemist: Document cleaned from trackers');
} else {
new Notice('Alchemist: No trackers found');
}
}
});
}
private registerPasteHandler() {
this.context.plugin.registerEvent(
this.context.app.workspace.on('editor-paste', async (evt: ClipboardEvent, editor: Editor) => {
if (!this.context.settings.enableSmartPaste) return;
const clipboardData = evt.clipboardData;
if (!clipboardData) return;
const text = clipboardData.getData('text/plain')?.trim() || '';
// SURGICAL INTERCEPTION: Only intercept pure URLs
const urlRegex = /^(https?:\/\/[^\s"'>]+)$/;
if (text.match(urlRegex) && this.context.settings.stripTrackingParameters) {
const cleanUrl = stripTrackers(text);
if (cleanUrl !== text) {
evt.preventDefault();
editor.replaceSelection(cleanUrl);
new Notice('Alchemist: URL cleaned');
}
}
// Note: We no longer intercept rich HTML pastes to avoid breaking
// Obsidian's native formatting (like code blocks or auto-linking).
// Users can use the "Clean trackers in current document" command instead.
})
);
}
}

View file

@ -0,0 +1,41 @@
import { containsTrackers, stripTrackers, purifyHtmlStructure } from '../logic';
describe('SmartPaste Logic: stripTrackers', () => {
it('should remove UTM parameters', () => {
const url = 'https://example.com/page?utm_source=google&utm_medium=cpc&id=123';
const expected = 'https://example.com/page?id=123';
expect(stripTrackers(url)).toBe(expected);
});
it('should remove fbclid', () => {
const url = 'https://example.com/?fbclid=IwAR123';
const expected = 'https://example.com/';
expect(stripTrackers(url)).toBe(expected);
});
it('should handle multiple URLs in text', () => {
const text = 'Check out https://site1.com?utm_source=x and https://site2.com?gclid=y';
const expected = 'Check out https://site1.com/ and https://site2.com/';
expect(stripTrackers(text)).toBe(expected);
});
it('should handle &amp; entities', () => {
const url = 'https://example.com/?a=1&amp;utm_source=2';
const expected = 'https://example.com/?a=1';
expect(stripTrackers(url)).toBe(expected);
});
});
describe('SmartPaste Logic: purifyHtmlStructure', () => {
it('should remove docs-internal-guid', () => {
const html = '<b id="docs-internal-guid-123">Hello</b>';
const expected = '<b>Hello</b>';
expect(purifyHtmlStructure(html)).toBe(expected);
});
it('should remove zero-width spaces', () => {
const html = 'H\u200Bello';
const expected = 'Hello';
expect(purifyHtmlStructure(html)).toBe(expected);
});
});

View file

@ -0,0 +1,70 @@
/**
* Pure logic for Smart Paste cleaning.
* Zero dependencies on Obsidian API.
*/
const BLACKLIST = [
'utm_', 'fbclid', 'gclid', 'yclid', 'msclkid', 'mc_cid', 'mc_eid',
'_hsenc', '_hsmi', 'sca_esv', 'sxsrf', 'ei', 'biw', 'bih', 'oq',
'gs_lp', 'sclient', 'ved', 'uact'
];
/**
* Checks if a string contains any tracking parameters.
*/
export function containsTrackers(input: string): boolean {
if (!input) return false;
return BLACKLIST.some(b => input.toLowerCase().includes(b));
}
/**
* Strips tracking parameters from all URLs found in the text.
*/
export function stripTrackers(input: string): string {
const urlRegex = /(https?:\/\/[^\s"'>]+)/g;
return input.replace(urlRegex, (url) => {
try {
// Handle HTML encoded entities
const decodedUrl = url.replace(/&amp;/g, '&');
const urlObj = new URL(decodedUrl);
const params = urlObj.searchParams;
const toRemove: string[] = [];
params.forEach((_, key) => {
if (BLACKLIST.some(b => key.toLowerCase().startsWith(b))) {
toRemove.push(key);
}
});
if (toRemove.length > 0) {
toRemove.forEach(k => params.delete(k));
return urlObj.toString();
}
return url;
} catch (e) {
return url;
}
});
}
/**
* Sanitizes HTML content structure and removes invisible trackers.
* (Note: Uses DOMParser which is available in Obsidian/Electron but not in pure Node)
*/
export function purifyHtmlStructure(html: string): string {
// Structural Cleaning (MS Office / Google Docs noise)
let content = html;
// Remove zero-width spaces
content = content.replace(/[\u200B-\u200D\uFEFF]/g, '');
// Clean specific junk IDs and styles
content = content.replace(/\sid="docs-internal-guid-[^"]*"/g, '');
content = content.replace(/\sstyle="[^"]*"/g, '');
// Remove double spaces and non-breaking spaces
content = content.replace(/&nbsp;/g, ' ').replace(/\s\s+/g, ' ');
return content.trim();
}

View file

@ -0,0 +1,191 @@
import { TFile, TFolder, Notice } from 'obsidian';
import { IAlchemistModule, AlchemistContext } from '../../core/IAlchemistModule';
import { AlchemistSettings } from '../../../settings';
import { TextBundleFeature } from './collector';
import { TextBundlePacker } from './packer';
import { TextBundleImporter } from './importer';
export class TextBundleModule implements IAlchemistModule {
public id = 'textbundle';
private context!: AlchemistContext;
private collector!: TextBundleFeature;
private packer!: TextBundlePacker;
private importer!: TextBundleImporter;
async load(context: AlchemistContext): Promise<void> {
this.context = context;
this.collector = new TextBundleFeature(context.app, context.settings);
this.packer = new TextBundlePacker(context.app);
this.importer = new TextBundleImporter(context.app, context.settings, context.system);
this.registerCommands();
this.registerEvents();
this.registerRibbonIcons();
}
async unload(): Promise<void> {
// Clean up if necessary
}
async onSettingsChange(newSettings: AlchemistSettings): Promise<void> {
this.context.settings = newSettings;
}
private registerRibbonIcons() {
this.context.plugin.addRibbonIcon('package-plus', 'Alchemist: Import TextBundle', () => {
if (!this.context.settings.enableTextBundle) {
new Notice('TextBundle module is disabled in settings.');
return;
}
this.runImport();
});
}
private registerCommands() {
this.context.plugin.addCommand({
id: 'import-textbundle',
name: 'Import TextBundle',
callback: () => {
if (!this.context.settings.enableTextBundle) return;
this.runImport();
}
});
this.context.plugin.addCommand({
id: 'export-current-textbundle',
name: 'Export current file as TextBundle',
checkCallback: (checking: boolean) => {
if (!this.context.settings.enableTextBundle) return false;
const activeFile = this.context.app.workspace.getActiveFile();
if (activeFile) {
if (!checking) this.runExport(activeFile);
return true;
}
return false;
}
});
}
private registerEvents() {
this.context.plugin.registerEvent(
this.context.app.workspace.on('file-menu', (menu: any, abstractFile: any) => {
if (!this.context.settings.enableTextBundle) return;
if (abstractFile instanceof TFile && abstractFile.extension === 'md') {
menu.addItem((item: any) => {
item
.setTitle('Alchemist: Export as TextBundle')
.setIcon('package')
.onClick(() => this.runExport(abstractFile));
});
} else if (abstractFile instanceof TFolder) {
menu.addItem((item: any) => {
item
.setTitle('Alchemist: Export folder as TextBundle')
.setIcon('package')
.onClick(async () => {
const files: TFile[] = [];
const collect = (f: any) => {
if (f instanceof TFile && f.extension === 'md') files.push(f);
else if (f instanceof TFolder) f.children.forEach(collect);
};
collect(abstractFile);
if (files.length === 0) {
new Notice('No markdown files found in this folder.');
return;
}
// Bulk Export Strategy: Ask for a folder once
const result = await this.context.system.showOpenDialog({
title: 'Select Destination for Bulk Export',
defaultPath: this.context.settings.lastDialogPath,
properties: ['openDirectory', 'createDirectory']
});
if (!result.canceled && result.filePaths.length > 0) {
const targetDir = result.filePaths[0];
this.context.settings.lastDialogPath = targetDir;
await this.context.plugin.saveSettings();
new Notice(`Bulk Exporting ${files.length} notes...`);
let successCount = 0;
for (const f of files) {
try {
const context = await this.collector.collectResources(f);
const blob = await this.packer.pack(context);
const extension = this.context.settings.compressionFormat || 'textbundle';
const fullPath = this.context.system.path.join(targetDir, `${f.basename}.${extension}`);
this.context.system.fs.writeFileSync(fullPath, Buffer.from(blob));
successCount++;
} catch (e) {
console.error(`Export failed for ${f.name}:`, e);
}
}
new Notice(`Successfully exported ${successCount}/${files.length} notes to ${targetDir}`);
}
});
});
menu.addItem((item: any) => {
item
.setTitle('Alchemist: Import into here')
.setIcon('package-plus')
.onClick(() => this.runImport(abstractFile.path));
});
}
})
);
}
private async runImport(targetPath?: string) {
const result = await this.context.system.showOpenDialog({
title: targetPath ? `Import into ${targetPath}` : 'Import TextBundle',
defaultPath: this.context.settings.lastDialogPath,
filters: [{ name: 'TextBundle', extensions: ['textbundle', 'zip', 'textpack'] }],
properties: ['openFile']
});
if (!result.canceled && result.filePaths.length > 0) {
const filePath = result.filePaths[0];
this.context.settings.lastDialogPath = this.context.system.path.dirname(filePath);
await this.context.plugin.saveSettings();
const sourceName = filePath.split(/[\\/]/).pop()?.replace(/\.(zip|textpack|textbundle)$/i, '') || 'Imported_Note';
try {
const data = this.context.system.fs.readFileSync(filePath);
await this.importer.importZip(data, targetPath, sourceName);
new Notice('Successfully imported TextBundle');
} catch (e) {
console.error('Alchemist Import Error:', e);
new Notice(`Import failed: ${(e as Error).message}`);
}
}
}
private async runExport(file: TFile) {
try {
const context = await this.collector.collectResources(file);
const blob = await this.packer.pack(context);
const extension = this.context.settings.compressionFormat || 'textbundle';
const result = await this.context.system.showSaveDialog({
title: 'Save TextBundle',
defaultPath: this.context.system.path.join(this.context.settings.lastDialogPath, `${file.basename}.${extension}`),
filters: [{ name: 'TextBundle', extensions: [extension, 'zip'] }]
});
if (!result.canceled && result.filePath) {
this.context.settings.lastDialogPath = this.context.system.path.dirname(result.filePath);
await this.context.plugin.saveSettings();
this.context.system.fs.writeFileSync(result.filePath, Buffer.from(blob));
new Notice(`Successfully exported: ${file.basename}`);
}
} catch (e) {
console.error('Alchemist Export Error:', e);
new Notice(`Export failed: ${(e as Error).message}`);
}
}
}

View file

@ -0,0 +1,44 @@
import { transformMarkdownLinks, LinkResolver } from '../logic';
describe('TextBundle Logic: transformMarkdownLinks', () => {
const mockResolver: LinkResolver = {
resolveAsset: (link: string) => {
if (link === 'image.png') return 'image.png';
if (link === 'photo') return 'real_photo.jpg';
return null;
},
resolveNote: (link: string) => {
if (link === 'Other Note') return '../Other Note.textbundle/text.md';
return null;
}
};
it('should transform simple embeds', () => {
const input = 'Check this out: ![[image.png]]';
const expected = 'Check this out: ![image.png](assets/image.png)';
expect(transformMarkdownLinks(input, mockResolver)).toBe(expected);
});
it('should transform embeds with aliases', () => {
const input = 'Check this out: ![[photo|My Cool Photo]]';
const expected = 'Check this out: ![My Cool Photo](assets/real_photo.jpg)';
expect(transformMarkdownLinks(input, mockResolver)).toBe(expected);
});
it('should transform regular links to assets', () => {
const input = 'Download [[image.png]]';
const expected = 'Download [image.png](assets/image.png)';
expect(transformMarkdownLinks(input, mockResolver)).toBe(expected);
});
it('should transform links to other notes in TextPack', () => {
const input = 'See [[Other Note]]';
const expected = 'See [Other Note](../Other Note.textbundle/text.md)';
expect(transformMarkdownLinks(input, mockResolver)).toBe(expected);
});
it('should not transform unknown links', () => {
const input = 'See [[Unknown Note]] and ![[unknown.png]]';
expect(transformMarkdownLinks(input, mockResolver)).toBe(input);
});
});

View file

@ -0,0 +1,105 @@
import { TFile, App } from 'obsidian';
import { AlchemistSettings } from '../../../settings';
export interface TextBundleContext {
visitedFiles: Set<string>;
assetMap: Map<string, string>; // originalPath -> bundleName
assets: Map<string, TFile>; // bundleName -> TFile
notes: TFile[];
}
export class TextBundleFeature {
app: App;
settings: AlchemistSettings;
constructor(app: App, settings: AlchemistSettings) {
this.app = app;
this.settings = settings;
}
/**
* Collects all files (note itself + assets + linked notes) for TextBundle export.
*/
async collectResources(startFile: TFile): Promise<TextBundleContext> {
const context: TextBundleContext = {
visitedFiles: new Set(),
assetMap: new Map(),
assets: new Map(),
notes: []
};
await this.traverse(startFile, context, 0);
return context;
}
private async traverse(file: TFile, context: TextBundleContext, depth: number) {
if (context.visitedFiles.has(file.path)) return;
context.visitedFiles.add(file.path);
if (file.extension === 'md') {
context.notes.push(file);
const cache = this.app.metadataCache.getFileCache(file);
if (!cache) return;
// Collect embeds (images, pdfs, audio, etc.)
if (cache.embeds) {
for (const embed of cache.embeds) {
const assetFile = this.app.metadataCache.getFirstLinkpathDest(embed.link, file.path);
if (assetFile instanceof TFile && this.shouldIncludeAsset(assetFile)) {
this.addAssetToContext(assetFile, context);
}
}
}
// Collect links (other notes) if recursion depth allows
if (depth < this.settings.recursionDepth && cache.links) {
for (const link of cache.links) {
const linkedFile = this.app.metadataCache.getFirstLinkpathDest(link.link, file.path);
if (linkedFile instanceof TFile && linkedFile.extension === 'md') {
await this.traverse(linkedFile, context, depth + 1);
}
}
}
} else {
// If it's not a markdown file, it's an asset itself
if (this.shouldIncludeAsset(file)) {
this.addAssetToContext(file, context);
}
}
}
private addAssetToContext(file: TFile, context: TextBundleContext) {
if (context.assetMap.has(file.path)) return;
let bundleName = file.name;
let counter = 1;
const nameParts = file.name.split('.');
const ext = nameParts.pop();
const base = nameParts.join('.');
while (context.assets.has(bundleName)) {
bundleName = `${base}_${counter}.${ext}`;
counter++;
}
context.assetMap.set(file.path, bundleName);
context.assets.set(bundleName, file);
}
private shouldIncludeAsset(file: TFile): boolean {
const ext = file.extension.toLowerCase();
if (ext === 'md' || ext === 'markdown') return false;
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'tiff'];
const audioExtensions = ['mp3', 'wav', 'ogg', 'm4a', 'flac', 'webm', 'opus'];
const videoExtensions = ['mp4', 'webm', 'mkv', 'avi', 'mov'];
const pdfExtensions = ['pdf'];
if (imageExtensions.includes(ext)) return this.settings.includeImages;
if (audioExtensions.includes(ext)) return this.settings.includeAudio;
if (videoExtensions.includes(ext)) return this.settings.includeVideo;
if (pdfExtensions.includes(ext)) return this.settings.includePDF;
return true; // Include other types by default if not specifically filtered
}
}

View file

@ -0,0 +1,233 @@
import { App, TFile, TFolder, normalizePath, Notice } from 'obsidian';
import * as fflate from 'fflate';
import { AlchemistSettings } from '../../../settings';
import { ISystemAdapter } from '../../core/IAlchemistModule';
export class TextBundleImporter {
app: App;
settings: AlchemistSettings;
system: ISystemAdapter;
constructor(app: App, settings: AlchemistSettings, system: ISystemAdapter) {
this.app = app;
this.settings = settings;
this.system = system;
}
/**
* Entry point for ZIP import.
*/
async importZip(data: ArrayBuffer, targetPath?: string, sourceName?: string) {
try {
const unzipped = fflate.unzipSync(new Uint8Array(data));
await this.processUnzipped(unzipped, targetPath, sourceName);
new Notice('Alchemist: Import successful');
} catch (e) {
console.error('Alchemist: Import failed', e);
new Notice('Alchemist: Import failed. Check console for details.');
}
}
private async processUnzipped(unzipped: Record<string, Uint8Array>, targetPath?: string, sourceName?: string) {
const allKeys = Object.keys(unzipped);
// Identify bundles (.textbundle folders)
const bundleFolders = new Set<string>();
allKeys.forEach(k => {
const match = k.match(/(.*\.textbundle)\//);
if (match) bundleFolders.add(match[1]);
});
if (bundleFolders.size === 0) {
// Flat structure
await this.importSingleBundle(unzipped, targetPath, sourceName);
} else {
// TextPack structure
new Notice(`Alchemist: Importing ${bundleFolders.size} notes from TextPack...`);
for (const folder of bundleFolders) {
const prefix = `${folder}/`;
const bundleFiles: Record<string, Uint8Array> = {};
for (const key of allKeys) {
if (key.startsWith(prefix)) {
bundleFiles[key.replace(prefix, '')] = unzipped[key];
}
}
const cleanName = folder.replace('.textbundle', '');
await this.importSingleBundle(bundleFiles, targetPath, cleanName);
}
}
}
private async importSingleBundle(files: Record<string, Uint8Array>, targetPath?: string, sourceName?: string) {
const allKeys = Object.keys(files);
const textKey = allKeys.find(k =>
k.endsWith('text.md') || k.endsWith('text.markdown') || k.endsWith('text.txt') ||
k === 'text.md' || k === 'text.markdown' || k === 'text.txt'
);
if (!textKey) return;
const textData = files[textKey];
let content = fflate.strFromU8(textData);
const importRoot = targetPath || this.settings.defaultImportPath || '/';
let noteName = sourceName ? `${sourceName}.md` : `Imported_Note.md`;
let finalImportFolder = normalizePath(importRoot);
// 1. Read info.json FIRST to determine structure
const infoKey = allKeys.find(k => k === 'info.json');
if (infoKey) {
try {
const info = JSON.parse(fflate.strFromU8(files[infoKey]));
if (info.title) noteName = `${info.title}.md`;
if (info.origin_path && this.settings.restoreFolderStructure) {
const relativeDir = this.system.path.dirname(info.origin_path);
if (relativeDir !== '.') {
finalImportFolder = normalizePath(`${importRoot}/${relativeDir}`);
}
}
} catch (e) {}
}
await this.ensureFolder(finalImportFolder);
// Put assets in a central "media" folder within the import root (or relative root)
const attachmentFolderPath = normalizePath(`${finalImportFolder}/media`);
await this.ensureFolder(attachmentFolderPath);
const assetsBase = 'assets/';
const assetRenameMap = new Map<string, string>();
// 2. Import Assets
for (const [path, assetData] of Object.entries(files)) {
if (path.startsWith(assetsBase) && path !== assetsBase) {
const originalFileName = path.replace(assetsBase, '');
const actualFileName = await this.importAssetWithSafety(originalFileName, assetData, attachmentFolderPath);
if (actualFileName !== originalFileName) {
assetRenameMap.set(originalFileName, actualFileName);
}
}
}
// 3. Link Restoration
content = this.reverseTransformLinks(content);
for (const [oldName, newName] of assetRenameMap.entries()) {
const escapedOldName = oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const linkRegex = new RegExp(`\\[\\[media/${escapedOldName}(\\|[^\\]]+)?\\]\\]`, 'g');
content = content.replace(linkRegex, (match, alias) => {
return `[[media/${newName}${alias || ''}]]`;
});
}
await this.createNoteWithSafety(finalImportFolder, noteName, content);
}
private async importAssetWithSafety(fileName: string, data: Uint8Array, folderPath: string): Promise<string> {
await this.ensureFolder(folderPath);
let actualName = fileName;
let fullPath = normalizePath(`${folderPath}/${actualName}`);
let counter = 1;
const nameParts = fileName.split('.');
const ext = nameParts.pop();
const base = nameParts.join('.');
while (await this.app.vault.adapter.exists(fullPath)) {
const existingData = await this.app.vault.adapter.readBinary(fullPath);
// If identical, reuse
if (this.areBuffersEqual(new Uint8Array(existingData), data)) {
return actualName;
}
// Otherwise rename
actualName = `${base} (${counter}).${ext}`;
fullPath = normalizePath(`${folderPath}/${actualName}`);
counter++;
}
await this.app.vault.createBinary(fullPath, data.buffer as ArrayBuffer);
return actualName;
}
private areBuffersEqual(buf1: Uint8Array, buf2: Uint8Array): boolean {
if (buf1.length !== buf2.length) return false;
for (let i = 0; i < buf1.length; i++) {
if (buf1[i] !== buf2[i]) return false;
}
return true;
}
private async createNoteWithSafety(folderPath: string, fileName: string, content: string) {
let finalPath = normalizePath(`${folderPath}/${fileName}`);
let counter = 1;
const nameParts = fileName.split('.');
const ext = nameParts.pop();
const base = nameParts.join('.');
while (this.app.vault.getAbstractFileByPath(finalPath)) {
if (this.settings.conflictStrategy === 'skip') return;
if (this.settings.conflictStrategy === 'overwrite') {
const existing = this.app.vault.getAbstractFileByPath(finalPath);
if (existing instanceof TFile) {
await this.app.vault.modify(existing, content);
}
return;
}
// Default or rename strategy
finalPath = normalizePath(`${folderPath}/${base} (${counter}).${ext}`);
counter++;
}
await this.app.vault.create(finalPath, content);
}
private async ensureFolder(path: string) {
if (!path || path === '/' || path === '.') return;
const normalized = normalizePath(path);
if (this.app.vault.getAbstractFileByPath(normalized)) return;
const parts = normalized.split('/');
let currentPath = '';
for (const part of parts) {
currentPath = currentPath ? `${currentPath}/${part}` : part;
if (!this.app.vault.getAbstractFileByPath(currentPath)) {
await this.app.vault.createFolder(currentPath);
}
}
}
private getAttachmentFolder(): string {
return (this.app.vault as any).getConfig?.('attachmentFolderPath') || '';
}
private reverseTransformLinks(content: string): string {
// 1. Embeds: ![alt](assets/filename) -> ![[media/filename|alt]]
content = content.replace(/!\[([^\]]*)\]\(assets\/([^\)]+)\)/g, (match, alt, filename) => {
return alt ? `![[media/${filename}|${alt}]]` : `![[media/${filename}]]`;
});
// 2. Links: [text](assets/filename) -> [[media/filename|text]]
content = content.replace(/\[([^\]]+)\]\(assets\/([^\)]+)\)/g, (match, text, filename) => {
return text === filename ? `[[media/${filename}]]` : `[[media/${filename}|${text}]]`;
});
// 3. Cross-Bundle Links: [Label](../BundleName.textbundle/text.md) -> [[NoteName|Label]]
content = content.replace(/\[([^\]]+)\]\(\.\.\/([^/]+)\/text\.(md|markdown|txt)\)/g, (match, text, folderName) => {
const decodedBundle = decodeURIComponent(folderName);
const bundleBase = decodedBundle.replace(/\.textbundle$/i, '');
// Handle collision suffixes like "Note (1)"
const noteName = bundleBase.replace(/ \(\d+\)$/, '');
return text === noteName ? `[[${noteName}]]` : `[[${noteName}|${text}]]`;
});
return content;
}
}

View file

@ -0,0 +1,53 @@
/**
* Pure functions for TextBundle processing.
* These functions have ZERO dependencies on Obsidian API.
*/
export interface LinkResolver {
resolveAsset(link: string): string | null; // Returns bundle asset name if found
resolveNote(link: string): string | null; // Returns relative path to other note if found
}
/**
* Transforms Obsidian-style links [[link]] and ![[embed]] to Markdown-style [link](assets/link)
*/
export function transformMarkdownLinks(
content: string,
resolver: LinkResolver
): string {
// 1. Transform Embeds ![[link]] or ![[link|alias]]
content = content.replace(/!\[\[([^\]]+)\]\]/g, (match, link) => {
const [path, alias] = link.split('|');
const resolvedName = resolver.resolveAsset(path);
if (resolvedName) {
return `![${alias || resolvedName}](assets/${resolvedName})`;
}
return match;
});
// 2. Transform Regular Links [[link]] or [[link|alias]]
content = content.replace(/\[\[([^\]]+)\]\]/g, (match, link) => {
const [path, alias] = link.split('|');
// 1. Try resolving as another note in TextPack FIRST
const notePath = resolver.resolveNote(path);
if (notePath) {
const parts = notePath.split('/');
const bundleName = parts[parts.length - 2] || '';
const decodedBundle = decodeURIComponent(bundleName);
const basename = decodedBundle.replace('.textbundle', '').split(' - ').pop() || 'Note';
return `[${alias || basename}](${notePath})`;
}
// 2. Try resolving as asset
const assetName = resolver.resolveAsset(path);
if (assetName) {
return `[${alias || assetName}](assets/${assetName})`;
}
return match;
});
return content;
}

View file

@ -0,0 +1,121 @@
import { TFile, App, normalizePath } from 'obsidian';
import * as fflate from 'fflate';
import { TextBundleContext } from './collector';
import { transformMarkdownLinks, LinkResolver } from './logic';
export class TextBundlePacker {
app: App;
constructor(app: App) {
this.app = app;
}
/**
* Zips the context into a TextBundle (single) or TextPack (multiple bundles) blob.
*/
async pack(context: TextBundleContext): Promise<Uint8Array> {
const zipData: any = {};
if (context.notes.length === 1) {
// Standard TextBundle (flat structure)
const note = context.notes[0];
await this.buildBundleData(note, context, zipData, "");
} else {
// TextPack structure (ZIP of bundles)
const usedNames = new Set<string>();
const bundleMap = new Map<string, string>();
// First pass: generate unique names
for (const note of context.notes) {
let baseName = note.basename;
let bundleName = `${baseName}.textbundle`;
let counter = 1;
while (usedNames.has(bundleName)) {
bundleName = `${baseName} (${counter}).textbundle`;
counter++;
}
usedNames.add(bundleName);
bundleMap.set(note.path, bundleName);
}
// Second pass: build data
for (const note of context.notes) {
const bundleName = bundleMap.get(note.path)!;
const bundleData: any = {};
await this.buildBundleData(note, context, bundleData, "", bundleMap);
zipData[bundleName] = bundleData;
}
}
return new Promise((resolve, reject) => {
fflate.zip(zipData, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
/**
* Builds the internal structure of a single TextBundle.
*/
private async buildBundleData(note: TFile, context: TextBundleContext, target: any, prefix: string, bundleMap?: Map<string, string>) {
// 1. info.json (with Traceability — PLAN.md line 59)
const info = {
"version": 2,
"type": "net.daringfireball.markdown",
"title": note.basename,
"origin_vault": this.app.vault.getName(),
"origin_path": note.path,
"export_timestamp": new Date().toISOString()
};
target[`${prefix}info.json`] = fflate.strToU8(JSON.stringify(info, null, 2));
// 2. assets
const assetsObj: any = {};
const contentRaw = await this.app.vault.read(note);
// Find which assets are actually used in THIS note
const embeds = this.app.metadataCache.getFileCache(note)?.embeds || [];
const links = this.app.metadataCache.getFileCache(note)?.links || [];
for (const mention of [...embeds, ...links]) {
const assetFile = this.app.metadataCache.getFirstLinkpathDest(mention.link, note.path);
if (assetFile instanceof TFile && context.assetMap.has(assetFile.path)) {
const bundleAssetName = context.assetMap.get(assetFile.path)!;
const assetData = await this.app.vault.readBinary(assetFile);
assetsObj[bundleAssetName] = new Uint8Array(assetData);
}
}
if (Object.keys(assetsObj).length > 0) {
target[`${prefix}assets`] = assetsObj;
}
// 3. text.md (with transformed links)
const resolver: LinkResolver = {
resolveAsset: (link: string) => {
const assetFile = this.app.metadataCache.getFirstLinkpathDest(link, note.path);
if (assetFile instanceof TFile) {
const ext = assetFile.extension.toLowerCase();
if (ext === 'md' || ext === 'markdown') return null;
return context.assetMap.get(assetFile.path) || assetFile.name;
}
return null;
},
resolveNote: (link: string) => {
const targetFile = this.app.metadataCache.getFirstLinkpathDest(link, note.path);
if (targetFile instanceof TFile) {
if (bundleMap && bundleMap.has(targetFile.path)) {
const bundleName = bundleMap.get(targetFile.path)!;
const encodedName = encodeURIComponent(bundleName);
return `../${encodedName}/text.md`;
}
}
return null;
}
};
const content = transformMarkdownLinks(contentRaw, resolver);
target[`${prefix}text.md`] = fflate.strToU8(content);
}
}

17
styles.css Normal file
View file

@ -0,0 +1,17 @@
/* Alchemist Styles */
.alchemist-export-btn {
opacity: 0.6;
transition: opacity 0.2s ease-in-out;
background: var(--interactive-accent);
color: var(--text-on-accent);
border: none;
border-radius: 4px;
padding: 2px 6px;
cursor: pointer;
}
.alchemist-export-btn:hover {
opacity: 1;
background: var(--interactive-accent-hover);
}

36
tsconfig.json Normal file
View file

@ -0,0 +1,36 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "Node",
"importHelpers": true,
"isolatedModules": true,
"strict": true,
"skipLibCheck": true,
"typeRoots": [
"./node_modules/@types",
"../../node_modules/@types"
],
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
],
"types": [
"node"
]
},
"include": [
"**/*.ts"
],
"exclude": [
"node_modules",
"**/__tests__/**"
]
}

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"1.0.0": "0.15.0"
}