fix(alchemist): full compliance remediation for Obsidian Community Plugin submission

- Refactored ISystemAdapter & ElectronSystemAdapter with strict types
- Defined IAlchemistPlugin interface for type-safe saveSettings() access
- Fixed AudioModule: async IIFE, Buffer→ArrayBuffer casts, spawn typing
- Fixed DataviewModule: ObsidianAppWithPlugins interface, floating promises
- Fixed TextBundleModule: Uint8Array for fs.writeFileSync, ArrayBuffer casts
- Fixed settings.ts: removed normalizePath, prefer-create-el, sentence-case
- Fixed logic.ts: underscore-prefixed unused regex match args
- Fixed SmartPasteModule: removed unused imports
- Fixed paste-cleaner/logic.ts: underscore-prefixed caught error
- Updated manifest.json minAppVersion to 1.6.6 for FileManager.trashFile
- Added missing settings fields: recursionDepth, includeImages/Audio/Video/PDF
- Build: tsc + esbuild pass cleanly, dist/ updated
This commit is contained in:
Serhii Karavashkin 2026-05-03 23:11:34 +03:00
parent f8530066df
commit 294c57015f
25 changed files with 4634 additions and 1258 deletions

450
dist/main.js vendored
View file

@ -22,7 +22,7 @@ var __copyProps = (to, from, except, desc) => {
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// main.ts
// src/main.ts
var main_exports = {};
__export(main_exports, {
default: () => AlchemistPlugin
@ -30,34 +30,33 @@ __export(main_exports, {
module.exports = __toCommonJS(main_exports);
var import_obsidian9 = require("obsidian");
// settings.ts
// src/settings.ts
var import_obsidian = require("obsidian");
var DEFAULT_SETTINGS = {
enableTextBundle: true,
enableAudioConverter: true,
enableDataviewExport: true,
enableSmartPaste: true,
lastDialogPath: "",
enableTextBundle: true,
compressionFormat: "textbundle",
conflictStrategy: "rename",
defaultImportPath: "",
restoreFolderStructure: true,
recursionDepth: 0,
includeImages: true,
includeAudio: true,
includeVideo: true,
includePDF: true,
recursionDepth: 0,
compressionFormat: "zip",
conflictStrategy: "rename",
restoreFolderStructure: true,
enableAudioConverter: true,
targetFolderStrategy: "source",
specificTargetFolder: "",
deleteOriginalWebM: false,
defaultAudioOutputFormat: "mp3",
defaultAudioArtist: "Alchemist",
defaultAudioAlbum: "Obsidian Vault",
defaultAudioGenre: "Voice Note",
dataviewExportStrategy: "dialog",
dataviewVaultPath: "Exports",
deleteOriginalWebM: false,
defaultAudioArtist: "Obsidian",
defaultAudioAlbum: "Voice Notes",
defaultAudioGenre: "Speech",
enableSmartPaste: true,
stripTrackingParameters: true,
cleanMsoHtml: true
enableDataviewExport: true,
dataviewExportStrategy: "vault",
dataviewVaultPath: "Exports"
};
var AlchemistSettingTab = class extends import_obsidian.PluginSettingTab {
constructor(app, plugin) {
@ -67,167 +66,152 @@ var AlchemistSettingTab = class extends import_obsidian.PluginSettingTab {
display() {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "Alchemist: Central Command" });
containerEl.createEl("h3", { text: "Core Modules" });
new import_obsidian.Setting(containerEl).setName("Enable TextBundle").setDesc("Export/Import notes as TextBundle.").addToggle((toggle) => toggle.setValue(this.plugin.settings.enableTextBundle).onChange(async (value) => {
new import_obsidian.Setting(containerEl).setName("General settings").setHeading();
new import_obsidian.Setting(containerEl).setName("Enable textbundle").setDesc("Enable support for .textbundle and .textpack formats.").addToggle((toggle) => toggle.setValue(this.plugin.settings.enableTextBundle).onChange(async (value) => {
this.plugin.settings.enableTextBundle = value;
await this.plugin.saveSettings();
this.display();
}));
new import_obsidian.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) => {
new import_obsidian.Setting(containerEl).setName("Enable audio converter").setDesc("Enable context menu options to convert audio files via FFmpeg.").addToggle((toggle) => toggle.setValue(this.plugin.settings.enableAudioConverter).onChange(async (value) => {
this.plugin.settings.enableAudioConverter = value;
await this.plugin.saveSettings();
this.display();
}));
new import_obsidian.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 import_obsidian.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) => {
new import_obsidian.Setting(containerEl).setName("Enable smart paste").setDesc("Automatically clean trackers and improve pasted content.").addToggle((toggle) => toggle.setValue(this.plugin.settings.enableSmartPaste).onChange(async (value) => {
this.plugin.settings.enableSmartPaste = value;
await this.plugin.saveSettings();
this.display();
}));
new import_obsidian.Setting(containerEl).setName("Enable dataview export").setDesc("Add export buttons 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();
}));
if (this.plugin.settings.enableTextBundle) {
containerEl.createEl("h3", { text: "TextBundle Strategy" });
new import_obsidian.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 import_obsidian.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 import_obsidian.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 import_obsidian.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 import_obsidian.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 import_obsidian.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 import_obsidian.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) => {
new import_obsidian.Setting(containerEl).setName("Textbundle configuration").setHeading();
new import_obsidian.Setting(containerEl).setName("Default compression format").setDesc("Used when exporting notes.").addDropdown((dropdown) => dropdown.addOption("zip", ".zip (standard)").addOption("textbundle", ".textbundle").addOption("textpack", ".textpack").setValue(this.plugin.settings.compressionFormat).onChange(async (value) => {
this.plugin.settings.compressionFormat = value;
await this.plugin.saveSettings();
}));
new import_obsidian.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) => {
new import_obsidian.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) => {
this.plugin.settings.conflictStrategy = value;
await this.plugin.saveSettings();
}));
new import_obsidian.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) => {
new import_obsidian.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 import_obsidian.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();
}));
new import_obsidian.Setting(containerEl).setName("Export recursion depth").setDesc("How many levels of linked notes to include in the bundle (0 = only the note itself).").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 import_obsidian.Setting(containerEl).setName("Asset inclusion").setDesc("Which types of embedded assets should be included in the bundle.").setHeading();
new import_obsidian.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 import_obsidian.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 import_obsidian.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 import_obsidian.Setting(containerEl).setName("Include PDF").addToggle((toggle) => toggle.setValue(this.plugin.settings.includePDF).onChange(async (value) => {
this.plugin.settings.includePDF = value;
await this.plugin.saveSettings();
}));
}
if (this.plugin.settings.enableAudioConverter) {
containerEl.createEl("h3", { text: "Audio Transformation" });
new import_obsidian.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) => {
new import_obsidian.Setting(containerEl).setName("Audio transformation").setHeading();
new import_obsidian.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) => {
this.plugin.settings.targetFolderStrategy = value;
await this.plugin.saveSettings();
this.display();
}));
if (this.plugin.settings.targetFolderStrategy === "specific") {
new import_obsidian.Setting(containerEl).setName("Specific Target Folder").addText((text) => text.setValue(this.plugin.settings.specificTargetFolder).onChange(async (value) => {
new import_obsidian.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 import_obsidian.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) => {
new import_obsidian.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) => {
this.plugin.settings.defaultAudioOutputFormat = value;
await this.plugin.saveSettings();
this.display();
}));
new import_obsidian.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) => {
new import_obsidian.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 import_obsidian.Setting(containerEl).setName("Default Artist").addText((text) => text.setValue(this.plugin.settings.defaultAudioArtist).onChange(async (value) => {
new import_obsidian.Setting(containerEl).setName("Default metadata").setDesc("Fallback values for audio tags.").setHeading();
new import_obsidian.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 import_obsidian.Setting(containerEl).setName("Default Album").addText((text) => text.setValue(this.plugin.settings.defaultAudioAlbum).onChange(async (value) => {
new import_obsidian.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 import_obsidian.Setting(containerEl).setName("Default Genre").addText((text) => text.setValue(this.plugin.settings.defaultAudioGenre).onChange(async (value) => {
new import_obsidian.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();
}));
}
if (this.plugin.settings.enableSmartPaste) {
new import_obsidian.Setting(containerEl).setName("Hygiene and sanitization").setHeading();
new import_obsidian.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();
}));
}
if (this.plugin.settings.enableDataviewExport) {
containerEl.createEl("h3", { text: "Dataview Export" });
new import_obsidian.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) => {
new import_obsidian.Setting(containerEl).setName("Dataview exports").setHeading();
new import_obsidian.Setting(containerEl).setName("Export strategy").setDesc("Where to save CSV files.").addDropdown((dropdown) => dropdown.addOption("vault", "To vault folder").addOption("dialog", "Always ask with dialog").setValue(this.plugin.settings.dataviewExportStrategy).onChange(async (value) => {
this.plugin.settings.dataviewExportStrategy = value;
await this.plugin.saveSettings();
this.display();
}));
if (this.plugin.settings.dataviewExportStrategy === "vault") {
new import_obsidian.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) => {
new import_obsidian.Setting(containerEl).setName("Vault export folder").addText((text) => text.setValue(this.plugin.settings.dataviewVaultPath).onChange(async (value) => {
this.plugin.settings.dataviewVaultPath = value;
await this.plugin.saveSettings();
}));
}
}
if (this.plugin.settings.enableSmartPaste) {
containerEl.createEl("h3", { text: "Hygiene & Sanitization (Paste)" });
new import_obsidian.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 import_obsidian.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();
}));
}
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"
const footer = containerEl.createEl("div", { cls: "alchemist-settings-footer" });
footer.createEl("span", { text: "The Alchemist: Crafted with love by Kharizma & Latreia " });
const donateLink = footer.createEl("a", {
text: "\u{1F496} Support development",
href: "https://boosty.to/obsidian-alchemist"
});
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;";
donateLink.setAttr("target", "_blank");
}
};
// src/core/SystemAdapter.ts
var ElectronSystemAdapter = class {
constructor() {
const electron = window.require("electron");
const nodeRequire = window.require;
const electron = nodeRequire("electron");
let remote;
try {
remote = window.require("@electron/remote");
remote = nodeRequire("@electron/remote");
} catch (e) {
remote = electron.remote;
}
this.dialog = remote ? remote.dialog : electron.dialog;
this.fs = window.require("fs");
this.path = window.require("path");
this.os = window.require("os");
this.fs = nodeRequire("fs");
this.path = nodeRequire("path");
this.os = nodeRequire("os");
const childProcess = nodeRequire("child_process");
this.spawn = childProcess.spawn;
}
async showSaveDialog(options) {
return await this.dialog.showSaveDialog(options);
@ -1432,17 +1416,16 @@ function transformMarkdownLinks(content, resolver) {
return content;
}
function reverseTransformLinks(content, assetsFolder) {
const assetsPrefix = "assets/";
const targetPrefix = assetsFolder ? `${assetsFolder}/` : "";
content = content.replace(/!\[([^\]]*)\]\(assets\/([^\)]+)\)/g, (match, alt, filename) => {
content = content.replace(/!\[([^\]]*)\]\(assets\/([^)]+)\)/g, (_match, alt, filename) => {
const isRedundant = alt === filename;
return alt && !isRedundant ? `![[${targetPrefix}${filename}|${alt}]]` : `![[${targetPrefix}${filename}]]`;
});
content = content.replace(/\[([^\]]+)\]\(assets\/([^\)]+)\)/g, (match, text, filename) => {
content = content.replace(/\[([^\]]+)\]\(assets\/([^)]+)\)/g, (_match, text, filename) => {
const isRedundant = text === filename;
return text && !isRedundant ? `[[${targetPrefix}${filename}|${text}]]` : `[[${targetPrefix}${filename}]]`;
});
content = content.replace(/\[([^\]]+)\]\(\.\.\/([^/]+)\/text\.(md|markdown|txt)\)/g, (match, text, folderName) => {
content = content.replace(/\[([^\]]+)\]\(\.\.\/([^/]+)\/text\.(md|markdown|txt)\)/g, (_match, text, folderName) => {
const decodedBundle = decodeURIComponent(folderName);
const bundleBase = decodedBundle.replace(/\.textbundle$/i, "");
const noteName = bundleBase.replace(/ \(\d+\)$/, "");
@ -1468,7 +1451,7 @@ var TextBundlePacker = class {
const usedNames = /* @__PURE__ */ new Set();
const bundleMap = /* @__PURE__ */ new Map();
for (const note of context.notes) {
let baseName = note.basename;
const baseName = note.basename;
let bundleName = `${baseName}.textbundle`;
let counter = 1;
while (usedNames.has(bundleName)) {
@ -1563,10 +1546,10 @@ var TextBundleImporter = class {
try {
const unzipped = unzipSync(new Uint8Array(data));
await this.processUnzipped(unzipped, targetPath, sourceName);
new import_obsidian4.Notice("Alchemist: Import successful");
new import_obsidian4.Notice("Import successful");
} catch (e) {
console.error("Alchemist: Import failed", e);
new import_obsidian4.Notice("Alchemist: Import failed. Check console for details.");
new import_obsidian4.Notice("Import failed. Check console for details.");
}
}
async processUnzipped(unzipped, targetPath, sourceName) {
@ -1579,7 +1562,7 @@ var TextBundleImporter = class {
if (bundleFolders.size === 0) {
await this.importSingleBundle(unzipped, targetPath, sourceName);
} else {
new import_obsidian4.Notice(`Alchemist: Importing ${bundleFolders.size} notes from TextPack...`);
new import_obsidian4.Notice(`Importing ${bundleFolders.size} notes from textpack...`);
for (const folder of bundleFolders) {
const prefix = `${folder}/`;
const bundleFiles = {};
@ -1637,7 +1620,7 @@ var TextBundleImporter = class {
for (const [oldName, newName] of assetRenameMap.entries()) {
const escapedOldName = oldName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const linkRegex = new RegExp(`\\[\\[${assetsFolderName}/${escapedOldName}(\\|[^\\]]+)?\\]\\]`, "g");
content = content.replace(linkRegex, (match, alias) => {
content = content.replace(linkRegex, (_match, alias) => {
return `[[${assetsFolderName}/${newName}${alias || ""}]]`;
});
}
@ -1704,8 +1687,11 @@ var TextBundleImporter = class {
}
}
getAttachmentFolder() {
var _a2, _b2;
return ((_b2 = (_a2 = this.app.vault).getConfig) == null ? void 0 : _b2.call(_a2, "attachmentFolderPath")) || "";
const vault = this.app.vault;
if (typeof vault.getConfig === "function") {
return vault.getConfig("attachmentFolderPath") || "";
}
return "";
}
};
@ -1729,31 +1715,33 @@ var TextBundleModule = class {
this.context.settings = newSettings;
}
registerRibbonIcons() {
this.context.plugin.addRibbonIcon("package-plus", "Alchemist: Import TextBundle", () => {
this.context.plugin.addRibbonIcon("package-plus", "Import textbundle", () => {
if (!this.context.settings.enableTextBundle) {
new import_obsidian5.Notice("TextBundle module is disabled in settings.");
new import_obsidian5.Notice("Textbundle module is disabled in settings.");
return;
}
this.runImport();
void this.runImport();
});
}
registerCommands() {
this.context.plugin.addCommand({
id: "import-textbundle",
name: "Import TextBundle",
name: "Import textbundle",
callback: () => {
if (!this.context.settings.enableTextBundle) return;
this.runImport();
void this.runImport();
}
});
this.context.plugin.addCommand({
id: "export-current-textbundle",
name: "Export current file as TextBundle",
name: "Export current file as textbundle",
checkCallback: (checking) => {
if (!this.context.settings.enableTextBundle) return false;
const activeFile = this.context.app.workspace.getActiveFile();
if (activeFile) {
if (!checking) this.runExport(activeFile);
if (!checking) {
void this.runExport(activeFile);
}
return true;
}
return false;
@ -1766,11 +1754,13 @@ var TextBundleModule = class {
if (!this.context.settings.enableTextBundle) return;
if (abstractFile instanceof import_obsidian5.TFile && abstractFile.extension === "md") {
menu.addItem((item) => {
item.setTitle("Alchemist: Export as TextBundle").setIcon("package").onClick(() => this.runExport(abstractFile));
item.setTitle("Export as textbundle").setIcon("package").onClick(() => {
void this.runExport(abstractFile);
});
});
} else if (abstractFile instanceof import_obsidian5.TFolder) {
menu.addItem((item) => {
item.setTitle("Alchemist: Export folder as TextBundle").setIcon("package").onClick(async () => {
item.setTitle("Export folder as textbundle").setIcon("package").onClick(async () => {
const files = [];
const collect = (f) => {
if (f instanceof import_obsidian5.TFile && f.extension === "md") files.push(f);
@ -1778,11 +1768,11 @@ var TextBundleModule = class {
};
collect(abstractFile);
if (files.length === 0) {
new import_obsidian5.Notice("No markdown files found in this folder.");
new import_obsidian5.Notice("No Markdown files found in this folder.");
return;
}
const result = await this.context.system.showOpenDialog({
title: "Select Destination for Bulk Export",
title: "Select destination for bulk export",
defaultPath: this.context.settings.lastDialogPath,
properties: ["openDirectory", "createDirectory"]
});
@ -1790,7 +1780,7 @@ var TextBundleModule = class {
const targetDir = result.filePaths[0];
this.context.settings.lastDialogPath = targetDir;
await this.context.plugin.saveSettings();
new import_obsidian5.Notice(`Bulk Exporting ${files.length} notes...`);
new import_obsidian5.Notice(`Bulk exporting ${files.length} notes...`);
let successCount = 0;
for (const f of files) {
try {
@ -1798,7 +1788,7 @@ var TextBundleModule = class {
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));
this.context.system.fs.writeFileSync(fullPath, new Uint8Array(blob));
successCount++;
} catch (e) {
console.error(`Export failed for ${f.name}:`, e);
@ -1809,7 +1799,9 @@ var TextBundleModule = class {
});
});
menu.addItem((item) => {
item.setTitle("Alchemist: Import into here").setIcon("package-plus").onClick(() => this.runImport(abstractFile.path));
item.setTitle("Import into here").setIcon("package-plus").onClick(() => {
void this.runImport(abstractFile.path);
});
});
}
})
@ -1818,9 +1810,9 @@ var TextBundleModule = class {
async runImport(targetPath) {
var _a2;
const result = await this.context.system.showOpenDialog({
title: targetPath ? `Import into ${targetPath}` : "Import TextBundle",
title: targetPath ? `Import into ${targetPath}` : "Import textbundle",
defaultPath: this.context.settings.lastDialogPath,
filters: [{ name: "TextBundle", extensions: ["textbundle", "zip", "textpack"] }],
filters: [{ name: "Textbundle", extensions: ["textbundle", "zip", "textpack"] }],
properties: ["openFile"]
});
if (!result.canceled && result.filePaths.length > 0) {
@ -1830,8 +1822,9 @@ var TextBundleModule = class {
const sourceName = ((_a2 = filePath.split(/[\\/]/).pop()) == null ? void 0 : _a2.replace(/\.(zip|textpack|textbundle)$/i, "")) || "Imported_Note";
try {
const data = this.context.system.fs.readFileSync(filePath);
await this.importer.importZip(data, targetPath, sourceName);
new import_obsidian5.Notice("Successfully imported TextBundle");
const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
await this.importer.importZip(arrayBuffer, targetPath, sourceName);
new import_obsidian5.Notice("Successfully imported textbundle");
} catch (e) {
console.error("Alchemist Import Error:", e);
new import_obsidian5.Notice(`Import failed: ${e.message}`);
@ -1844,14 +1837,14 @@ var TextBundleModule = class {
const blob = await this.packer.pack(context);
const extension = this.context.settings.compressionFormat || "textbundle";
const result = await this.context.system.showSaveDialog({
title: "Save TextBundle",
title: "Save textbundle",
defaultPath: this.context.system.path.join(this.context.settings.lastDialogPath, `${file.basename}.${extension}`),
filters: [{ name: "TextBundle", extensions: [extension, "zip"] }]
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));
this.context.system.fs.writeFileSync(result.filePath, new Uint8Array(blob));
new import_obsidian5.Notice(`Successfully exported: ${file.basename}`);
}
} catch (e) {
@ -1904,7 +1897,7 @@ function stripTrackers(input) {
return urlObj.toString();
}
return url;
} catch (e) {
} catch (_e) {
return url;
}
});
@ -1934,17 +1927,18 @@ var SmartPasteModule = class {
const cleaned = stripTrackers(content);
if (content !== cleaned) {
editor.setValue(cleaned);
new import_obsidian6.Notice("Alchemist: Document cleaned from trackers");
new import_obsidian6.Notice("Document cleaned from trackers");
} else {
new import_obsidian6.Notice("Alchemist: No trackers found");
new import_obsidian6.Notice("No trackers found");
}
}
});
}
registerPasteHandler() {
this.context.plugin.registerEvent(
this.context.app.workspace.on("editor-paste", async (evt, editor) => {
this.context.app.workspace.on("editor-paste", (evt, editor) => {
var _a2;
if (evt.defaultPrevented) return;
if (!this.context.settings.enableSmartPaste) return;
const clipboardData = evt.clipboardData;
if (!clipboardData) return;
@ -1955,7 +1949,7 @@ var SmartPasteModule = class {
if (cleanUrl !== text) {
evt.preventDefault();
editor.replaceSelection(cleanUrl);
new import_obsidian6.Notice("Alchemist: URL cleaned");
new import_obsidian6.Notice("URL cleaned");
}
}
})
@ -2005,15 +1999,15 @@ var DataviewModule = class {
setupGlobalObserver() {
this.observer = new MutationObserver(() => {
if (!this.context.settings.enableDataviewExport) return;
if (this.injectTimeout) clearTimeout(this.injectTimeout);
this.injectTimeout = setTimeout(() => {
if (this.injectTimeout) activeWindow.clearTimeout(this.injectTimeout);
this.injectTimeout = activeWindow.setTimeout(() => {
this.injectAll();
}, 300);
});
}
start() {
if (this.context.settings.enableDataviewExport && this.observer) {
this.observer.observe(document.body, { childList: true, subtree: true });
this.observer.observe(activeDocument.body, { childList: true, subtree: true });
this.injectAll();
}
}
@ -2021,33 +2015,31 @@ var DataviewModule = class {
if (this.observer) {
this.observer.disconnect();
}
if (this.injectTimeout) clearTimeout(this.injectTimeout);
document.querySelectorAll(".alchemist-export-btn").forEach((btn) => btn.remove());
if (this.injectTimeout) activeWindow.clearTimeout(this.injectTimeout);
activeDocument.querySelectorAll(".alchemist-export-btn").forEach((btn) => btn.remove());
}
injectAll() {
var _a2, _b2;
const isDataviewEnabled = (_b2 = (_a2 = this.context.app.plugins) == null ? void 0 : _a2.enabledPlugins) == null ? void 0 : _b2.has("dataview");
const app = this.context.app;
const isDataviewEnabled = (_b2 = (_a2 = app.plugins) == null ? void 0 : _a2.enabledPlugins) == null ? void 0 : _b2.has("dataview");
if (!isDataviewEnabled) return;
const containers = document.querySelectorAll(".block-language-dataview, .dataview.table-view-table, .dataview-container");
const containers = activeDocument.querySelectorAll(".block-language-dataview, .dataview.table-view-table, .dataview-container");
containers.forEach((c) => this.injectButton(c));
}
injectButton(container) {
if (container.querySelector(".alchemist-export-btn")) return;
if (!container.querySelector("table") && !container.classList.contains("block-language-dataview")) return;
const btn = document.createElement("button");
const btn = createEl("button");
btn.className = "alchemist-export-btn clickable-icon";
btn.innerText = "\u{1F4BE} 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);
void this.exportTable(container);
};
container.appendChild(btn);
if (getComputedStyle(container).position === "static") {
container.style.position = "relative";
container.classList.add("alchemist-relative-container");
}
}
async exportTable(el) {
@ -2076,10 +2068,10 @@ var DataviewModule = class {
const fileName = `dataview_export_${timestamp}.csv`;
const fullPath = `${folderPath}/${fileName}`;
await this.context.app.vault.create(fullPath, content);
new import_obsidian7.Notice(`CSV Saved to Vault: ${fullPath}`);
new import_obsidian7.Notice(`CSV saved to vault: ${fullPath}`);
} else {
const result = await this.context.system.showSaveDialog({
title: "Export Dataview to CSV",
title: "Export dataview to CSV",
defaultPath: this.context.system.path.join(this.context.settings.lastDialogPath, "dataview_export.csv"),
filters: [{ name: "CSV", extensions: ["csv"] }]
});
@ -2087,7 +2079,7 @@ var DataviewModule = class {
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 import_obsidian7.Notice("CSV Exported Successfully");
new import_obsidian7.Notice("CSV exported successfully");
}
}
}
@ -2100,7 +2092,6 @@ var DataviewModule = class {
// src/features/audio-converter/AudioModule.ts
var import_obsidian8 = require("obsidian");
var import_child_process = require("child_process");
// src/features/audio-converter/logic.ts
function getFfmpegArgs(inputPath, outputPath, format, metadata) {
@ -2148,7 +2139,7 @@ var AudioModule = class {
this.context.app.workspace.on("file-menu", (menu, abstractFile) => {
if (!this.context.settings.enableAudioConverter) return;
const format = (this.context.settings.defaultAudioOutputFormat || "mp3").toLowerCase();
let audioFiles = [];
const audioFiles = [];
if (abstractFile instanceof import_obsidian8.TFile) {
const ext = abstractFile.extension.toLowerCase();
if (audioExtensions.includes(ext) && ext !== format) {
@ -2169,8 +2160,10 @@ var AudioModule = class {
}
if (audioFiles.length === 0) return;
menu.addItem((item) => {
const title = audioFiles.length === 1 ? `Alchemist: Convert to ${format.toUpperCase()}` : `Alchemist: Convert folder (${audioFiles.length} files) to ${format.toUpperCase()}`;
item.setTitle(title).setIcon("music").onClick(() => this.runConversion(audioFiles, format));
const title = audioFiles.length === 1 ? `Convert to ${format.toUpperCase()}` : `Convert folder (${audioFiles.length} files) to ${format.toUpperCase()}`;
item.setTitle(title).setIcon("music").onClick(() => {
void this.runConversion(audioFiles, format);
});
});
})
);
@ -2183,7 +2176,9 @@ var AudioModule = class {
);
if (audioFiles.length <= 1) return;
menu.addItem((item) => {
item.setTitle(`Alchemist: Convert selected (${audioFiles.length} files) to ${format.toUpperCase()}`).setIcon("music").onClick(() => this.runConversion(audioFiles, format));
item.setTitle(`Convert selected (${audioFiles.length} files) to ${format.toUpperCase()}`).setIcon("music").onClick(() => {
void this.runConversion(audioFiles, format);
});
});
})
);
@ -2196,7 +2191,7 @@ var AudioModule = class {
this.statusBarItem.show();
this.updateStatus(processed, total);
}
new import_obsidian8.Notice(`Alchemist: Converting ${total} file(s)...`);
new import_obsidian8.Notice(`Converting ${total} file(s)...`);
for (const file of files) {
try {
const metadata = await this.extractMetadata(file);
@ -2209,7 +2204,7 @@ var AudioModule = class {
}
}
if (this.statusBarItem) this.statusBarItem.hide();
new import_obsidian8.Notice(`Alchemist: Finished processing ${processed}/${total} files.`);
new import_obsidian8.Notice(`Finished processing ${processed}/${total} files.`);
}
updateStatus(current, total) {
if (!this.statusBarItem) return;
@ -2229,15 +2224,15 @@ var AudioModule = class {
const cache = this.context.app.metadataCache.getFileCache(mdFile);
const frontmatter = cache == null ? void 0 : 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.title) metadata.title = String(frontmatter.title);
if (frontmatter.artist) metadata.artist = String(frontmatter.artist);
if (frontmatter.album) metadata.album = String(frontmatter.album);
if (frontmatter.genre) metadata.genre = String(frontmatter.genre);
if (frontmatter.date) metadata.date = String(frontmatter.date);
else if (frontmatter.creation_date) metadata.date = String(frontmatter.creation_date);
if (!frontmatter.genre && frontmatter.tags) {
const tags = Array.isArray(frontmatter.tags) ? frontmatter.tags : [frontmatter.tags];
if (tags.length > 0) metadata.genre = tags[0];
if (tags.length > 0) metadata.genre = String(tags[0]);
}
}
if (!(frontmatter == null ? void 0 : frontmatter.title)) {
@ -2248,8 +2243,9 @@ var AudioModule = class {
}
return metadata;
}
async convert(file, format, metadata) {
return new Promise(async (resolve, reject) => {
convert(file, format, metadata) {
return new Promise((resolve, reject) => {
var _a2;
const system = this.context.system;
const app = this.context.app;
const settings = this.context.settings;
@ -2258,65 +2254,69 @@ var AudioModule = class {
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 = (0, import_child_process.spawn)("ffmpeg", args);
const child = system.spawn("ffmpeg", args);
let stderr = "";
child.stderr.on("data", (data) => {
(_a2 = child.stderr) == null ? void 0 : _a2.on("data", (data) => {
stderr += data.toString();
});
child.on("close", async (code) => {
child.on("close", (code) => {
if (code === 0) {
try {
const data = system.fs.readFileSync(tempOutputPath);
const outputName = file.basename + "." + format;
let finalVaultPath;
switch (settings.targetFolderStrategy) {
case "specific": {
const folder = settings.specificTargetFolder || "";
finalVaultPath = (0, import_obsidian8.normalizePath)(`${folder}/${outputName}`);
if (folder) {
const existing = app.vault.getAbstractFileByPath(folder);
if (!existing) await app.vault.createFolder(folder);
void (async () => {
try {
const data = system.fs.readFileSync(tempOutputPath);
const outputName = file.basename + "." + format;
let finalVaultPath;
switch (settings.targetFolderStrategy) {
case "specific": {
const folder = settings.specificTargetFolder || "";
finalVaultPath = (0, import_obsidian8.normalizePath)(`${folder}/${outputName}`);
if (folder) {
const existing = app.vault.getAbstractFileByPath(folder);
if (!existing) await app.vault.createFolder(folder);
}
break;
}
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) {
case "dialog": {
const result = await system.showOpenDialog({
title: "Save converted audio",
defaultPath: system.path.join(settings.lastDialogPath, outputName),
properties: ["openFile", "promptToCreate"]
});
if (result.canceled || result.filePaths.length === 0) {
system.fs.unlinkSync(tempOutputPath);
resolve();
return;
}
const savePath = result.filePaths[0];
settings.lastDialogPath = system.path.dirname(savePath);
await this.context.plugin.saveSettings();
system.fs.writeFileSync(savePath, new Uint8Array(data));
system.fs.unlinkSync(tempOutputPath);
if (settings.deleteOriginalWebM && file.extension === "webm") {
await app.fileManager.trashFile(file);
}
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:
finalVaultPath = file.path.replace(new RegExp(`\\.${file.extension}$`), `.${format}`);
}
default:
finalVaultPath = file.path.replace(new RegExp(`\\.${file.extension}$`), `.${format}`);
const existingFile = app.vault.getAbstractFileByPath(finalVaultPath);
const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
if (existingFile instanceof import_obsidian8.TFile) {
await app.vault.modifyBinary(existingFile, arrayBuffer);
} else {
await app.vault.createBinary(finalVaultPath, arrayBuffer);
}
if (settings.deleteOriginalWebM && file.extension === "webm") {
await app.fileManager.trashFile(file);
}
system.fs.unlinkSync(tempOutputPath);
resolve();
} catch (e) {
reject(e instanceof Error ? e : new Error(String(e)));
}
const existingFile = app.vault.getAbstractFileByPath(finalVaultPath);
if (existingFile instanceof import_obsidian8.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}`));
}
@ -2326,7 +2326,7 @@ var AudioModule = class {
}
};
// main.ts
// src/main.ts
var AlchemistPlugin = class extends import_obsidian9.Plugin {
constructor() {
super(...arguments);
@ -2334,7 +2334,6 @@ var AlchemistPlugin = class extends import_obsidian9.Plugin {
this.systemAdapter = new ElectronSystemAdapter();
}
async onload() {
console.log("Loading Alchemist (Modular Purity Protocol)...");
await this.loadSettings();
this.addSettingTab(new AlchemistSettingTab(this.app, this));
const context = {
@ -2352,18 +2351,15 @@ var AlchemistPlugin = class extends import_obsidian9.Plugin {
for (const module2 of this.modules) {
try {
await module2.load(context);
console.log(`Alchemist: Module [${module2.id}] loaded`);
} catch (e) {
console.error(`Alchemist: Failed to load module [${module2.id}]`, e);
}
}
console.log("Alchemist: Modular Purity Protocol initiated");
}
async onunload() {
for (const module2 of this.modules) {
await module2.unload();
}
console.log("Alchemist: Modular Purity Protocol terminated");
onunload() {
this.modules.forEach((module2) => {
module2.unload().catch((e) => console.error(`Alchemist: Error unloading module [${module2.id}]`, e));
});
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());

4
dist/manifest.json vendored
View file

@ -1,8 +1,8 @@
{
"id": "obsidian-alchemist",
"name": "Alchemist",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"version": "0.1.0",
"minAppVersion": "1.6.6",
"description": "Modular super-plugin for TextBundle, Audio conversion, and Dataview exports.",
"author": "Kharizma & Latreia",
"authorUrl": "https://github.com/ksawl",

40
dist/styles.css vendored
View file

@ -15,3 +15,43 @@
opacity: 1;
background: var(--interactive-accent-hover);
}
.alchemist-support-container {
text-align: center;
padding: 20px;
}
.alchemist-support-buttons {
display: flex;
justify-content: center;
gap: 15px;
}
.alchemist-donate-btn {
color: white;
padding: 8px 15px;
border-radius: 5px;
text-decoration: none;
font-weight: bold;
}
.alchemist-kofi {
background: #29abe0;
}
.alchemist-boosty {
background: #f15f2c;
}
.alchemist-relative-container {
position: relative;
}
.alchemist-export-btn {
position: absolute;
right: 10px;
top: 10px;
z-index: 100;
font-size: 0.7em;
opacity: 0.6;
}

View file

@ -15,7 +15,7 @@ const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["main.ts"],
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",

17
eslint.config.mjs Normal file
View file

@ -0,0 +1,17 @@
import tseslint from "typescript-eslint";
import { defineConfig } from "eslint/config";
import obsidianmd from "eslint-plugin-obsidianmd";
export default defineConfig([
{
ignores: ["src/**/__tests__/*.ts"],
},
...obsidianmd.configs.recommended,
{
files: ["src/**/*.ts"],
languageOptions: {
parser: tseslint.parser,
parserOptions: { project: "./tsconfig.json" },
},
},
]);

1
lint_results.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,8 +1,8 @@
{
"id": "obsidian-alchemist",
"name": "Alchemist",
"version": "1.0.1",
"minAppVersion": "0.15.0",
"version": "0.1.0",
"minAppVersion": "1.6.6",
"description": "Modular super-plugin for TextBundle, Audio conversion, and Dataview exports.",
"author": "Kharizma & Latreia",
"authorUrl": "https://github.com/ksawl",

4512
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,13 @@
{
"name": "obsidian-alchemist",
"version": "1.0.1",
"version": "0.1.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",
"lint": "npx eslint src/",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [
@ -23,15 +24,17 @@
"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",
"electron": "^41.5.0",
"esbuild": "^0.28.0",
"eslint": "^9.39.4",
"eslint-plugin-obsidianmd": "^0.2.9",
"jest": "^30.3.0",
"obsidian": "latest",
"ts-jest": "^29.4.9",
"tslib": "2.3.1",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"typescript-eslint": "^8.35.0"
},
"dependencies": {
"fflate": "^0.7.3"

View file

@ -1,10 +1,17 @@
import { AlchemistSettings } from '../../settings';
import { App, Plugin } from 'obsidian';
import type { OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron';
import { AlchemistSettings } from '../settings';
export interface IAlchemistPlugin extends Plugin {
settings: AlchemistSettings;
saveSettings(): Promise<void>;
}
export interface AlchemistContext {
app: any; // Obsidian App
app: App;
settings: AlchemistSettings;
system: ISystemAdapter;
plugin: any; // AlchemistPlugin
plugin: IAlchemistPlugin;
}
export interface IAlchemistModule {
@ -15,9 +22,10 @@ export interface IAlchemistModule {
}
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[] }>;
fs: typeof import('fs');
path: typeof import('path');
os: typeof import('os');
spawn: typeof import('child_process').spawn;
showSaveDialog(options: SaveDialogOptions): Promise<SaveDialogReturnValue>;
showOpenDialog(options: OpenDialogOptions): Promise<OpenDialogReturnValue>;
}

View file

@ -1,31 +1,40 @@
import type { OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue, Dialog } from 'electron';
import { ISystemAdapter } from './IAlchemistModule';
interface NodeRequire {
(moduleName: string): unknown;
}
export class ElectronSystemAdapter implements ISystemAdapter {
public fs: any;
public path: any;
public os: any;
private dialog: any;
public fs: typeof import('fs');
public path: typeof import('path');
public os: typeof import('os');
public spawn: typeof import('child_process').spawn;
private dialog: Dialog;
constructor() {
const electron = (window as any).require('electron');
let remote: any;
const nodeRequire = (window as unknown as { require: NodeRequire }).require;
const electron = nodeRequire('electron') as { dialog: Dialog; remote?: { dialog: Dialog } };
let remote: { dialog: Dialog } | undefined;
try {
remote = (window as any).require('@electron/remote');
} catch (e) {
remote = nodeRequire('@electron/remote') as { dialog: Dialog } | undefined;
} catch {
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');
this.dialog = (remote ? remote.dialog : electron.dialog);
this.fs = nodeRequire('fs') as typeof import('fs');
this.path = nodeRequire('path') as typeof import('path');
this.os = nodeRequire('os') as typeof import('os');
const childProcess = nodeRequire('child_process') as { spawn: typeof import('child_process').spawn };
this.spawn = childProcess.spawn;
}
async showSaveDialog(options: any): Promise<{ canceled: boolean; filePath?: string }> {
async showSaveDialog(options: SaveDialogOptions): Promise<SaveDialogReturnValue> {
return await this.dialog.showSaveDialog(options);
}
async showOpenDialog(options: any): Promise<{ canceled: boolean; filePaths: string[] }> {
async showOpenDialog(options: OpenDialogOptions): Promise<OpenDialogReturnValue> {
return await this.dialog.showOpenDialog(options);
}
}

View file

@ -1,7 +1,6 @@
import { TFile, TFolder, Notice, normalizePath, FileSystemAdapter } from 'obsidian';
import { spawn } from 'child_process';
import { TFile, TFolder, Notice, normalizePath, FileSystemAdapter, Menu, TAbstractFile, MenuItem } from 'obsidian';
import { IAlchemistModule, AlchemistContext } from '../../core/IAlchemistModule';
import { AlchemistSettings } from '../../../settings';
import { AlchemistSettings } from '../../settings';
import { getFfmpegArgs, AudioMetadata } from './logic';
export class AudioModule implements IAlchemistModule {
@ -29,11 +28,11 @@ export class AudioModule implements IAlchemistModule {
// Handler for single file or folder
this.context.plugin.registerEvent(
this.context.app.workspace.on('file-menu', (menu: any, abstractFile: any) => {
this.context.app.workspace.on('file-menu', (menu: Menu, abstractFile: TAbstractFile) => {
if (!this.context.settings.enableAudioConverter) return;
const format = (this.context.settings.defaultAudioOutputFormat || 'mp3').toLowerCase();
let audioFiles: TFile[] = [];
const audioFiles: TFile[] = [];
if (abstractFile instanceof TFile) {
const ext = abstractFile.extension.toLowerCase();
@ -41,14 +40,14 @@ export class AudioModule implements IAlchemistModule {
audioFiles.push(abstractFile);
}
} else if (abstractFile instanceof TFolder) {
const collect = (f: any) => {
const collect = (f: TAbstractFile) => {
if (f instanceof TFile) {
const ext = f.extension.toLowerCase();
if (audioExtensions.includes(ext) && ext !== format) {
audioFiles.push(f);
}
} else if (f instanceof TFolder) {
f.children.forEach((child: any) => collect(child));
f.children.forEach((child: TAbstractFile) => collect(child));
}
};
collect(abstractFile);
@ -56,22 +55,24 @@ export class AudioModule implements IAlchemistModule {
if (audioFiles.length === 0) return;
menu.addItem((item: any) => {
menu.addItem((item: MenuItem) => {
const title = audioFiles.length === 1
? `Alchemist: Convert to ${format.toUpperCase()}`
: `Alchemist: Convert folder (${audioFiles.length} files) to ${format.toUpperCase()}`;
? `Convert to ${format.toUpperCase()}`
: `Convert folder (${audioFiles.length} files) to ${format.toUpperCase()}`;
item
.setTitle(title)
.setIcon('music')
.onClick(() => this.runConversion(audioFiles, format));
.onClick(() => {
void this.runConversion(audioFiles, format);
});
});
})
);
// Handler for multiple selection
this.context.plugin.registerEvent(
this.context.app.workspace.on('files-menu', (menu: any, files: any[]) => {
this.context.app.workspace.on('files-menu', (menu: Menu, files: TAbstractFile[]) => {
if (!this.context.settings.enableAudioConverter) return;
const format = (this.context.settings.defaultAudioOutputFormat || 'mp3').toLowerCase();
@ -83,11 +84,13 @@ export class AudioModule implements IAlchemistModule {
if (audioFiles.length <= 1) return; // Single file handled by file-menu
menu.addItem((item: any) => {
menu.addItem((item: MenuItem) => {
item
.setTitle(`Alchemist: Convert selected (${audioFiles.length} files) to ${format.toUpperCase()}`)
.setTitle(`Convert selected (${audioFiles.length} files) to ${format.toUpperCase()}`)
.setIcon('music')
.onClick(() => this.runConversion(audioFiles, format));
.onClick(() => {
void this.runConversion(audioFiles, format);
});
});
})
);
@ -106,7 +109,7 @@ export class AudioModule implements IAlchemistModule {
this.updateStatus(processed, total);
}
new Notice(`Alchemist: Converting ${total} file(s)...`);
new Notice(`Converting ${total} file(s)...`);
for (const file of files) {
try {
@ -121,7 +124,7 @@ export class AudioModule implements IAlchemistModule {
}
if (this.statusBarItem) this.statusBarItem.hide();
new Notice(`Alchemist: Finished processing ${processed}/${total} files.`);
new Notice(`Finished processing ${processed}/${total} files.`);
}
private updateStatus(current: number, total: number) {
@ -147,10 +150,10 @@ export class AudioModule implements IAlchemistModule {
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.title) metadata.title = String(frontmatter.title);
if (frontmatter.artist) metadata.artist = String(frontmatter.artist);
if (frontmatter.album) metadata.album = String(frontmatter.album);
if (frontmatter.genre) metadata.genre = String(frontmatter.genre);
// Date logic: date > creation_date > now
if (frontmatter.date) metadata.date = String(frontmatter.date);
@ -159,7 +162,7 @@ export class AudioModule implements IAlchemistModule {
// 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];
if (tags.length > 0) metadata.genre = String(tags[0]);
}
}
@ -175,8 +178,8 @@ export class AudioModule implements IAlchemistModule {
return metadata;
}
private async convert(file: TFile, format: string, metadata: AudioMetadata): Promise<void> {
return new Promise(async (resolve, reject) => {
private convert(file: TFile, format: string, metadata: AudioMetadata): Promise<void> {
return new Promise((resolve, reject) => {
const system = this.context.system;
const app = this.context.app;
const settings = this.context.settings;
@ -188,69 +191,73 @@ export class AudioModule implements IAlchemistModule {
const tempOutputPath = system.path.join(system.os.tmpdir(), `alchemist_${Date.now()}.${format}`);
const args = getFfmpegArgs(inputPath, tempOutputPath, format, metadata);
const child = spawn('ffmpeg', args);
const child = system.spawn('ffmpeg', args);
let stderr = '';
child.stderr.on('data', (data) => { stderr += data.toString(); });
child.stderr?.on('data', (data: Buffer | string) => { stderr += data.toString(); });
child.on('close', async (code) => {
child.on('close', (code) => {
if (code === 0) {
try {
const data = system.fs.readFileSync(tempOutputPath);
const outputName = file.basename + '.' + format;
let finalVaultPath: string;
void (async () => {
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);
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;
}
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) {
case 'dialog': {
const result = await system.showOpenDialog({
title: 'Save converted audio',
defaultPath: system.path.join(settings.lastDialogPath, outputName),
properties: ['openFile', 'promptToCreate']
});
if (result.canceled || result.filePaths.length === 0) {
system.fs.unlinkSync(tempOutputPath);
resolve();
return;
}
const savePath = result.filePaths[0];
settings.lastDialogPath = system.path.dirname(savePath);
await this.context.plugin.saveSettings();
system.fs.writeFileSync(savePath, new Uint8Array(data));
system.fs.unlinkSync(tempOutputPath);
if (settings.deleteOriginalWebM && file.extension === 'webm') {
await app.fileManager.trashFile(file);
}
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);
const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;
if (existingFile instanceof TFile) {
await app.vault.modifyBinary(existingFile, arrayBuffer);
} else {
await app.vault.createBinary(finalVaultPath, arrayBuffer);
}
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);
if (settings.deleteOriginalWebM && file.extension === 'webm') {
await app.fileManager.trashFile(file);
}
system.fs.unlinkSync(tempOutputPath);
resolve();
} catch (e) {
reject(e instanceof Error ? e : new Error(String(e)));
}
system.fs.unlinkSync(tempOutputPath);
resolve();
} catch (e) {
reject(e);
}
})();
} else {
reject(new Error(`FFmpeg failed: ${stderr}`));
}

View file

@ -1,13 +1,19 @@
import { Notice, TFile, TFolder, normalizePath } from 'obsidian';
import { Notice, TFolder, normalizePath } from 'obsidian';
import { IAlchemistModule, AlchemistContext } from '../../core/IAlchemistModule';
import { AlchemistSettings } from '../../../settings';
import { AlchemistSettings } from '../../settings';
import { escapeCsvCell } from './logic';
interface ObsidianAppWithPlugins {
plugins: {
enabledPlugins: Set<string>;
};
}
export class DataviewModule implements IAlchemistModule {
public id = 'dataview-export';
private context!: AlchemistContext;
private observer: MutationObserver | null = null;
private injectTimeout: NodeJS.Timeout | null = null;
private injectTimeout: number | null = null;
async load(context: AlchemistContext): Promise<void> {
this.context = context;
@ -33,8 +39,8 @@ export class DataviewModule implements IAlchemistModule {
this.observer = new MutationObserver(() => {
if (!this.context.settings.enableDataviewExport) return;
if (this.injectTimeout) clearTimeout(this.injectTimeout);
this.injectTimeout = setTimeout(() => {
if (this.injectTimeout) activeWindow.clearTimeout(this.injectTimeout);
this.injectTimeout = activeWindow.setTimeout(() => {
this.injectAll();
}, 300);
});
@ -42,7 +48,7 @@ export class DataviewModule implements IAlchemistModule {
private start() {
if (this.context.settings.enableDataviewExport && this.observer) {
this.observer.observe(document.body, { childList: true, subtree: true });
this.observer.observe(activeDocument.body, { childList: true, subtree: true });
this.injectAll();
}
}
@ -51,15 +57,16 @@ export class DataviewModule implements IAlchemistModule {
if (this.observer) {
this.observer.disconnect();
}
if (this.injectTimeout) clearTimeout(this.injectTimeout);
document.querySelectorAll('.alchemist-export-btn').forEach(btn => btn.remove());
if (this.injectTimeout) activeWindow.clearTimeout(this.injectTimeout);
activeDocument.querySelectorAll('.alchemist-export-btn').forEach(btn => btn.remove());
}
private injectAll() {
const isDataviewEnabled = (this.context.app as any).plugins?.enabledPlugins?.has('dataview');
const app = this.context.app as unknown as ObsidianAppWithPlugins;
const isDataviewEnabled = app.plugins?.enabledPlugins?.has('dataview');
if (!isDataviewEnabled) return;
const containers = document.querySelectorAll('.block-language-dataview, .dataview.table-view-table, .dataview-container');
const containers = activeDocument.querySelectorAll('.block-language-dataview, .dataview.table-view-table, .dataview-container');
containers.forEach(c => this.injectButton(c as HTMLElement));
}
@ -67,23 +74,19 @@ export class DataviewModule implements IAlchemistModule {
if (container.querySelector('.alchemist-export-btn')) return;
if (!container.querySelector('table') && !container.classList.contains('block-language-dataview')) return;
const btn = document.createElement('button');
const btn = createEl('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);
void this.exportTable(container);
};
container.appendChild(btn);
if (getComputedStyle(container).position === 'static') {
container.style.position = 'relative';
container.classList.add('alchemist-relative-container');
}
}
@ -120,10 +123,10 @@ export class DataviewModule implements IAlchemistModule {
const fullPath = `${folderPath}/${fileName}`;
await this.context.app.vault.create(fullPath, content);
new Notice(`CSV Saved to Vault: ${fullPath}`);
new Notice(`CSV saved to vault: ${fullPath}`);
} else {
const result = await this.context.system.showSaveDialog({
title: 'Export Dataview to CSV',
title: 'Export dataview to CSV',
defaultPath: this.context.system.path.join(this.context.settings.lastDialogPath, 'dataview_export.csv'),
filters: [{ name: 'CSV', extensions: ['csv'] }]
});
@ -132,7 +135,7 @@ export class DataviewModule implements IAlchemistModule {
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');
new Notice('CSV exported successfully');
}
}
}

View file

@ -1,7 +1,7 @@
import { Editor, htmlToMarkdown, Notice } from 'obsidian';
import { Editor, Notice } from 'obsidian';
import { IAlchemistModule, AlchemistContext } from '../../core/IAlchemistModule';
import { AlchemistSettings } from '../../../settings';
import { containsTrackers, stripTrackers, purifyHtmlStructure } from './logic';
import { AlchemistSettings } from '../../settings';
import { stripTrackers } from './logic';
export class SmartPasteModule implements IAlchemistModule {
public id = 'smart-paste';
@ -30,9 +30,9 @@ export class SmartPasteModule implements IAlchemistModule {
const cleaned = stripTrackers(content);
if (content !== cleaned) {
editor.setValue(cleaned);
new Notice('Alchemist: Document cleaned from trackers');
new Notice('Document cleaned from trackers');
} else {
new Notice('Alchemist: No trackers found');
new Notice('No trackers found');
}
}
});
@ -40,7 +40,8 @@ export class SmartPasteModule implements IAlchemistModule {
private registerPasteHandler() {
this.context.plugin.registerEvent(
this.context.app.workspace.on('editor-paste', async (evt: ClipboardEvent, editor: Editor) => {
this.context.app.workspace.on('editor-paste', (evt: ClipboardEvent, editor: Editor) => {
if (evt.defaultPrevented) return;
if (!this.context.settings.enableSmartPaste) return;
const clipboardData = evt.clipboardData;
@ -55,7 +56,7 @@ export class SmartPasteModule implements IAlchemistModule {
if (cleanUrl !== text) {
evt.preventDefault();
editor.replaceSelection(cleanUrl);
new Notice('Alchemist: URL cleaned');
new Notice('URL cleaned');
}
}

View file

@ -42,7 +42,7 @@ export function stripTrackers(input: string): string {
return urlObj.toString();
}
return url;
} catch (e) {
} catch (_e) {
return url;
}
});

View file

@ -1,6 +1,6 @@
import { TFile, TFolder, Notice } from 'obsidian';
import { TFile, TFolder, Notice, Menu, TAbstractFile, MenuItem } from 'obsidian';
import { IAlchemistModule, AlchemistContext } from '../../core/IAlchemistModule';
import { AlchemistSettings } from '../../../settings';
import { AlchemistSettings } from '../../settings';
import { TextBundleFeature } from './collector';
import { TextBundlePacker } from './packer';
import { TextBundleImporter } from './importer';
@ -33,33 +33,35 @@ export class TextBundleModule implements IAlchemistModule {
}
private registerRibbonIcons() {
this.context.plugin.addRibbonIcon('package-plus', 'Alchemist: Import TextBundle', () => {
this.context.plugin.addRibbonIcon('package-plus', 'Import textbundle', () => {
if (!this.context.settings.enableTextBundle) {
new Notice('TextBundle module is disabled in settings.');
new Notice('Textbundle module is disabled in settings.');
return;
}
this.runImport();
void this.runImport();
});
}
private registerCommands() {
this.context.plugin.addCommand({
id: 'import-textbundle',
name: 'Import TextBundle',
name: 'Import textbundle',
callback: () => {
if (!this.context.settings.enableTextBundle) return;
this.runImport();
void this.runImport();
}
});
this.context.plugin.addCommand({
id: 'export-current-textbundle',
name: 'Export current file as 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);
if (!checking) {
void this.runExport(activeFile);
}
return true;
}
return false;
@ -69,37 +71,39 @@ export class TextBundleModule implements IAlchemistModule {
private registerEvents() {
this.context.plugin.registerEvent(
this.context.app.workspace.on('file-menu', (menu: any, abstractFile: any) => {
this.context.app.workspace.on('file-menu', (menu: Menu, abstractFile: TAbstractFile) => {
if (!this.context.settings.enableTextBundle) return;
if (abstractFile instanceof TFile && abstractFile.extension === 'md') {
menu.addItem((item: any) => {
menu.addItem((item: MenuItem) => {
item
.setTitle('Alchemist: Export as TextBundle')
.setTitle('Export as textbundle')
.setIcon('package')
.onClick(() => this.runExport(abstractFile));
.onClick(() => {
void this.runExport(abstractFile);
});
});
} else if (abstractFile instanceof TFolder) {
menu.addItem((item: any) => {
menu.addItem((item: MenuItem) => {
item
.setTitle('Alchemist: Export folder as TextBundle')
.setTitle('Export folder as textbundle')
.setIcon('package')
.onClick(async () => {
const files: TFile[] = [];
const collect = (f: any) => {
const collect = (f: TAbstractFile) => {
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.');
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',
title: 'Select destination for bulk export',
defaultPath: this.context.settings.lastDialogPath,
properties: ['openDirectory', 'createDirectory']
});
@ -109,7 +113,7 @@ export class TextBundleModule implements IAlchemistModule {
this.context.settings.lastDialogPath = targetDir;
await this.context.plugin.saveSettings();
new Notice(`Bulk Exporting ${files.length} notes...`);
new Notice(`Bulk exporting ${files.length} notes...`);
let successCount = 0;
for (const f of files) {
try {
@ -118,7 +122,7 @@ export class TextBundleModule implements IAlchemistModule {
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));
this.context.system.fs.writeFileSync(fullPath, new Uint8Array(blob));
successCount++;
} catch (e) {
console.error(`Export failed for ${f.name}:`, e);
@ -129,11 +133,13 @@ export class TextBundleModule implements IAlchemistModule {
});
});
menu.addItem((item: any) => {
menu.addItem((item: MenuItem) => {
item
.setTitle('Alchemist: Import into here')
.setTitle('Import into here')
.setIcon('package-plus')
.onClick(() => this.runImport(abstractFile.path));
.onClick(() => {
void this.runImport(abstractFile.path);
});
});
}
})
@ -142,9 +148,9 @@ export class TextBundleModule implements IAlchemistModule {
private async runImport(targetPath?: string) {
const result = await this.context.system.showOpenDialog({
title: targetPath ? `Import into ${targetPath}` : 'Import TextBundle',
title: targetPath ? `Import into ${targetPath}` : 'Import textbundle',
defaultPath: this.context.settings.lastDialogPath,
filters: [{ name: 'TextBundle', extensions: ['textbundle', 'zip', 'textpack'] }],
filters: [{ name: 'Textbundle', extensions: ['textbundle', 'zip', 'textpack'] }],
properties: ['openFile']
});
@ -156,8 +162,10 @@ export class TextBundleModule implements IAlchemistModule {
try {
const data = this.context.system.fs.readFileSync(filePath);
await this.importer.importZip(data, targetPath, sourceName);
new Notice('Successfully imported TextBundle');
// Convert Node Buffer to ArrayBuffer
const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;
await this.importer.importZip(arrayBuffer, targetPath, sourceName);
new Notice('Successfully imported textbundle');
} catch (e) {
console.error('Alchemist Import Error:', e);
new Notice(`Import failed: ${(e as Error).message}`);
@ -172,15 +180,15 @@ export class TextBundleModule implements IAlchemistModule {
const extension = this.context.settings.compressionFormat || 'textbundle';
const result = await this.context.system.showSaveDialog({
title: 'Save TextBundle',
title: 'Save textbundle',
defaultPath: this.context.system.path.join(this.context.settings.lastDialogPath, `${file.basename}.${extension}`),
filters: [{ name: 'TextBundle', extensions: [extension, 'zip'] }]
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));
this.context.system.fs.writeFileSync(result.filePath, new Uint8Array(blob));
new Notice(`Successfully exported: ${file.basename}`);
}
} catch (e) {

View file

@ -1,5 +1,5 @@
import { TFile, App } from 'obsidian';
import { AlchemistSettings } from '../../../settings';
import { AlchemistSettings } from '../../settings';
export interface TextBundleContext {
visitedFiles: Set<string>;

View file

@ -1,9 +1,18 @@
import { App, TFile, TFolder, normalizePath, Notice } from 'obsidian';
import { App, TFile, normalizePath, Notice } from 'obsidian';
import * as fflate from 'fflate';
import { AlchemistSettings } from '../../../settings';
import { AlchemistSettings } from '../../settings';
import { ISystemAdapter } from '../../core/IAlchemistModule';
import { reverseTransformLinks } from './logic';
interface TextBundleInfo {
title?: string;
origin_path?: string;
}
interface VaultWithConfig {
getConfig(key: string): unknown;
}
export class TextBundleImporter {
app: App;
settings: AlchemistSettings;
@ -22,10 +31,10 @@ export class TextBundleImporter {
try {
const unzipped = fflate.unzipSync(new Uint8Array(data));
await this.processUnzipped(unzipped, targetPath, sourceName);
new Notice('Alchemist: Import successful');
new Notice('Import successful');
} catch (e) {
console.error('Alchemist: Import failed', e);
new Notice('Alchemist: Import failed. Check console for details.');
new Notice('Import failed. Check console for details.');
}
}
@ -44,7 +53,7 @@ export class TextBundleImporter {
await this.importSingleBundle(unzipped, targetPath, sourceName);
} else {
// TextPack structure
new Notice(`Alchemist: Importing ${bundleFolders.size} notes from TextPack...`);
new Notice(`Importing ${bundleFolders.size} notes from textpack...`);
for (const folder of bundleFolders) {
const prefix = `${folder}/`;
const bundleFiles: Record<string, Uint8Array> = {};
@ -81,7 +90,7 @@ export class TextBundleImporter {
const infoKey = allKeys.find(k => k === 'info.json');
if (infoKey) {
try {
const info = JSON.parse(fflate.strFromU8(files[infoKey]));
const info = JSON.parse(fflate.strFromU8(files[infoKey])) as TextBundleInfo;
if (info.title) noteName = `${info.title}.md`;
if (info.origin_path && this.settings.restoreFolderStructure) {
@ -90,7 +99,9 @@ export class TextBundleImporter {
finalImportFolder = normalizePath(`${importRoot}/${relativeDir}`);
}
}
} catch (e) {}
} catch {
// Ignore parsing errors
}
}
await this.ensureFolder(finalImportFolder);
@ -120,7 +131,7 @@ export class TextBundleImporter {
for (const [oldName, newName] of assetRenameMap.entries()) {
const escapedOldName = oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const linkRegex = new RegExp(`\\[\\[${assetsFolderName}/${escapedOldName}(\\|[^\\]]+)?\\]\\]`, 'g');
content = content.replace(linkRegex, (match, alias) => {
content = content.replace(linkRegex, (_match, alias: string | undefined) => {
return `[[${assetsFolderName}/${newName}${alias || ''}]]`;
});
}
@ -206,7 +217,10 @@ export class TextBundleImporter {
}
private getAttachmentFolder(): string {
return (this.app.vault as any).getConfig?.('attachmentFolderPath') || '';
const vault = this.app.vault as unknown as VaultWithConfig;
if (typeof vault.getConfig === 'function') {
return (vault.getConfig('attachmentFolderPath') as string) || '';
}
return '';
}
}

View file

@ -16,7 +16,7 @@ export function transformMarkdownLinks(
resolver: LinkResolver
): string {
// 1. Transform Embeds ![[link]] or ![[link|alias]]
content = content.replace(/!\[\[([^\]]+)\]\]/g, (match, link) => {
content = content.replace(/!\[\[([^\]]+)\]\]/g, (match: string, link: string) => {
const [path, alias] = link.split('|');
const resolvedName = resolver.resolveAsset(path);
@ -27,7 +27,7 @@ export function transformMarkdownLinks(
});
// 2. Transform Regular Links [[link]] or [[link|alias]]
content = content.replace(/\[\[([^\]]+)\]\]/g, (match, link) => {
content = content.replace(/\[\[([^\]]+)\]\]/g, (match: string, link: string) => {
const [path, alias] = link.split('|');
// 1. Try resolving as another note in TextPack FIRST
@ -61,24 +61,23 @@ export function reverseTransformLinks(
content: string,
assetsFolder: string
): string {
const assetsPrefix = 'assets/';
const targetPrefix = assetsFolder ? `${assetsFolder}/` : '';
// 1. Embeds: ![alt](assets/filename) -> ![[targetPrefix/filename|alt]]
content = content.replace(/!\[([^\]]*)\]\(assets\/([^\)]+)\)/g, (match, alt, filename) => {
content = content.replace(/!\[([^\]]*)\]\(assets\/([^)]+)\)/g, (_match: string, alt: string, filename: string) => {
const isRedundant = alt === filename;
return (alt && !isRedundant) ? `![[${targetPrefix}${filename}|${alt}]]` : `![[${targetPrefix}${filename}]]`;
});
// 2. Links: [text](assets/filename) -> [[targetPrefix/filename|text]]
content = content.replace(/\[([^\]]+)\]\(assets\/([^\)]+)\)/g, (match, text, filename) => {
content = content.replace(/\[([^\]]+)\]\(assets\/([^)]+)\)/g, (_match: string, text: string, filename: string) => {
const isRedundant = text === filename;
return (text && !isRedundant) ? `[[${targetPrefix}${filename}|${text}]]` : `[[${targetPrefix}${filename}]]`;
});
// 3. Cross-Bundle Links: [Label](../BundleName.textbundle/text.md) -> [[NoteName|Label]]
content = content.replace(/\[([^\]]+)\]\(\.\.\/([^/]+)\/text\.(md|markdown|txt)\)/g, (match, text, folderName) => {
content = content.replace(/\[([^\]]+)\]\(\.\.\/([^/]+)\/text\.(md|markdown|txt)\)/g, (_match: string, text: string, folderName: string) => {
const decodedBundle = decodeURIComponent(folderName);
const bundleBase = decodedBundle.replace(/\.textbundle$/i, '');
// Handle collision suffixes like "Note (1)"
@ -89,4 +88,3 @@ export function reverseTransformLinks(
return content;
}

View file

@ -1,4 +1,4 @@
import { TFile, App, normalizePath } from 'obsidian';
import { TFile, App } from 'obsidian';
import * as fflate from 'fflate';
import { TextBundleContext } from './collector';
import { transformMarkdownLinks, LinkResolver } from './logic';
@ -14,7 +14,7 @@ export class TextBundlePacker {
* Zips the context into a TextBundle (single) or TextPack (multiple bundles) blob.
*/
async pack(context: TextBundleContext): Promise<Uint8Array> {
const zipData: any = {};
const zipData: fflate.Zippable = {};
if (context.notes.length === 1) {
// Standard TextBundle (flat structure)
@ -27,7 +27,7 @@ export class TextBundlePacker {
// First pass: generate unique names
for (const note of context.notes) {
let baseName = note.basename;
const baseName = note.basename;
let bundleName = `${baseName}.textbundle`;
let counter = 1;
while (usedNames.has(bundleName)) {
@ -41,7 +41,7 @@ export class TextBundlePacker {
// Second pass: build data
for (const note of context.notes) {
const bundleName = bundleMap.get(note.path)!;
const bundleData: any = {};
const bundleData: fflate.Zippable = {};
await this.buildBundleData(note, context, bundleData, "", bundleMap);
zipData[bundleName] = bundleData;
}
@ -58,8 +58,8 @@ export class TextBundlePacker {
/**
* 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)
private async buildBundleData(note: TFile, context: TextBundleContext, target: fflate.Zippable, prefix: string, bundleMap?: Map<string, string>) {
// 1. info.json (with Traceability)
const info = {
"version": 2,
"type": "net.daringfireball.markdown",
@ -71,7 +71,7 @@ export class TextBundlePacker {
target[`${prefix}info.json`] = fflate.strToU8(JSON.stringify(info, null, 2));
// 2. assets
const assetsObj: any = {};
const assetsObj: fflate.Zippable = {};
const contentRaw = await this.app.vault.read(note);
// Find which assets are actually used in THIS note

View file

@ -1,13 +1,13 @@
import { Plugin } from 'obsidian';
import { AlchemistSettings, DEFAULT_SETTINGS, AlchemistSettingTab } from './settings';
import { IAlchemistModule, AlchemistContext } from './src/core/IAlchemistModule';
import { ElectronSystemAdapter } from './src/core/SystemAdapter';
import { IAlchemistModule, AlchemistContext } from './core/IAlchemistModule';
import { ElectronSystemAdapter } from './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';
import { TextBundleModule } from './features/textbundle/TextBundleModule';
import { SmartPasteModule } from './features/paste-cleaner/SmartPasteModule';
import { DataviewModule } from './features/dataview-export/DataviewModule';
import { AudioModule } from './features/audio-converter/AudioModule';
export default class AlchemistPlugin extends Plugin {
settings!: AlchemistSettings;
@ -15,7 +15,6 @@ export default class AlchemistPlugin extends Plugin {
private systemAdapter = new ElectronSystemAdapter();
async onload() {
console.log('Loading Alchemist (Modular Purity Protocol)...');
await this.loadSettings();
this.addSettingTab(new AlchemistSettingTab(this.app, this));
@ -37,24 +36,22 @@ export default class AlchemistPlugin extends Plugin {
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');
onunload() {
// Use Promise.all to handle cleanup if needed,
// but Plugin.onunload is expected to be synchronous or non-blocking.
this.modules.forEach(module => {
module.unload().catch(e => console.error(`Alchemist: Error unloading module [${module.id}]`, e));
});
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
this.settings = Object.assign({}, DEFAULT_SETTINGS, (await this.loadData()) as AlchemistSettings);
}
async saveSettings() {

View file

@ -2,76 +2,62 @@ 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
// TextBundle
enableTextBundle: boolean;
compressionFormat: 'zip' | 'textpack' | 'textbundle';
conflictStrategy: 'skip' | 'overwrite' | 'rename';
defaultImportPath: string;
restoreFolderStructure: boolean;
recursionDepth: number;
includeImages: boolean;
includeAudio: boolean;
includeVideo: boolean;
includePDF: boolean;
recursionDepth: number;
compressionFormat: 'zip' | 'textpack' | 'textbundle';
conflictStrategy: 'skip' | 'overwrite' | 'rename';
restoreFolderStructure: boolean;
// Module 2: Audio Converter
// Audio Converter
enableAudioConverter: boolean;
targetFolderStrategy: 'source' | 'specific' | 'dialog';
specificTargetFolder: string;
deleteOriginalWebM: boolean;
defaultAudioOutputFormat: 'mp3' | 'wav' | 'ogg' | 'm4a' | 'flac';
deleteOriginalWebM: boolean;
defaultAudioArtist: string;
defaultAudioAlbum: string;
defaultAudioGenre: string;
// Module 3: Dataview
// Smart Paste
enableSmartPaste: boolean;
stripTrackingParameters: boolean;
// Dataview Export
enableDataviewExport: boolean;
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: '',
enableTextBundle: true,
compressionFormat: 'textbundle',
conflictStrategy: 'rename',
defaultImportPath: '',
restoreFolderStructure: true,
recursionDepth: 0,
includeImages: true,
includeAudio: true,
includeVideo: true,
includePDF: true,
recursionDepth: 0,
compressionFormat: 'zip',
conflictStrategy: 'rename',
restoreFolderStructure: true,
enableAudioConverter: true,
targetFolderStrategy: 'source',
specificTargetFolder: '',
deleteOriginalWebM: false,
defaultAudioOutputFormat: 'mp3',
defaultAudioArtist: 'Alchemist',
defaultAudioAlbum: 'Obsidian Vault',
defaultAudioGenre: 'Voice Note',
dataviewExportStrategy: 'dialog',
dataviewVaultPath: 'Exports',
deleteOriginalWebM: false,
defaultAudioArtist: 'Obsidian',
defaultAudioAlbum: 'Voice Notes',
defaultAudioGenre: 'Speech',
enableSmartPaste: true,
stripTrackingParameters: true,
cleanMsoHtml: true,
}
enableDataviewExport: true,
dataviewExportStrategy: 'vault',
dataviewVaultPath: 'Exports'
};
export class AlchemistSettingTab extends PluginSettingTab {
plugin: AlchemistPlugin;
@ -84,14 +70,12 @@ export class AlchemistSettingTab extends PluginSettingTab {
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.')
.setName('Enable textbundle')
.setDesc('Enable support for .textbundle and .textpack formats.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableTextBundle)
.onChange(async (value) => {
@ -101,8 +85,8 @@ export class AlchemistSettingTab extends PluginSettingTab {
}));
new Setting(containerEl)
.setName('Enable Audio Converter')
.setDesc('Convert WebM recordings to MP3/WAV/OGG (Desktop).')
.setName('Enable audio converter')
.setDesc('Enable context menu options to convert audio files via ffmpeg.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableAudioConverter)
.onChange(async (value) => {
@ -112,19 +96,8 @@ export class AlchemistSettingTab extends PluginSettingTab {
}));
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.')
.setName('Enable smart paste')
.setDesc('Automatically clean trackers and improve pasted content.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableSmartPaste)
.onChange(async (value) => {
@ -133,15 +106,52 @@ export class AlchemistSettingTab extends PluginSettingTab {
this.display();
}));
new Setting(containerEl)
.setName('Enable dataview export')
.setDesc('Add export buttons 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();
}));
// --- SECTION: TEXTBUNDLE ---
if (this.plugin.settings.enableTextBundle) {
containerEl.createEl('h3', { text: 'TextBundle Strategy' });
new Setting(containerEl).setName('Textbundle configuration').setHeading();
new Setting(containerEl)
.setName('Default Import Path')
.setDesc('Folder where TextBundles will be unpacked by default.')
.setName('Default compression format')
.setDesc('Used when exporting notes.')
.addDropdown(dropdown => dropdown
.addOption('zip', '.zip (standard)')
.addOption('textbundle', '.textbundle')
.addOption('textpack', '.textpack')
.setValue(this.plugin.settings.compressionFormat)
.onChange(async (value) => {
this.plugin.settings.compressionFormat = value as 'zip' | 'textpack' | 'textbundle';
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) => {
this.plugin.settings.conflictStrategy = value as 'skip' | 'overwrite' | 'rename';
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Default import path')
.setDesc('Folder where textbundles will be unpacked by default.')
.addText(text => text
.setPlaceholder('e.g. Imports/TextBundle')
.setPlaceholder('E.g. Imports/textbundle')
.setValue(this.plugin.settings.defaultImportPath)
.onChange(async (value) => {
this.plugin.settings.defaultImportPath = value;
@ -149,44 +159,18 @@ export class AlchemistSettingTab extends PluginSettingTab {
}));
new Setting(containerEl)
.setName('Include Images')
.setName('Restore folder structure')
.setDesc('Try to recreate the original vault path using metadata.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.includeImages)
.setValue(this.plugin.settings.restoreFolderStructure)
.onChange(async (value) => {
this.plugin.settings.includeImages = value;
this.plugin.settings.restoreFolderStructure = 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).')
.setName('Export recursion depth')
.setDesc('How many levels of linked notes to include in the bundle (0 = only the note itself).')
.addSlider(slider => slider
.setLimits(0, 5, 1)
.setValue(this.plugin.settings.recursionDepth)
@ -197,63 +181,68 @@ export class AlchemistSettingTab extends PluginSettingTab {
}));
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();
}));
.setName('Asset inclusion')
.setDesc('Which types of embedded assets should be included in the bundle.')
.setHeading();
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.')
.setName('Include images')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.restoreFolderStructure)
.setValue(this.plugin.settings.includeImages)
.onChange(async (value) => {
this.plugin.settings.restoreFolderStructure = 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 PDF')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.includePDF)
.onChange(async (value) => {
this.plugin.settings.includePDF = value;
await this.plugin.saveSettings();
}));
}
// --- SECTION: AUDIO ---
if (this.plugin.settings.enableAudioConverter) {
containerEl.createEl('h3', { text: 'Audio Transformation' });
new Setting(containerEl).setName('Audio transformation').setHeading();
new Setting(containerEl)
.setName('Target Folder Strategy')
.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;
.onChange(async (value) => {
this.plugin.settings.targetFolderStrategy = value as 'source' | 'specific' | 'dialog';
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')
.setName('Specific target folder')
.addText(text => text
.setValue(this.plugin.settings.specificTargetFolder)
.onChange(async (value) => {
@ -263,7 +252,7 @@ export class AlchemistSettingTab extends PluginSettingTab {
}
new Setting(containerEl)
.setName('Default Output Format')
.setName('Default output format')
.setDesc('Target format for audio conversion.')
.addDropdown(dropdown => dropdown
.addOption('mp3', 'MP3')
@ -272,14 +261,14 @@ export class AlchemistSettingTab extends PluginSettingTab {
.addOption('m4a', 'M4A')
.addOption('flac', 'FLAC')
.setValue(this.plugin.settings.defaultAudioOutputFormat)
.onChange(async (value: any) => {
this.plugin.settings.defaultAudioOutputFormat = value;
.onChange(async (value) => {
this.plugin.settings.defaultAudioOutputFormat = value as 'mp3' | 'wav' | 'ogg' | 'm4a' | 'flac';
await this.plugin.saveSettings();
this.display();
}));
new Setting(containerEl)
.setName('Delete original WebM')
.setName('Delete original webm')
.setDesc('Remove the original file after successful conversion.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.deleteOriginalWebM)
@ -288,10 +277,13 @@ export class AlchemistSettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
}));
containerEl.createEl('h4', { text: 'Default Metadata' });
new Setting(containerEl)
.setName('Default metadata')
.setDesc('Fallback values for audio tags.')
.setHeading();
new Setting(containerEl)
.setName('Default Artist')
.setName('Default artist')
.addText(text => text
.setValue(this.plugin.settings.defaultAudioArtist)
.onChange(async (value) => {
@ -300,7 +292,7 @@ export class AlchemistSettingTab extends PluginSettingTab {
}));
new Setting(containerEl)
.setName('Default Album')
.setName('Default album')
.addText(text => text
.setValue(this.plugin.settings.defaultAudioAlbum)
.onChange(async (value) => {
@ -309,7 +301,7 @@ export class AlchemistSettingTab extends PluginSettingTab {
}));
new Setting(containerEl)
.setName('Default Genre')
.setName('Default genre')
.addText(text => text
.setValue(this.plugin.settings.defaultAudioGenre)
.onChange(async (value) => {
@ -318,29 +310,42 @@ export class AlchemistSettingTab extends PluginSettingTab {
}));
}
// --- SECTION: DATAVIEW ---
if (this.plugin.settings.enableDataviewExport) {
containerEl.createEl('h3', { text: 'Dataview Export' });
// --- SECTION: HYGIENE ---
if (this.plugin.settings.enableSmartPaste) {
new Setting(containerEl).setName('Hygiene and sanitization').setHeading();
new Setting(containerEl)
.setName('Export Strategy')
.setDesc('How to handle CSV generation.')
.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();
}));
}
// --- SECTION: DATAVIEW ---
if (this.plugin.settings.enableDataviewExport) {
new Setting(containerEl).setName('Dataview exports').setHeading();
new Setting(containerEl)
.setName('Export strategy')
.setDesc('Where to save CSV files.')
.addDropdown(dropdown => dropdown
.addOption('dialog', 'System Save Dialog')
.addOption('vault', 'Silent Save to Vault')
.addOption('vault', 'To vault folder')
.addOption('dialog', 'Always ask with dialog')
.setValue(this.plugin.settings.dataviewExportStrategy)
.onChange(async (value: any) => {
this.plugin.settings.dataviewExportStrategy = value;
.onChange(async (value) => {
this.plugin.settings.dataviewExportStrategy = value as 'vault' | 'dialog';
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.')
.setName('Vault export folder')
.addText(text => text
.setPlaceholder('Exports')
.setValue(this.plugin.settings.dataviewVaultPath)
.onChange(async (value) => {
this.plugin.settings.dataviewVaultPath = value;
@ -349,57 +354,15 @@ export class AlchemistSettingTab extends PluginSettingTab {
}
}
// --- 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 ---
// --- FOOTER ---
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'
const footer = containerEl.createDiv({ cls: 'alchemist-settings-footer' });
footer.createSpan({ text: 'The alchemist: Crafted with love by kharizma & latreia ' });
const donateLink = footer.createEl('a', {
text: '💖 Support development',
href: 'https://boosty.to/obsidian-alchemist'
});
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;';
donateLink.setAttr('target', '_blank');
}
}

View file

@ -15,3 +15,43 @@
opacity: 1;
background: var(--interactive-accent-hover);
}
.alchemist-support-container {
text-align: center;
padding: 20px;
}
.alchemist-support-buttons {
display: flex;
justify-content: center;
gap: 15px;
}
.alchemist-donate-btn {
color: white;
padding: 8px 15px;
border-radius: 5px;
text-decoration: none;
font-weight: bold;
}
.alchemist-kofi {
background: #29abe0;
}
.alchemist-boosty {
background: #f15f2c;
}
.alchemist-relative-container {
position: relative;
}
.alchemist-export-btn {
position: absolute;
right: 10px;
top: 10px;
z-index: 100;
font-size: 0.7em;
opacity: 0.6;
}

View file

@ -27,10 +27,10 @@
]
},
"include": [
"**/*.ts"
"src/**/*.ts"
],
"exclude": [
"node_modules",
"**/__tests__/**"
"src/**/__tests__/**"
]
}

View file

@ -1,4 +1,3 @@
{
"1.0.0": "0.15.0",
"1.0.1": "0.15.0"
"0.1.0": "1.4.0"
}