feat(release): version 1.0.1 - restore link symmetry and enhance audio converter

- fix(textbundle): restore link symmetry (assets folder) and fix redundant aliases
- fix(textbundle): improve Cyrillic handling in links and assets
- feat(audio): add video-to-audio extraction (mp4, mov, mkv, avi)
- feat(audio): add recursive folder conversion and multiple selection support
- feat(audio): implement smart metadata mapping (H1 heading and creation_date)
- chore(version): add version-bump.mjs automation and sync configs
- docs: update README and add CHANGELOG.md
This commit is contained in:
Serhii Karavashkin 2026-04-21 15:59:11 +03:00
parent 7508bc7954
commit f8530066df
12 changed files with 297 additions and 96 deletions

28
CHANGELOG.md Normal file
View file

@ -0,0 +1,28 @@
# Changelog
All notable changes to this project will be documented in this file.
## [1.0.1] - 2026-04-21
### Added
- **Audio Converter**: Support for video containers (`mp4`, `mov`, `mkv`, `avi`) to extract audio directly from video files.
- **Audio Converter**: Recursive folder conversion. Now you can convert entire folders of audio/video files at once.
- **Audio Converter**: Support for multiple file selection in the context menu.
- **Audio Converter**: Enhanced metadata extraction. Now supports note H1 headings as titles and `creation_date` YAML field for date tags.
- **Automation**: Added `version-bump.mjs` script for automated version synchronization.
### Fixed
- **TextBundle**: Critical fix for broken links during import. Links now correctly point to the `assets/` folder, ensuring full symmetry between export and import.
- **TextBundle**: Removed redundant aliases in markdown links when importing (no more `[[image.png|image.png]]`).
- **TextBundle**: Improved handling of Cyrillic characters in filenames and links.
- **Documentation**: Cleaned up non-renderable symbols in `README.md`.
### Security & Hygiene
- Improved Pure Logic separation in TextBundle features for better testability and reliability.
- Added comprehensive roundtrip tests for link integrity.
---
## [1.0.0] - 2026-04-15
- Initial public release of Alchemist.
- Modular architecture with TextBundle, Audio Converter, and Dataview Export.

View file

@ -6,15 +6,18 @@ Alchemist is a modular super-plugin designed for seamless data transmutation and
### TextBundle Export/Import
Preserve the sovereignty of your data. Export your notes with all linked assets (images, audio, PDFs) into a standardized .textbundle or .textpack format. Perfect for sharing notes with other apps or creating portable backups.
- Recursion Depth: Include linked notes up to 5 levels deep.
- Conflict Resolution: Smart hashing to detect identical files and avoid duplicates.
- TextPack Support: Compressed multi-note archives.
- **Link Symmetry**: Ensuring links work perfectly after export and subsequent import back to Obsidian.
- **Recursion Depth**: Include linked notes up to 5 levels deep.
- **Conflict Resolution**: Smart hashing to detect identical files and avoid duplicates.
- **TextPack Support**: Compressed multi-note archives.
### Audio Converter (Desktop Only)
Transform your voice recordings into high-quality MP3s or FLACs without leaving Obsidian.
- Meta-Sync: Automatically extracts ID3 tags (Title, Artist, Date, Genre) from the note's YAML frontmatter.
- Cleanup: Option to delete the original heavy .webm files after successful conversion.
- FFmpeg Powered: Uses the industry-standard FFmpeg engine for crystal-clear quality.
Transform your voice recordings and video notes into high-quality MP3s or FLACs without leaving Obsidian.
- **Video Extraction**: Extract high-quality audio from MP4, MOV, MKV, and AVI containers.
- **Bulk & Folder Conversion**: Convert entire folders or multiple selected files in one click via the context menu.
- **Meta-Sync+**: Automatically extracts ID3 tags (Title, Artist, Date, Genre) from YAML frontmatter, with fallbacks to note H1 headings and creation dates.
- **Cleanup**: Option to delete original files after successful conversion.
- **FFmpeg Powered**: Uses the industry-standard FFmpeg engine for crystal-clear quality.
### Dataview Table Export
Turn your dynamic Dataview queries into static, shareable CSV files with a single click.

84
dist/main.js vendored
View file

@ -1431,6 +1431,25 @@ 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) => {
const isRedundant = alt === filename;
return alt && !isRedundant ? `![[${targetPrefix}${filename}|${alt}]]` : `![[${targetPrefix}${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) => {
const decodedBundle = decodeURIComponent(folderName);
const bundleBase = decodedBundle.replace(/\.textbundle$/i, "");
const noteName = bundleBase.replace(/ \(\d+\)$/, "");
return text === noteName ? `[[${noteName}]]` : `[[${noteName}|${text}]]`;
});
return content;
}
// src/features/textbundle/packer.ts
var TextBundlePacker = class {
@ -1600,7 +1619,8 @@ var TextBundleImporter = class {
}
}
await this.ensureFolder(finalImportFolder);
const attachmentFolderPath = (0, import_obsidian4.normalizePath)(`${finalImportFolder}/media`);
const assetsFolderName = "assets";
const attachmentFolderPath = (0, import_obsidian4.normalizePath)(`${finalImportFolder}/${assetsFolderName}`);
await this.ensureFolder(attachmentFolderPath);
const assetsBase = "assets/";
const assetRenameMap = /* @__PURE__ */ new Map();
@ -1613,12 +1633,12 @@ var TextBundleImporter = class {
}
}
}
content = this.reverseTransformLinks(content);
content = reverseTransformLinks(content, assetsFolderName);
for (const [oldName, newName] of assetRenameMap.entries()) {
const escapedOldName = oldName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const linkRegex = new RegExp(`\\[\\[media/${escapedOldName}(\\|[^\\]]+)?\\]\\]`, "g");
const linkRegex = new RegExp(`\\[\\[${assetsFolderName}/${escapedOldName}(\\|[^\\]]+)?\\]\\]`, "g");
content = content.replace(linkRegex, (match, alias) => {
return `[[media/${newName}${alias || ""}]]`;
return `[[${assetsFolderName}/${newName}${alias || ""}]]`;
});
}
await this.createNoteWithSafety(finalImportFolder, noteName, content);
@ -1687,21 +1707,6 @@ var TextBundleImporter = class {
var _a2, _b2;
return ((_b2 = (_a2 = this.app.vault).getConfig) == null ? void 0 : _b2.call(_a2, "attachmentFolderPath")) || "";
}
reverseTransformLinks(content) {
content = content.replace(/!\[([^\]]*)\]\(assets\/([^\)]+)\)/g, (match, alt, filename) => {
return alt ? `![[media/${filename}|${alt}]]` : `![[media/${filename}]]`;
});
content = content.replace(/\[([^\]]+)\]\(assets\/([^\)]+)\)/g, (match, text, filename) => {
return text === filename ? `[[media/${filename}]]` : `[[media/${filename}|${text}]]`;
});
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+\)$/, "");
return text === noteName ? `[[${noteName}]]` : `[[${noteName}|${text}]]`;
});
return content;
}
};
// src/features/textbundle/TextBundleModule.ts
@ -2138,30 +2143,47 @@ var AudioModule = class {
this.context.settings = newSettings;
}
registerEvents() {
const audioExtensions = ["webm", "ogg", "wav", "mp3", "m4a", "flac", "aac", "opus", "mp4", "mov", "mkv", "avi"];
this.context.plugin.registerEvent(
this.context.app.workspace.on("file-menu", (menu, abstractFile) => {
if (!this.context.settings.enableAudioConverter) return;
const format = this.context.settings.defaultAudioOutputFormat || "mp3";
const format = (this.context.settings.defaultAudioOutputFormat || "mp3").toLowerCase();
let audioFiles = [];
if (abstractFile instanceof import_obsidian8.TFile) {
const ext = abstractFile.extension.toLowerCase();
const audioExtensions = ["webm", "ogg", "wav", "mp3", "m4a", "flac", "aac", "opus"];
if (audioExtensions.includes(ext) && ext !== format) {
menu.addItem((item) => {
item.setTitle(`Alchemist: Convert to ${format.toUpperCase()}`).setIcon("music").onClick(() => this.runConversion([abstractFile], format));
});
audioFiles.push(abstractFile);
}
} else if (abstractFile instanceof import_obsidian8.TFolder) {
const collect = (f) => {
if (f instanceof import_obsidian8.TFile) {
const ext = f.extension.toLowerCase();
if (audioExtensions.includes(ext) && ext !== format) {
audioFiles.push(f);
}
} else if (f instanceof import_obsidian8.TFolder) {
f.children.forEach((child) => collect(child));
}
};
collect(abstractFile);
}
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));
});
})
);
this.context.plugin.registerEvent(
this.context.app.workspace.on("files-menu", (menu, files) => {
if (!this.context.settings.enableAudioConverter) return;
const audioExtensions = ["webm", "ogg", "wav", "mp3", "m4a", "flac", "aac", "opus"];
const audioFiles = files.filter((f) => f instanceof import_obsidian8.TFile && audioExtensions.includes(f.extension.toLowerCase()) && f.extension.toLowerCase() !== format);
const format = (this.context.settings.defaultAudioOutputFormat || "mp3").toLowerCase();
const audioFiles = files.filter(
(f) => f instanceof import_obsidian8.TFile && audioExtensions.includes(f.extension.toLowerCase()) && f.extension.toLowerCase() !== format
);
if (audioFiles.length <= 1) return;
const format = this.context.settings.defaultAudioOutputFormat || "mp3";
menu.addItem((item) => {
item.setTitle(`Alchemist: Convert ${audioFiles.length} files to ${format.toUpperCase()}`).setIcon("music").onClick(() => this.runConversion(audioFiles, format));
item.setTitle(`Alchemist: Convert selected (${audioFiles.length} files) to ${format.toUpperCase()}`).setIcon("music").onClick(() => this.runConversion(audioFiles, format));
});
})
);
@ -2212,11 +2234,17 @@ var AudioModule = class {
if (frontmatter.album) metadata.album = frontmatter.album;
if (frontmatter.genre) metadata.genre = 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 (!(frontmatter == null ? void 0 : frontmatter.title)) {
const content = await this.context.app.vault.read(mdFile);
const h1Match = content.match(/^#\s+(.+)$/m);
if (h1Match) metadata.title = h1Match[1].trim();
}
}
return metadata;
}

View file

@ -1,7 +1,7 @@
{
"id": "obsidian-alchemist",
"name": "Alchemist",
"version": "1.0.0",
"version": "1.0.1",
"minAppVersion": "0.15.0",
"description": "Modular super-plugin for TextBundle, Audio conversion, and Dataview exports.",
"author": "Kharizma & Latreia",

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-alchemist",
"version": "1.0.0",
"version": "1.0.1",
"description": "The Alchemist: A modular super-plugin for TextBundle, Audio conversion, and Data purity.",
"main": "main.js",
"scripts": {

View file

@ -1,4 +1,4 @@
import { TFile, Notice, normalizePath, FileSystemAdapter } from 'obsidian';
import { TFile, TFolder, Notice, normalizePath, FileSystemAdapter } from 'obsidian';
import { spawn } from 'child_process';
import { IAlchemistModule, AlchemistContext } from '../../core/IAlchemistModule';
import { AlchemistSettings } from '../../../settings';
@ -25,40 +25,67 @@ export class AudioModule implements IAlchemistModule {
}
private registerEvents() {
// Single file menu
const audioExtensions = ['webm', 'ogg', 'wav', 'mp3', 'm4a', 'flac', 'aac', 'opus', 'mp4', 'mov', 'mkv', 'avi'];
// Handler for single file or folder
this.context.plugin.registerEvent(
this.context.app.workspace.on('file-menu', (menu: any, abstractFile: any) => {
if (!this.context.settings.enableAudioConverter) return;
const format = this.context.settings.defaultAudioOutputFormat || 'mp3';
const format = (this.context.settings.defaultAudioOutputFormat || 'mp3').toLowerCase();
let audioFiles: TFile[] = [];
if (abstractFile instanceof TFile) {
const ext = abstractFile.extension.toLowerCase();
const audioExtensions = ['webm', 'ogg', 'wav', 'mp3', 'm4a', 'flac', 'aac', 'opus'];
if (audioExtensions.includes(ext) && ext !== format) {
menu.addItem((item: any) => {
item
.setTitle(`Alchemist: Convert to ${format.toUpperCase()}`)
.setIcon('music')
.onClick(() => this.runConversion([abstractFile], format));
});
audioFiles.push(abstractFile);
}
} else if (abstractFile instanceof TFolder) {
const collect = (f: any) => {
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));
}
};
collect(abstractFile);
}
if (audioFiles.length === 0) return;
menu.addItem((item: any) => {
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));
});
})
);
// Multiple files menu
// Handler for multiple selection
this.context.plugin.registerEvent(
this.context.app.workspace.on('files-menu', (menu: any, files: any[]) => {
if (!this.context.settings.enableAudioConverter) return;
const audioExtensions = ['webm', 'ogg', 'wav', 'mp3', 'm4a', 'flac', 'aac', 'opus'];
const audioFiles = files.filter(f => f instanceof TFile && audioExtensions.includes(f.extension.toLowerCase()) && f.extension.toLowerCase() !== format);
if (audioFiles.length <= 1) return;
const format = (this.context.settings.defaultAudioOutputFormat || 'mp3').toLowerCase();
const audioFiles = files.filter(f =>
f instanceof TFile &&
audioExtensions.includes(f.extension.toLowerCase()) &&
f.extension.toLowerCase() !== format
) as TFile[];
if (audioFiles.length <= 1) return; // Single file handled by file-menu
const format = this.context.settings.defaultAudioOutputFormat || 'mp3';
menu.addItem((item: any) => {
item
.setTitle(`Alchemist: Convert ${audioFiles.length} files to ${format.toUpperCase()}`)
.setTitle(`Alchemist: Convert selected (${audioFiles.length} files) to ${format.toUpperCase()}`)
.setIcon('music')
.onClick(() => this.runConversion(audioFiles, format));
});
@ -66,6 +93,8 @@ export class AudioModule implements IAlchemistModule {
);
}
private async runConversion(files: TFile[], format: string) {
if (files.length === 0) return;
@ -122,7 +151,10 @@ export class AudioModule implements IAlchemistModule {
if (frontmatter.artist) metadata.artist = frontmatter.artist;
if (frontmatter.album) metadata.album = frontmatter.album;
if (frontmatter.genre) metadata.genre = frontmatter.genre;
// Date logic: date > creation_date > now
if (frontmatter.date) metadata.date = String(frontmatter.date);
else if (frontmatter.creation_date) metadata.date = String(frontmatter.creation_date);
// Fallback for tags -> genre
if (!frontmatter.genre && frontmatter.tags) {
@ -130,8 +162,16 @@ export class AudioModule implements IAlchemistModule {
if (tags.length > 0) metadata.genre = tags[0];
}
}
// H1 Fallback for title
if (!frontmatter?.title) {
const content = await this.context.app.vault.read(mdFile);
const h1Match = content.match(/^#\s+(.+)$/m);
if (h1Match) metadata.title = h1Match[1].trim();
}
}
return metadata;
}

View file

@ -1,14 +1,18 @@
import { transformMarkdownLinks, LinkResolver } from '../logic';
// Note: reverseTransformLinks will be moved to logic.ts soon, but for now we test transform
describe('TextBundle Logic: transformMarkdownLinks', () => {
const mockResolver: LinkResolver = {
resolveAsset: (link: string) => {
if (link === 'image.png') return 'image.png';
if (link === 'Обучение.md') return null; // .md is NOT an asset
if (link === 'photo') return 'real_photo.jpg';
if (link === 'документ.pdf') return 'документ.pdf';
return null;
},
resolveNote: (link: string) => {
if (link === 'Other Note') return '../Other Note.textbundle/text.md';
if (link === 'Обучение') return '../Обучение.textbundle/text.md';
return null;
}
};
@ -19,26 +23,28 @@ describe('TextBundle Logic: transformMarkdownLinks', () => {
expect(transformMarkdownLinks(input, mockResolver)).toBe(expected);
});
it('should transform embeds with aliases', () => {
const input = 'Check this out: ![[photo|My Cool Photo]]';
const expected = 'Check this out: ![My Cool Photo](assets/real_photo.jpg)';
it('should handle Cyrillic in assets', () => {
const input = 'См. ![[документ.pdf]]';
const expected = 'См. ![документ.pdf](assets/документ.pdf)';
expect(transformMarkdownLinks(input, mockResolver)).toBe(expected);
});
it('should transform regular links to assets', () => {
const input = 'Download [[image.png]]';
const expected = 'Download [image.png](assets/image.png)';
it('should transform links to other notes (even with Cyrillic)', () => {
const input = 'Перейти к [[Обучение]]';
const expected = 'Перейти к [Обучение](../Обучение.textbundle/text.md)';
expect(transformMarkdownLinks(input, mockResolver)).toBe(expected);
});
it('should transform links to other notes in TextPack', () => {
const input = 'See [[Other Note]]';
const expected = 'See [Other Note](../Other Note.textbundle/text.md)';
it('should handle aliases correctly', () => {
const input = 'Check [[photo|This cool pic]]';
const expected = 'Check [This cool pic](assets/real_photo.jpg)';
expect(transformMarkdownLinks(input, mockResolver)).toBe(expected);
});
it('should not transform unknown links', () => {
const input = 'See [[Unknown Note]] and ![[unknown.png]]';
it('should not transform .md files as assets (bug check)', () => {
const input = 'Lesson [[Обучение.md]]';
// It should NOT be [Обучение.md](assets/Обучение.md) if it's not resolved as note or asset
expect(transformMarkdownLinks(input, mockResolver)).toBe(input);
});
});

View file

@ -0,0 +1,61 @@
import { transformMarkdownLinks, reverseTransformLinks, LinkResolver } from '../logic';
describe('TextBundle: Roundtrip Link Symmetry', () => {
const assetsFolder = 'assets';
// 1. Export Mock Resolver
const exportResolver: LinkResolver = {
resolveAsset: (link: string) => {
if (link === 'Обучение.png') return 'Обучение.png';
if (link === 'images/photo.jpg') return 'photo.jpg';
return null;
},
resolveNote: (link: string) => {
if (link === 'Target Note') return '../Target Note.textbundle/text.md';
return null;
}
};
it('should maintain symmetry for internal assets with Cyrillic', () => {
const original = 'Check this: ![[Обучение.png]]';
// Step 1: Export
const exported = transformMarkdownLinks(original, exportResolver);
expect(exported).toBe('Check this: ![Обучение.png](assets/Обучение.png)');
// Step 2: Import
const imported = reverseTransformLinks(exported, assetsFolder);
expect(imported).toBe('Check this: ![[assets/Обучение.png]]');
});
it('should maintain symmetry for assets with paths', () => {
const original = 'Photo: ![[images/photo.jpg|My Photo]]';
// Step 1: Export
const exported = transformMarkdownLinks(original, exportResolver);
expect(exported).toBe('Photo: ![My Photo](assets/photo.jpg)');
// Step 2: Import
const imported = reverseTransformLinks(exported, assetsFolder);
expect(imported).toBe('Photo: ![[assets/photo.jpg|My Photo]]');
});
it('should maintain symmetry for cross-note links', () => {
const original = 'Read [[Target Note|click here]]';
// Step 1: Export
const exported = transformMarkdownLinks(original, exportResolver);
expect(exported).toBe('Read [click here](../Target Note.textbundle/text.md)');
// Step 2: Import
const imported = reverseTransformLinks(exported, assetsFolder);
expect(imported).toBe('Read [[Target Note|click here]]');
});
it('should NOT break existing markdown links that are not assets', () => {
const original = '[Google](https://google.com)';
const exported = transformMarkdownLinks(original, exportResolver);
const imported = reverseTransformLinks(exported, assetsFolder);
expect(imported).toBe(original);
});
});

View file

@ -2,6 +2,7 @@ import { App, TFile, TFolder, normalizePath, Notice } from 'obsidian';
import * as fflate from 'fflate';
import { AlchemistSettings } from '../../../settings';
import { ISystemAdapter } from '../../core/IAlchemistModule';
import { reverseTransformLinks } from './logic';
export class TextBundleImporter {
app: App;
@ -93,8 +94,9 @@ export class TextBundleImporter {
}
await this.ensureFolder(finalImportFolder);
// Put assets in a central "media" folder within the import root (or relative root)
const attachmentFolderPath = normalizePath(`${finalImportFolder}/media`);
// Put assets in a central "assets" folder within the import root (or relative root)
const assetsFolderName = 'assets';
const attachmentFolderPath = normalizePath(`${finalImportFolder}/${assetsFolderName}`);
await this.ensureFolder(attachmentFolderPath);
const assetsBase = 'assets/';
@ -113,13 +115,13 @@ export class TextBundleImporter {
}
// 3. Link Restoration
content = this.reverseTransformLinks(content);
content = reverseTransformLinks(content, assetsFolderName);
for (const [oldName, newName] of assetRenameMap.entries()) {
const escapedOldName = oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const linkRegex = new RegExp(`\\[\\[media/${escapedOldName}(\\|[^\\]]+)?\\]\\]`, 'g');
const linkRegex = new RegExp(`\\[\\[${assetsFolderName}/${escapedOldName}(\\|[^\\]]+)?\\]\\]`, 'g');
content = content.replace(linkRegex, (match, alias) => {
return `[[media/${newName}${alias || ''}]]`;
return `[[${assetsFolderName}/${newName}${alias || ''}]]`;
});
}
@ -206,28 +208,5 @@ export class TextBundleImporter {
private getAttachmentFolder(): string {
return (this.app.vault as any).getConfig?.('attachmentFolderPath') || '';
}
private reverseTransformLinks(content: string): string {
// 1. Embeds: ![alt](assets/filename) -> ![[media/filename|alt]]
content = content.replace(/!\[([^\]]*)\]\(assets\/([^\)]+)\)/g, (match, alt, filename) => {
return alt ? `![[media/${filename}|${alt}]]` : `![[media/${filename}]]`;
});
// 2. Links: [text](assets/filename) -> [[media/filename|text]]
content = content.replace(/\[([^\]]+)\]\(assets\/([^\)]+)\)/g, (match, text, filename) => {
return text === filename ? `[[media/${filename}]]` : `[[media/${filename}|${text}]]`;
});
// 3. Cross-Bundle Links: [Label](../BundleName.textbundle/text.md) -> [[NoteName|Label]]
content = content.replace(/\[([^\]]+)\]\(\.\.\/([^/]+)\/text\.(md|markdown|txt)\)/g, (match, text, folderName) => {
const decodedBundle = decodeURIComponent(folderName);
const bundleBase = decodedBundle.replace(/\.textbundle$/i, '');
// Handle collision suffixes like "Note (1)"
const noteName = bundleBase.replace(/ \(\d+\)$/, '');
return text === noteName ? `[[${noteName}]]` : `[[${noteName}|${text}]]`;
});
return content;
}
}

View file

@ -51,3 +51,42 @@ export function transformMarkdownLinks(
return content;
}
/**
* Transforms Markdown-style links [link](assets/link) back to Obsidian-style [[link]]
* @param content Markdown content
* @param assetsFolder The folder where assets were imported into (e.g. 'media')
*/
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) => {
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) => {
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) => {
const decodedBundle = decodeURIComponent(folderName);
const bundleBase = decodedBundle.replace(/\.textbundle$/i, '');
// Handle collision suffixes like "Note (1)"
const noteName = bundleBase.replace(/ \(\d+\)$/, '');
return text === noteName ? `[[${noteName}]]` : `[[${noteName}|${text}]]`;
});
return content;
}

16
version-bump.mjs Normal file
View file

@ -0,0 +1,16 @@
import { readFileSync, writeFileSync } from 'fs';
const manifest = JSON.parse(readFileSync('manifest.json', 'utf8'));
const { version, minAppVersion } = manifest;
// 1. Update package.json
const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
pkg.version = version;
writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
// 2. Update versions.json
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
versions[version] = minAppVersion;
writeFileSync('versions.json', JSON.stringify(versions, null, 2) + '\n');
console.log(`Version bumped to ${version} in all configuration files.`);

View file

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