mirror of
https://github.com/ksawl/obsidian-alchemist.git
synced 2026-07-22 06:49:47 +00:00
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:
parent
7508bc7954
commit
f8530066df
12 changed files with 297 additions and 96 deletions
28
CHANGELOG.md
Normal file
28
CHANGELOG.md
Normal 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.
|
||||
17
README.md
17
README.md
|
|
@ -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
84
dist/main.js
vendored
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: ';
|
||||
it('should handle Cyrillic in assets', () => {
|
||||
const input = 'См. ![[документ.pdf]]';
|
||||
const expected = 'См. ';
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
61
src/features/textbundle/__tests__/roundtrip.test.ts
Normal file
61
src/features/textbundle/__tests__/roundtrip.test.ts
Normal 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: ');
|
||||
|
||||
// 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: ');
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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:  -> ![[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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:  -> ![[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
16
version-bump.mjs
Normal 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.`);
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
{
|
||||
"1.0.0": "0.15.0"
|
||||
"1.0.0": "0.15.0",
|
||||
"1.0.1": "0.15.0"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue