mirror of
https://github.com/ksawl/obsidian-alchemist.git
synced 2026-07-22 06:49:47 +00:00
initial release v1.0.0
This commit is contained in:
commit
731850d7ec
34 changed files with 10658 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
52
README.md
Normal 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
2349
dist/main.js
vendored
Normal file
File diff suppressed because it is too large
Load diff
10
dist/manifest.json
vendored
Normal file
10
dist/manifest.json
vendored
Normal 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
17
dist/styles.css
vendored
Normal 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
48
esbuild.config.mjs
Normal 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
10
jest.config.js
Normal 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
67
main.ts
Normal 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
10
manifest.json
Normal 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
6056
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
39
package.json
Normal file
39
package.json
Normal 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
405
settings.ts
Normal 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;';
|
||||
}
|
||||
}
|
||||
23
src/core/IAlchemistModule.ts
Normal file
23
src/core/IAlchemistModule.ts
Normal 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
31
src/core/SystemAdapter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
36
src/core/__tests__/SystemAdapter.test.ts
Normal file
36
src/core/__tests__/SystemAdapter.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
222
src/features/audio-converter/AudioModule.ts
Normal file
222
src/features/audio-converter/AudioModule.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
}
|
||||
27
src/features/audio-converter/__tests__/logic.test.ts
Normal file
27
src/features/audio-converter/__tests__/logic.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
47
src/features/audio-converter/logic.ts
Normal file
47
src/features/audio-converter/logic.ts
Normal 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;
|
||||
}
|
||||
146
src/features/dataview-export/DataviewModule.ts
Normal file
146
src/features/dataview-export/DataviewModule.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
31
src/features/dataview-export/__tests__/logic.test.ts
Normal file
31
src/features/dataview-export/__tests__/logic.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
26
src/features/dataview-export/logic.ts
Normal file
26
src/features/dataview-export/logic.ts
Normal 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');
|
||||
}
|
||||
68
src/features/paste-cleaner/SmartPasteModule.ts
Normal file
68
src/features/paste-cleaner/SmartPasteModule.ts
Normal 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.
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
41
src/features/paste-cleaner/__tests__/logic.test.ts
Normal file
41
src/features/paste-cleaner/__tests__/logic.test.ts
Normal 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 & entities', () => {
|
||||
const url = 'https://example.com/?a=1&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);
|
||||
});
|
||||
});
|
||||
70
src/features/paste-cleaner/logic.ts
Normal file
70
src/features/paste-cleaner/logic.ts
Normal 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(/&/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(/ /g, ' ').replace(/\s\s+/g, ' ');
|
||||
|
||||
return content.trim();
|
||||
}
|
||||
191
src/features/textbundle/TextBundleModule.ts
Normal file
191
src/features/textbundle/TextBundleModule.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
44
src/features/textbundle/__tests__/logic.test.ts
Normal file
44
src/features/textbundle/__tests__/logic.test.ts
Normal 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: ';
|
||||
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: ';
|
||||
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);
|
||||
});
|
||||
});
|
||||
105
src/features/textbundle/collector.ts
Normal file
105
src/features/textbundle/collector.ts
Normal 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
|
||||
}
|
||||
}
|
||||
233
src/features/textbundle/importer.ts
Normal file
233
src/features/textbundle/importer.ts
Normal 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:  -> ![[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;
|
||||
}
|
||||
}
|
||||
53
src/features/textbundle/logic.ts
Normal file
53
src/features/textbundle/logic.ts
Normal 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 ``;
|
||||
}
|
||||
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;
|
||||
}
|
||||
121
src/features/textbundle/packer.ts
Normal file
121
src/features/textbundle/packer.ts
Normal 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
17
styles.css
Normal 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
36
tsconfig.json
Normal 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
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"1.0.0": "0.15.0"
|
||||
}
|
||||
Loading…
Reference in a new issue