mirror of
https://github.com/davidvkimball/obsidian-astro-composer.git
synced 2026-07-22 06:44:40 +00:00
Refactor: reorganize codebase into modular src/ structure with strict TypeScript config.
This commit is contained in:
parent
ef7269dce1
commit
eabfb3dc2a
16 changed files with 1317 additions and 1159 deletions
|
|
@ -15,7 +15,7 @@ const context = await esbuild.context({
|
|||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["main.ts"],
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "astro-composer",
|
||||
"name": "Astro Composer",
|
||||
"version": "0.5.3",
|
||||
"version": "0.5.5",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Create and manage notes as Astro blog posts with automatic file renaming, frontmatter insertion, and internal link conversion.",
|
||||
"author": "David V. Kimball",
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "astro-composer",
|
||||
"version": "0.5.3",
|
||||
"version": "0.5.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "astro-composer",
|
||||
"version": "0.5.3",
|
||||
"version": "0.5.5",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.6",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "astro-composer",
|
||||
"version": "0.5.3",
|
||||
"version": "0.5.5",
|
||||
"description": "Create and manage notes as Astro blog posts with automatic file renaming, frontmatter insertion, and internal link conversion.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
92
src/commands/index.ts
Normal file
92
src/commands/index.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { Plugin, Editor, MarkdownView, TFile, Notice } from "obsidian";
|
||||
import { AstroComposerSettings } from "../types";
|
||||
import { FileOperations } from "../utils/file-operations";
|
||||
import { TemplateParser } from "../utils/template-parsing";
|
||||
import { LinkConverter } from "../utils/link-conversion";
|
||||
import { TitleModal } from "../ui/title-modal";
|
||||
|
||||
export function registerCommands(plugin: Plugin, settings: AstroComposerSettings): void {
|
||||
const fileOps = new FileOperations(plugin.app, settings);
|
||||
const templateParser = new TemplateParser(plugin.app, settings);
|
||||
const linkConverter = new LinkConverter(settings);
|
||||
|
||||
// Standardize Properties command
|
||||
plugin.addCommand({
|
||||
id: "standardize-properties",
|
||||
name: "Standardize Properties",
|
||||
icon: "file-check",
|
||||
editorCallback: (editor: Editor, ctx: MarkdownView | any) => {
|
||||
if (ctx.file instanceof TFile) {
|
||||
standardizeProperties(plugin.app, settings, ctx.file);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Convert Wikilinks command
|
||||
plugin.addCommand({
|
||||
id: "convert-wikilinks-astro",
|
||||
name: "Convert internal links for Astro",
|
||||
icon: "link-2",
|
||||
editorCallback: (editor: Editor, ctx: MarkdownView | any) => {
|
||||
if (ctx.file instanceof TFile) {
|
||||
linkConverter.convertWikilinksForAstro(editor, ctx.file);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Rename Note command
|
||||
plugin.addCommand({
|
||||
id: "rename-note",
|
||||
name: "Rename Current Note",
|
||||
icon: "pencil",
|
||||
editorCallback: (editor: Editor, ctx: MarkdownView | any) => {
|
||||
if (ctx.file instanceof TFile) {
|
||||
const type = fileOps.determineType(ctx.file);
|
||||
const titleKey = fileOps.getTitleKey(type);
|
||||
const cache = plugin.app.metadataCache.getFileCache(ctx.file);
|
||||
if (!cache?.frontmatter || !(titleKey in cache.frontmatter)) {
|
||||
new Notice("Cannot rename: No title found in properties");
|
||||
return;
|
||||
}
|
||||
new TitleModal(plugin.app, ctx.file, plugin, type, true).open();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function standardizeProperties(app: any, settings: AstroComposerSettings, file: TFile): Promise<void> {
|
||||
const templateParser = new TemplateParser(app, settings);
|
||||
|
||||
// Determine if it's a page or post
|
||||
const filePath = file.path;
|
||||
const pagesFolder = settings.pagesFolder || "";
|
||||
const isPage = settings.enablePages && pagesFolder && (filePath.startsWith(pagesFolder + "/") || filePath === pagesFolder);
|
||||
const templateString = isPage ? settings.pageTemplate : settings.defaultTemplate;
|
||||
|
||||
// Wait briefly to allow editor state to stabilize
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Re-read content to ensure latest state after editor changes
|
||||
const content = await app.vault.read(file);
|
||||
const title = file.basename.replace(/^_/, "");
|
||||
|
||||
const parsed = await templateParser.parseFrontmatter(content);
|
||||
const { templateProps, templateValues } = templateParser.parseTemplate(templateString, title);
|
||||
|
||||
// Merge template properties with existing ones, preserving all existing
|
||||
const finalProps: Record<string, string[]> = { ...parsed.properties };
|
||||
for (const key of templateProps) {
|
||||
if (!(key in parsed.properties)) {
|
||||
finalProps[key] = templateValues[key] || (['tags', 'aliases', 'cssclasses'].includes(key) ? [] : [""]);
|
||||
} else if (['tags', 'aliases', 'cssclasses'].includes(key) && templateValues[key]?.length > 0) {
|
||||
// Merge items, ensuring no duplicates
|
||||
const allItems = [...(parsed.properties[key] || []), ...templateValues[key].filter(item => !(parsed.properties[key] || []).includes(item))];
|
||||
finalProps[key] = allItems;
|
||||
}
|
||||
}
|
||||
|
||||
const newContent = templateParser.buildFrontmatterContent(finalProps) + parsed.bodyContent;
|
||||
|
||||
await app.vault.modify(file, newContent);
|
||||
new Notice("Properties standardized using template.");
|
||||
}
|
||||
122
src/main.ts
Normal file
122
src/main.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import {
|
||||
App,
|
||||
Plugin,
|
||||
TFile,
|
||||
} from "obsidian";
|
||||
|
||||
import { AstroComposerSettings, DEFAULT_SETTINGS, CONSTANTS } from "./settings";
|
||||
import { registerCommands } from "./commands";
|
||||
import { AstroComposerSettingTab } from "./ui/settings-tab";
|
||||
import { TitleModal } from "./ui/title-modal";
|
||||
import { FileOperations } from "./utils/file-operations";
|
||||
import { TemplateParser } from "./utils/template-parsing";
|
||||
|
||||
export default class AstroComposerPlugin extends Plugin {
|
||||
settings!: AstroComposerSettings;
|
||||
private createEvent!: (file: TFile) => void;
|
||||
private fileOps!: FileOperations;
|
||||
private templateParser!: TemplateParser;
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
|
||||
// Initialize utilities
|
||||
this.fileOps = new FileOperations(this.app, this.settings);
|
||||
this.templateParser = new TemplateParser(this.app, this.settings);
|
||||
|
||||
// Wait for the vault to be fully loaded before registering the create event
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
this.registerCreateEvent();
|
||||
});
|
||||
|
||||
// Register commands
|
||||
registerCommands(this, this.settings);
|
||||
|
||||
// Add settings tab
|
||||
this.addSettingTab(new AstroComposerSettingTab(this.app, this));
|
||||
}
|
||||
|
||||
public registerCreateEvent() {
|
||||
if (this.createEvent) {
|
||||
this.app.vault.off("create", this.createEvent as any);
|
||||
}
|
||||
|
||||
if (this.settings.automatePostCreation || this.settings.enablePages) {
|
||||
// Debounce to prevent multiple modals from rapid file creations
|
||||
let lastProcessedTime = 0;
|
||||
|
||||
this.createEvent = async (file: TFile) => {
|
||||
const now = Date.now();
|
||||
if (now - lastProcessedTime < CONSTANTS.DEBOUNCE_MS) {
|
||||
return; // Skip if within debounce period
|
||||
}
|
||||
lastProcessedTime = now;
|
||||
|
||||
if (file instanceof TFile && file.extension === "md") {
|
||||
const filePath = file.path;
|
||||
|
||||
// Check if file is newly created by user (recent creation time and empty content)
|
||||
const stat = await this.app.vault.adapter.stat(file.path);
|
||||
const isNewNote = stat?.mtime && (now - stat.mtime < CONSTANTS.STAT_MTIME_THRESHOLD);
|
||||
const content = await this.app.vault.read(file);
|
||||
const isEmpty = content.trim() === "";
|
||||
|
||||
if (!isNewNote || !isEmpty) {
|
||||
return; // Skip if not a user-initiated new note
|
||||
}
|
||||
|
||||
// Check folder restrictions
|
||||
const postsFolder = this.settings.postsFolder || "";
|
||||
const pagesFolder = this.settings.enablePages ? (this.settings.pagesFolder || "") : "";
|
||||
let isPage = false;
|
||||
|
||||
if (pagesFolder && (filePath.startsWith(pagesFolder + "/") || filePath === pagesFolder)) {
|
||||
isPage = true;
|
||||
}
|
||||
|
||||
const cache = this.app.metadataCache.getFileCache(file);
|
||||
if (!cache || !cache.sections || cache.sections.length === 0) {
|
||||
if (isPage) {
|
||||
if (this.settings.enablePages) {
|
||||
new TitleModal(this.app, file, this, "page").open();
|
||||
}
|
||||
} else {
|
||||
if (this.settings.onlyAutomateInPostsFolder) {
|
||||
if (
|
||||
!postsFolder ||
|
||||
(filePath.startsWith(postsFolder + "/") || filePath === postsFolder)
|
||||
) {
|
||||
new TitleModal(this.app, file, this, "post").open();
|
||||
}
|
||||
} else {
|
||||
let excludedDirs = this.settings.excludedDirectories
|
||||
.split("|")
|
||||
.map((dir: string) => dir.trim())
|
||||
.filter((dir: string) => dir.length > 0);
|
||||
if (pagesFolder) {
|
||||
excludedDirs.push(pagesFolder);
|
||||
}
|
||||
const isExcluded = excludedDirs.some((dir: string) =>
|
||||
filePath.startsWith(dir + "/") || filePath === dir
|
||||
);
|
||||
|
||||
if (!isExcluded) {
|
||||
new TitleModal(this.app, file, this, "post").open();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
this.registerEvent(this.app.vault.on("create", this.createEvent as any));
|
||||
}
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
}
|
||||
23
src/settings.ts
Normal file
23
src/settings.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { AstroComposerSettings, CONSTANTS } from "./types";
|
||||
|
||||
export type { AstroComposerSettings } from "./types";
|
||||
export { CONSTANTS } from "./types";
|
||||
|
||||
export const DEFAULT_SETTINGS: AstroComposerSettings = {
|
||||
enableUnderscorePrefix: false,
|
||||
defaultTemplate:
|
||||
'---\ntitle: "{{title}}"\ndate: {{date}}\ntags: []\n---\n',
|
||||
linkBasePath: "/blog/",
|
||||
postsFolder: "posts",
|
||||
automatePostCreation: true,
|
||||
autoInsertProperties: true,
|
||||
creationMode: "file",
|
||||
indexFileName: "index",
|
||||
dateFormat: "YYYY-MM-DD",
|
||||
excludedDirectories: "",
|
||||
onlyAutomateInPostsFolder: false,
|
||||
enablePages: false,
|
||||
pagesFolder: "pages",
|
||||
pageTemplate:
|
||||
'---\ntitle: "{{title}}"\ndescription: ""\n---\n',
|
||||
};
|
||||
52
src/types.ts
Normal file
52
src/types.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { TFile } from "obsidian";
|
||||
|
||||
export interface AstroComposerSettings {
|
||||
enableUnderscorePrefix: boolean;
|
||||
defaultTemplate: string;
|
||||
linkBasePath: string;
|
||||
postsFolder: string;
|
||||
automatePostCreation: boolean;
|
||||
autoInsertProperties: boolean;
|
||||
creationMode: "file" | "folder";
|
||||
indexFileName: string;
|
||||
dateFormat: string;
|
||||
excludedDirectories: string;
|
||||
onlyAutomateInPostsFolder: boolean;
|
||||
enablePages: boolean;
|
||||
pagesFolder: string;
|
||||
pageTemplate: string;
|
||||
}
|
||||
|
||||
export interface ParsedFrontmatter {
|
||||
properties: Record<string, string[]>;
|
||||
propertiesText: string;
|
||||
propertiesEnd: number;
|
||||
bodyContent: string;
|
||||
}
|
||||
|
||||
export interface TemplateValues {
|
||||
[key: string]: string[];
|
||||
}
|
||||
|
||||
export type PostType = "post" | "page";
|
||||
|
||||
export interface FileCreationOptions {
|
||||
file: TFile;
|
||||
title: string;
|
||||
type: PostType;
|
||||
}
|
||||
|
||||
export interface RenameOptions {
|
||||
file: TFile;
|
||||
title: string;
|
||||
type: PostType;
|
||||
}
|
||||
|
||||
export const KNOWN_ARRAY_KEYS = ['tags', 'aliases', 'cssclasses'] as const;
|
||||
|
||||
export const CONSTANTS = {
|
||||
DEBOUNCE_MS: 500,
|
||||
STAT_MTIME_THRESHOLD: 1000,
|
||||
EDITOR_STABILIZE_DELAY: 100,
|
||||
FILE_EXPLORER_REVEAL_DELAY: 200,
|
||||
} as const;
|
||||
288
src/ui/settings-tab.ts
Normal file
288
src/ui/settings-tab.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
import { App, PluginSettingTab, Setting } from "obsidian";
|
||||
import { Plugin } from "obsidian";
|
||||
import { AstroComposerSettings } from "../types";
|
||||
|
||||
export class AstroComposerSettingTab extends PluginSettingTab {
|
||||
plugin: Plugin;
|
||||
autoRenameContainer: HTMLElement | null = null;
|
||||
postsFolderContainer: HTMLElement | null = null;
|
||||
onlyAutomateContainer: HTMLElement | null = null;
|
||||
creationModeContainer: HTMLElement | null = null;
|
||||
indexFileContainer: HTMLElement | null = null;
|
||||
excludedDirsContainer: HTMLElement | null = null;
|
||||
underscorePrefixContainer: HTMLElement | null = null;
|
||||
autoInsertContainer: HTMLElement | null = null;
|
||||
pagesFieldsContainer: HTMLElement | null = null;
|
||||
|
||||
constructor(app: App, plugin: Plugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
const settings = (this.plugin as any).settings as AstroComposerSettings;
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Automate post creation")
|
||||
.setDesc("Automatically show title dialog for new .md files, rename them based on the title, and insert properties if enabled.")
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(settings.automatePostCreation)
|
||||
.onChange(async (value: boolean) => {
|
||||
settings.automatePostCreation = value;
|
||||
settings.autoInsertProperties = value;
|
||||
await (this.plugin as any).saveSettings();
|
||||
(this.plugin as any).registerCreateEvent();
|
||||
this.updateConditionalFields();
|
||||
})
|
||||
);
|
||||
|
||||
this.autoRenameContainer = containerEl.createDiv({ cls: "auto-rename-fields" });
|
||||
this.autoRenameContainer.style.display = settings.automatePostCreation ? "block" : "none";
|
||||
|
||||
this.autoInsertContainer = this.autoRenameContainer.createDiv();
|
||||
new Setting(this.autoInsertContainer)
|
||||
.setName("Auto-insert properties")
|
||||
.setDesc("Automatically insert the properties template when creating new files (requires 'Automate post creation' to be enabled).")
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(settings.autoInsertProperties)
|
||||
.setDisabled(!settings.automatePostCreation)
|
||||
.onChange(async (value: boolean) => {
|
||||
settings.autoInsertProperties = value;
|
||||
await (this.plugin as any).saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
this.postsFolderContainer = this.autoRenameContainer.createDiv();
|
||||
new Setting(this.postsFolderContainer)
|
||||
.setName("Posts folder")
|
||||
.setDesc("Folder name for blog posts (leave blank to use the vault folder). You can specify the default location for new notes in Obsidian's 'Files and links' settings.")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("Enter folder path")
|
||||
.setValue(settings.postsFolder)
|
||||
.onChange(async (value: string) => {
|
||||
settings.postsFolder = value;
|
||||
await (this.plugin as any).saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
this.onlyAutomateContainer = this.autoRenameContainer.createDiv();
|
||||
new Setting(this.onlyAutomateContainer)
|
||||
.setName("Only automate in this folder")
|
||||
.setDesc("When enabled, automation will only trigger for new .md files within the specified Posts folder and subfolders.")
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(settings.onlyAutomateInPostsFolder)
|
||||
.onChange(async (value: boolean) => {
|
||||
settings.onlyAutomateInPostsFolder = value;
|
||||
await (this.plugin as any).saveSettings();
|
||||
this.updateExcludedDirsField();
|
||||
})
|
||||
);
|
||||
|
||||
this.excludedDirsContainer = this.autoRenameContainer.createDiv({ cls: "excluded-dirs-field" });
|
||||
this.excludedDirsContainer.style.display = !settings.onlyAutomateInPostsFolder ? "block" : "none";
|
||||
|
||||
new Setting(this.excludedDirsContainer)
|
||||
.setName("Excluded directories")
|
||||
.setDesc("Directories to exclude from automatic post creation (e.g., pages|posts/example). Excluded directories and their child folders will be ignored. Use '|' to separate multiple directories.")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("pages|posts/example")
|
||||
.setValue(settings.excludedDirectories)
|
||||
.onChange(async (value: string) => {
|
||||
settings.excludedDirectories = value;
|
||||
await (this.plugin as any).saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
this.creationModeContainer = this.autoRenameContainer.createDiv();
|
||||
new Setting(this.creationModeContainer)
|
||||
.setName("Creation mode")
|
||||
.setDesc("How to create new posts: file-based or folder-based with index.md.")
|
||||
.addDropdown((dropdown) =>
|
||||
dropdown
|
||||
.addOption("file", "File-based (post-title.md)")
|
||||
.addOption("folder", "Folder-based (post-title/index.md)")
|
||||
.setValue(settings.creationMode)
|
||||
.onChange(async (value: string) => {
|
||||
settings.creationMode = value as "file" | "folder";
|
||||
await (this.plugin as any).saveSettings();
|
||||
this.updateIndexFileField();
|
||||
})
|
||||
);
|
||||
|
||||
this.indexFileContainer = this.autoRenameContainer.createDiv({ cls: "index-file-field" });
|
||||
this.indexFileContainer.style.display = settings.creationMode === "folder" ? "block" : "none";
|
||||
|
||||
new Setting(this.indexFileContainer)
|
||||
.setName("Index file name")
|
||||
.setDesc("Name for the main file in folder-based mode (without .md extension).")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("index")
|
||||
.setValue(settings.indexFileName)
|
||||
.onChange(async (value: string) => {
|
||||
settings.indexFileName = value || "index";
|
||||
await (this.plugin as any).saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
this.underscorePrefixContainer = this.autoRenameContainer.createDiv();
|
||||
new Setting(this.underscorePrefixContainer)
|
||||
.setName("Use underscore prefix for drafts")
|
||||
.setDesc("Add an underscore prefix (_post-title) to new notes by default when enabled. This hides them from Astro, which can be helpful for post drafts. Disable to skip prefixing.")
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(settings.enableUnderscorePrefix)
|
||||
.onChange(async (value: boolean) => {
|
||||
settings.enableUnderscorePrefix = value;
|
||||
await (this.plugin as any).saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Link base path")
|
||||
.setDesc("Base path for converted links (e.g., /blog/, leave blank for root domain).")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("/blog/")
|
||||
.setValue(settings.linkBasePath)
|
||||
.onChange(async (value: string) => {
|
||||
settings.linkBasePath = value;
|
||||
await (this.plugin as any).saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Date format")
|
||||
.setDesc("Format for the date in properties (e.g., YYYY-MM-DD, MMMM D, YYYY, YYYY-MM-DD HH:mm).")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("YYYY-MM-DD")
|
||||
.setValue(settings.dateFormat)
|
||||
.onChange(async (value: string) => {
|
||||
settings.dateFormat = value || "YYYY-MM-DD";
|
||||
await (this.plugin as any).saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Properties template")
|
||||
.addTextArea((text) => {
|
||||
text
|
||||
.setPlaceholder(
|
||||
'---\ntitle: "{{title}}"\ndate: {{date}}\ntags: []\n---\n',
|
||||
)
|
||||
.setValue(settings.defaultTemplate)
|
||||
.onChange(async (value: string) => {
|
||||
settings.defaultTemplate = value;
|
||||
await (this.plugin as any).saveSettings();
|
||||
});
|
||||
text.inputEl.classList.add("astro-composer-template-textarea");
|
||||
return text;
|
||||
})
|
||||
.then((setting) => {
|
||||
setting.descEl.empty();
|
||||
const descDiv = setting.descEl.createEl("div");
|
||||
descDiv.innerHTML =
|
||||
"Used for new posts and when standardizing properties.<br />" +
|
||||
"Variables include {{title}} and {{date}}.<br />" +
|
||||
"Do not wrap {{date}} in quotes as it represents a datetime value, not a string.<br />" +
|
||||
"The 'standardize properties' command ignores anything below the second '---' line.";
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Automate page creation")
|
||||
.setDesc("Enable automatic page creation in a specified folder.")
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(settings.enablePages)
|
||||
.onChange(async (value: boolean) => {
|
||||
settings.enablePages = value;
|
||||
await (this.plugin as any).saveSettings();
|
||||
(this.plugin as any).registerCreateEvent();
|
||||
this.updatePagesFields();
|
||||
})
|
||||
);
|
||||
|
||||
this.pagesFieldsContainer = containerEl.createDiv();
|
||||
this.pagesFieldsContainer.style.display = settings.enablePages ? "block" : "none";
|
||||
|
||||
new Setting(this.pagesFieldsContainer)
|
||||
.setName("Pages folder")
|
||||
.setDesc("Folder for pages (leave blank to disable). Posts automation will exclude this folder.")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("Enter folder path")
|
||||
.setValue(settings.pagesFolder)
|
||||
.onChange(async (value: string) => {
|
||||
settings.pagesFolder = value;
|
||||
await (this.plugin as any).saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(this.pagesFieldsContainer)
|
||||
.setName("Page properties template")
|
||||
.addTextArea((text) => {
|
||||
text
|
||||
.setPlaceholder(
|
||||
'---\ntitle: "{{title}}"\ndescription: ""\n---\n',
|
||||
)
|
||||
.setValue(settings.pageTemplate)
|
||||
.onChange(async (value: string) => {
|
||||
settings.pageTemplate = value;
|
||||
await (this.plugin as any).saveSettings();
|
||||
});
|
||||
text.inputEl.classList.add("astro-composer-template-textarea");
|
||||
return text;
|
||||
})
|
||||
.then((setting) => {
|
||||
setting.descEl.empty();
|
||||
const descDiv = setting.descEl.createEl("div");
|
||||
descDiv.innerHTML =
|
||||
"Used for new pages and when standardizing properties.<br />" +
|
||||
"Variables include {{title}} and {{date}}.<br />" +
|
||||
"Do not wrap {{date}} in quotes as it represents a datetime value, not a string.<br />" +
|
||||
"The 'standardize properties' command ignores anything below the second '---' line.";
|
||||
});
|
||||
|
||||
this.updateConditionalFields();
|
||||
this.updateIndexFileField();
|
||||
this.updateExcludedDirsField();
|
||||
this.updatePagesFields();
|
||||
}
|
||||
|
||||
updateConditionalFields() {
|
||||
if (this.autoRenameContainer) {
|
||||
const settings = (this.plugin as any).settings as AstroComposerSettings;
|
||||
this.autoRenameContainer.style.display = settings.automatePostCreation ? "block" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
updateIndexFileField() {
|
||||
if (this.indexFileContainer) {
|
||||
const settings = (this.plugin as any).settings as AstroComposerSettings;
|
||||
this.indexFileContainer.style.display = settings.creationMode === "folder" ? "block" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
updateExcludedDirsField() {
|
||||
if (this.excludedDirsContainer) {
|
||||
const settings = (this.plugin as any).settings as AstroComposerSettings;
|
||||
this.excludedDirsContainer.style.display = !settings.onlyAutomateInPostsFolder ? "block" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
updatePagesFields() {
|
||||
if (this.pagesFieldsContainer) {
|
||||
const settings = (this.plugin as any).settings as AstroComposerSettings;
|
||||
this.pagesFieldsContainer.style.display = settings.enablePages ? "block" : "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
130
src/ui/title-modal.ts
Normal file
130
src/ui/title-modal.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { App, Modal, TFile, Notice } from "obsidian";
|
||||
import { Plugin } from "obsidian";
|
||||
import { PostType } from "../types";
|
||||
import { FileOperations } from "../utils/file-operations";
|
||||
import { TemplateParser } from "../utils/template-parsing";
|
||||
|
||||
export class TitleModal extends Modal {
|
||||
file: TFile;
|
||||
plugin: Plugin;
|
||||
type: PostType;
|
||||
isRename: boolean;
|
||||
titleInput!: HTMLInputElement;
|
||||
private fileOps: FileOperations;
|
||||
private templateParser: TemplateParser;
|
||||
|
||||
constructor(app: App, file: TFile, plugin: Plugin, type: PostType = "post", isRename: boolean = false) {
|
||||
super(app);
|
||||
this.file = file;
|
||||
this.plugin = plugin;
|
||||
this.type = type;
|
||||
this.isRename = isRename;
|
||||
|
||||
// Initialize utilities with current settings
|
||||
const settings = (plugin as any).settings;
|
||||
this.fileOps = new FileOperations(app, settings);
|
||||
this.templateParser = new TemplateParser(app, settings);
|
||||
}
|
||||
|
||||
getCurrentTitle(): string {
|
||||
const titleKey = this.fileOps.getTitleKey(this.type);
|
||||
const cache = this.app.metadataCache.getFileCache(this.file);
|
||||
let basename = this.file.basename;
|
||||
if (this.file.parent && basename === (this.plugin as any).settings.indexFileName) {
|
||||
basename = this.file.parent.name;
|
||||
}
|
||||
if (basename.startsWith("_")) {
|
||||
basename = basename.slice(1);
|
||||
}
|
||||
const fallbackTitle = basename.replace(/-/g, " ").split(" ").map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
||||
|
||||
if (cache?.frontmatter && cache.frontmatter[titleKey]) {
|
||||
return cache.frontmatter[titleKey];
|
||||
}
|
||||
return fallbackTitle;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
|
||||
if (this.isRename) {
|
||||
contentEl.createEl("h2", { text: "Rename Note" });
|
||||
contentEl.createEl("p", { text: "Enter new title for your note:" });
|
||||
this.titleInput = contentEl.createEl("input", {
|
||||
type: "text",
|
||||
placeholder: "My Renamed Note",
|
||||
cls: "astro-composer-title-input"
|
||||
});
|
||||
this.titleInput.value = this.getCurrentTitle();
|
||||
} else {
|
||||
contentEl.createEl("h2", { text: this.type === "post" ? "New Blog Post" : "New Page" });
|
||||
contentEl.createEl("p", { text: `Enter a title for your ${this.type}:` });
|
||||
this.titleInput = contentEl.createEl("input", {
|
||||
type: "text",
|
||||
placeholder: this.type === "post" ? "My Awesome Blog Post" : "My Awesome Page",
|
||||
cls: "astro-composer-title-input"
|
||||
});
|
||||
}
|
||||
this.titleInput.focus();
|
||||
|
||||
const buttonContainer = contentEl.createDiv({ cls: "astro-composer-button-container" });
|
||||
|
||||
const cancelButton = buttonContainer.createEl("button", { text: "Cancel", cls: "astro-composer-cancel-button" });
|
||||
cancelButton.onclick = () => this.close();
|
||||
|
||||
const submitButton = buttonContainer.createEl("button", { text: this.isRename ? "Rename" : "Create", cls: ["astro-composer-create-button", "mod-cta"] });
|
||||
submitButton.onclick = () => this.submit();
|
||||
|
||||
this.titleInput.addEventListener("keypress", (e) => {
|
||||
if (e.key === "Enter") this.submit();
|
||||
});
|
||||
}
|
||||
|
||||
async submit() {
|
||||
const title = this.titleInput.value.trim();
|
||||
if (!title) {
|
||||
new Notice("Please enter a title.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let newFile: TFile | null = null;
|
||||
if (this.isRename) {
|
||||
newFile = await this.fileOps.renameFile({ file: this.file, title, type: this.type });
|
||||
if (newFile) {
|
||||
await this.templateParser.updateTitleInFrontmatter(newFile, title, this.type);
|
||||
}
|
||||
} else {
|
||||
newFile = await this.fileOps.createFile({ file: this.file, title, type: this.type });
|
||||
if (newFile && (this.plugin as any).settings.autoInsertProperties) {
|
||||
await this.addPropertiesToFile(newFile, title, this.type);
|
||||
}
|
||||
}
|
||||
if (!newFile) {
|
||||
throw new Error("Failed to process the note.");
|
||||
}
|
||||
} catch (error) {
|
||||
new Notice(`Error ${this.isRename ? "renaming" : "creating"} ${this.type}: ${(error as Error).message}.`);
|
||||
}
|
||||
|
||||
this.close();
|
||||
}
|
||||
|
||||
private async addPropertiesToFile(file: TFile, title: string, type: PostType = "post") {
|
||||
const now = new Date();
|
||||
const dateString = window.moment(now).format((this.plugin as any).settings.dateFormat);
|
||||
|
||||
let template = type === "post" ? (this.plugin as any).settings.defaultTemplate : (this.plugin as any).settings.pageTemplate;
|
||||
template = template.replace(/\{\{title\}\}/g, title);
|
||||
template = template.replace(/\{\{date\}\}/g, dateString);
|
||||
|
||||
// Ensure no extra newlines or --- are added beyond the template
|
||||
await this.app.vault.modify(file, template);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
246
src/utils/file-operations.ts
Normal file
246
src/utils/file-operations.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import { App, TFile, TFolder, Notice } from "obsidian";
|
||||
import { AstroComposerSettings, PostType, FileCreationOptions, RenameOptions } from "../types";
|
||||
|
||||
export class FileOperations {
|
||||
constructor(private app: App, private settings: AstroComposerSettings) {}
|
||||
|
||||
toKebabCase(str: string): string {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.trim()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
determineType(file: TFile): PostType {
|
||||
const filePath = file.path;
|
||||
const pagesFolder = this.settings.pagesFolder || "";
|
||||
const isPage = this.settings.enablePages && pagesFolder && (filePath.startsWith(pagesFolder + "/") || filePath === pagesFolder);
|
||||
return isPage ? "page" : "post";
|
||||
}
|
||||
|
||||
getTitleKey(type: PostType): string {
|
||||
const template = type === "post" ? this.settings.defaultTemplate : this.settings.pageTemplate;
|
||||
const lines = template.split("\n");
|
||||
let inProperties = false;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "---") {
|
||||
inProperties = !inProperties;
|
||||
continue;
|
||||
}
|
||||
if (inProperties) {
|
||||
const match = trimmed.match(/^(\w+):\s*(.+)$/);
|
||||
if (match) {
|
||||
const key = match[1];
|
||||
const value = match[2];
|
||||
if (value.includes("{{title}}")) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "title";
|
||||
}
|
||||
|
||||
async createFile(options: FileCreationOptions): Promise<TFile | null> {
|
||||
const { file, title, type } = options;
|
||||
|
||||
if (!title) {
|
||||
new Notice(`Title is required to create a ${type}.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const kebabTitle = this.toKebabCase(title);
|
||||
const prefix = this.settings.enableUnderscorePrefix ? "_" : "";
|
||||
|
||||
let targetFolder = type === "post" ? this.settings.postsFolder || "" : this.settings.pagesFolder || "";
|
||||
if (targetFolder) {
|
||||
const folder = this.app.vault.getAbstractFileByPath(targetFolder);
|
||||
if (!(folder instanceof TFolder)) {
|
||||
await this.app.vault.createFolder(targetFolder);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.settings.creationMode === "folder") {
|
||||
return this.createFolderStructure(file, kebabTitle, prefix, targetFolder, type);
|
||||
} else {
|
||||
return this.createFileStructure(file, kebabTitle, prefix, targetFolder);
|
||||
}
|
||||
}
|
||||
|
||||
private async createFolderStructure(file: TFile, kebabTitle: string, prefix: string, targetFolder: string, type: PostType): Promise<TFile | null> {
|
||||
const folderName = `${prefix}${kebabTitle}`;
|
||||
const folderPath = targetFolder ? `${targetFolder}/${folderName}` : folderName;
|
||||
|
||||
try {
|
||||
const folder = this.app.vault.getAbstractFileByPath(folderPath);
|
||||
if (!(folder instanceof TFolder)) {
|
||||
await this.app.vault.createFolder(folderPath);
|
||||
}
|
||||
} catch (error) {
|
||||
// Folder might already exist, proceed
|
||||
}
|
||||
|
||||
const fileName = `${this.settings.indexFileName}.md`;
|
||||
const newPath = `${folderPath}/${fileName}`;
|
||||
|
||||
const existingFile = this.app.vault.getAbstractFileByPath(newPath);
|
||||
if (existingFile instanceof TFile) {
|
||||
new Notice(`File already exists at ${newPath}.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.app.vault.rename(file, newPath);
|
||||
const newFile = this.app.vault.getAbstractFileByPath(newPath);
|
||||
if (!(newFile instanceof TFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const fileExplorer = this.app.workspace.getLeavesOfType("file-explorer")[0];
|
||||
if (fileExplorer && fileExplorer.view) {
|
||||
const fileTree = (fileExplorer.view as any).tree;
|
||||
if (fileTree && newFile instanceof TFile) {
|
||||
fileTree.revealFile(newFile);
|
||||
}
|
||||
}
|
||||
}, 200);
|
||||
|
||||
const leaf = this.app.workspace.getLeaf(false);
|
||||
await leaf.openFile(newFile);
|
||||
|
||||
return newFile;
|
||||
} catch (error) {
|
||||
new Notice(`Failed to create folder structure: ${(error as Error).message}.`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async createFileStructure(file: TFile, kebabTitle: string, prefix: string, targetFolder: string): Promise<TFile | null> {
|
||||
const newName = `${prefix}${kebabTitle}.md`;
|
||||
const newPath = targetFolder ? `${targetFolder}/${newName}` : newName;
|
||||
|
||||
const existingFile = this.app.vault.getAbstractFileByPath(newPath);
|
||||
if (existingFile instanceof TFile && existingFile !== file) {
|
||||
new Notice(`File with name "${newName}" already exists.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.app.vault.rename(file, newPath);
|
||||
const newFile = this.app.vault.getAbstractFileByPath(newPath);
|
||||
if (!(newFile instanceof TFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const leaf = this.app.workspace.getLeaf(false);
|
||||
await leaf.openFile(newFile);
|
||||
|
||||
return newFile;
|
||||
} catch (error) {
|
||||
new Notice(`Failed to rename file: ${(error as Error).message}.`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async renameFile(options: RenameOptions): Promise<TFile | null> {
|
||||
const { file, title, type } = options;
|
||||
|
||||
if (!title) {
|
||||
new Notice(`Title is required to rename the note.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const kebabTitle = this.toKebabCase(title);
|
||||
let prefix = "";
|
||||
|
||||
if (this.settings.creationMode === "folder") {
|
||||
return this.renameFolderStructure(file, kebabTitle, prefix, type);
|
||||
} else {
|
||||
return this.renameFileStructure(file, kebabTitle, prefix);
|
||||
}
|
||||
}
|
||||
|
||||
private async renameFolderStructure(file: TFile, kebabTitle: string, prefix: string, type: PostType): Promise<TFile | null> {
|
||||
const isIndex = file.basename === this.settings.indexFileName;
|
||||
if (isIndex) {
|
||||
if (!file.parent) {
|
||||
new Notice("Cannot rename: File has no parent folder.");
|
||||
return null;
|
||||
}
|
||||
prefix = file.parent.name.startsWith("_") ? "_" : "";
|
||||
const newFolderName = `${prefix}${kebabTitle}`;
|
||||
const parentFolder = file.parent.parent;
|
||||
if (!parentFolder) {
|
||||
new Notice("Cannot rename: Parent folder has no parent.");
|
||||
return null;
|
||||
}
|
||||
const newFolderPath = `${parentFolder.path}/${newFolderName}`;
|
||||
|
||||
const existingFolder = this.app.vault.getAbstractFileByPath(newFolderPath);
|
||||
if (existingFolder instanceof TFolder) {
|
||||
new Notice(`Folder already exists at ${newFolderPath}.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.app.vault.rename(file.parent, newFolderPath);
|
||||
const newFilePath = `${newFolderPath}/${file.name}`;
|
||||
const newFile = this.app.vault.getAbstractFileByPath(newFilePath);
|
||||
if (!(newFile instanceof TFile)) {
|
||||
new Notice("Failed to locate renamed file.");
|
||||
return null;
|
||||
}
|
||||
return newFile;
|
||||
} else {
|
||||
if (!file.parent) {
|
||||
new Notice("Cannot rename: File has no parent folder.");
|
||||
return null;
|
||||
}
|
||||
prefix = file.basename.startsWith("_") ? "_" : "";
|
||||
const newName = `${prefix}${kebabTitle}.md`;
|
||||
const newPath = `${file.parent.path}/${newName}`;
|
||||
|
||||
const existingFile = this.app.vault.getAbstractFileByPath(newPath);
|
||||
if (existingFile instanceof TFile && existingFile !== file) {
|
||||
new Notice(`File already exists at ${newPath}.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.app.vault.rename(file, newPath);
|
||||
const newFile = this.app.vault.getAbstractFileByPath(newPath);
|
||||
if (!(newFile instanceof TFile)) {
|
||||
new Notice("Failed to locate renamed file.");
|
||||
return null;
|
||||
}
|
||||
return newFile;
|
||||
}
|
||||
}
|
||||
|
||||
private async renameFileStructure(file: TFile, kebabTitle: string, prefix: string): Promise<TFile | null> {
|
||||
if (!file.parent) {
|
||||
new Notice("Cannot rename: File has no parent folder.");
|
||||
return null;
|
||||
}
|
||||
prefix = file.basename.startsWith("_") ? "_" : "";
|
||||
const newName = `${prefix}${kebabTitle}.md`;
|
||||
const newPath = `${file.parent.path}/${newName}`;
|
||||
|
||||
const existingFile = this.app.vault.getAbstractFileByPath(newPath);
|
||||
if (existingFile instanceof TFile && existingFile !== file) {
|
||||
new Notice(`File already exists at ${newPath}.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.app.vault.rename(file, newPath);
|
||||
const newFile = this.app.vault.getAbstractFileByPath(newPath);
|
||||
if (!(newFile instanceof TFile)) {
|
||||
new Notice("Failed to locate renamed file.");
|
||||
return null;
|
||||
}
|
||||
return newFile;
|
||||
}
|
||||
}
|
||||
122
src/utils/link-conversion.ts
Normal file
122
src/utils/link-conversion.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { Editor, TFile, Notice } from "obsidian";
|
||||
import { AstroComposerSettings, PostType } from "../types";
|
||||
|
||||
export class LinkConverter {
|
||||
constructor(private settings: AstroComposerSettings) {}
|
||||
|
||||
toKebabCase(str: string): string {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.trim()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
getAstroUrlFromInternalLink(link: string): string {
|
||||
const hashIndex = link.indexOf('#');
|
||||
let path = hashIndex >= 0 ? link.slice(0, hashIndex) : link;
|
||||
let anchor = hashIndex >= 0 ? link.slice(hashIndex) : '';
|
||||
|
||||
path = path.replace(/\.md$/, "");
|
||||
|
||||
// Strip root folder if present
|
||||
if (path.startsWith(this.settings.postsFolder + '/')) {
|
||||
path = path.slice(this.settings.postsFolder.length + 1);
|
||||
} else if (this.settings.enablePages && path.startsWith(this.settings.pagesFolder + '/')) {
|
||||
path = path.slice(this.settings.pagesFolder.length + 1);
|
||||
}
|
||||
|
||||
let addTrailingSlash = false;
|
||||
if (this.settings.creationMode === "folder") {
|
||||
const parts = path.split('/');
|
||||
if (parts[parts.length - 1] === this.settings.indexFileName) {
|
||||
parts.pop();
|
||||
path = parts.join('/');
|
||||
addTrailingSlash = true;
|
||||
}
|
||||
}
|
||||
|
||||
const slugParts = path.split('/').map(part => this.toKebabCase(part));
|
||||
const slug = slugParts.join('/');
|
||||
|
||||
let basePath = this.settings.linkBasePath;
|
||||
if (!basePath.startsWith("/")) basePath = "/" + basePath;
|
||||
if (!basePath.endsWith("/")) basePath += "/";
|
||||
|
||||
return `${basePath}${slug}${addTrailingSlash ? '/' : ''}${anchor}`;
|
||||
}
|
||||
|
||||
async convertWikilinksForAstro(editor: Editor, file: TFile | null): Promise<void> {
|
||||
if (!(file instanceof TFile)) {
|
||||
new Notice("No active file.");
|
||||
return;
|
||||
}
|
||||
|
||||
const content = editor.getValue();
|
||||
let newContent = content;
|
||||
|
||||
// Define common image extensions
|
||||
const imageExtensions = /\.(png|jpg|jpeg|gif|svg)$/i;
|
||||
|
||||
// Handle regular Wikilinks (non-image)
|
||||
newContent = newContent.replace(
|
||||
/\[\[([^\]|]+)(\|([^\]]+))?\]\]/g,
|
||||
(match, linkText, _pipe, displayText) => {
|
||||
// Check if it's an image Wikilink
|
||||
if (imageExtensions.test(linkText)) {
|
||||
return match; // Ignore and return original image Wikilink
|
||||
}
|
||||
|
||||
const display = displayText || linkText.replace(/\.md$/, "");
|
||||
|
||||
const url = this.getAstroUrlFromInternalLink(linkText);
|
||||
|
||||
return `[${display}](${url})`;
|
||||
}
|
||||
);
|
||||
|
||||
// Handle standard Markdown links (non-image, non-external)
|
||||
newContent = newContent.replace(
|
||||
/\[([^\]]+)\]\(([^)]+)\)/g,
|
||||
(match, displayText, link) => {
|
||||
// Check if it's an image link or external link
|
||||
if (link.match(/^https?:\/\//) || imageExtensions.test(link)) {
|
||||
return match; // Ignore external or image links
|
||||
}
|
||||
|
||||
// Check if it's internal .md link
|
||||
if (!link.includes('.md')) {
|
||||
return match; // Ignore if not .md
|
||||
}
|
||||
|
||||
const url = this.getAstroUrlFromInternalLink(link);
|
||||
|
||||
return `[${displayText}](${url})`;
|
||||
}
|
||||
);
|
||||
|
||||
// Handle image links in Markdown format (e.g., )
|
||||
newContent = newContent.replace(
|
||||
/!\[(.*?)\]\(([^)]+)\)/g,
|
||||
(match) => {
|
||||
return match; // Ignore all image links
|
||||
}
|
||||
);
|
||||
|
||||
// Handle {{embed}} syntax
|
||||
newContent = newContent.replace(/\{\{([^}]+)\}\}/g, (match, fileName) => {
|
||||
if (imageExtensions.test(fileName)) {
|
||||
return match; // Ignore embedded images
|
||||
}
|
||||
|
||||
const url = this.getAstroUrlFromInternalLink(fileName);
|
||||
|
||||
return `[Embedded: ${fileName}](${url})`;
|
||||
});
|
||||
|
||||
editor.setValue(newContent);
|
||||
new Notice("All internal links converted for Astro.");
|
||||
}
|
||||
}
|
||||
230
src/utils/template-parsing.ts
Normal file
230
src/utils/template-parsing.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import { App, TFile, Notice } from "obsidian";
|
||||
import { AstroComposerSettings, PostType, ParsedFrontmatter, TemplateValues, KNOWN_ARRAY_KEYS } from "../types";
|
||||
|
||||
export class TemplateParser {
|
||||
constructor(private app: App, private settings: AstroComposerSettings) {}
|
||||
|
||||
async parseFrontmatter(content: string): Promise<ParsedFrontmatter> {
|
||||
let propertiesEnd = 0;
|
||||
let propertiesText = "";
|
||||
const existingProperties: Record<string, string[]> = {};
|
||||
|
||||
// Parse existing properties with fallback for missing second ---
|
||||
if (content.startsWith("---")) {
|
||||
propertiesEnd = content.indexOf("\n---", 3);
|
||||
if (propertiesEnd === -1) {
|
||||
propertiesEnd = content.length; // Treat entire content as frontmatter if no second ---
|
||||
} else {
|
||||
propertiesEnd += 4; // Move past the second ---
|
||||
}
|
||||
propertiesText = content.slice(4, propertiesEnd - 4).trim();
|
||||
|
||||
try {
|
||||
let currentKey: string | null = null;
|
||||
propertiesText.split("\n").forEach((line) => {
|
||||
const match = line.match(/^(\w+):\s*(.+)?$/);
|
||||
if (match) {
|
||||
const [, key, value] = match;
|
||||
currentKey = key;
|
||||
if (KNOWN_ARRAY_KEYS.includes(key as any)) {
|
||||
existingProperties[key] = [];
|
||||
} else {
|
||||
existingProperties[key] = [value ? value.trim() : ""];
|
||||
}
|
||||
} else if (currentKey && KNOWN_ARRAY_KEYS.includes(currentKey as any) && line.trim().startsWith("- ")) {
|
||||
const item = line.trim().replace(/^-\s*/, "");
|
||||
if (item) existingProperties[currentKey].push(item);
|
||||
} else if (line.trim() && !line.trim().startsWith("- ")) {
|
||||
// Handle unrecognized properties
|
||||
const keyMatch = line.match(/^(\w+):/);
|
||||
if (keyMatch) {
|
||||
const key = keyMatch[1];
|
||||
const value = line.slice(line.indexOf(":") + 1).trim();
|
||||
if (!existingProperties[key]) {
|
||||
existingProperties[key] = [value || ""];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// Preserve array keys if they exist without values
|
||||
KNOWN_ARRAY_KEYS.forEach(key => {
|
||||
if (propertiesText.includes(key + ':') && !existingProperties[key]) {
|
||||
existingProperties[key] = [];
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// Fallback to template if parsing fails
|
||||
new Notice("Falling back to template due to parsing error.");
|
||||
}
|
||||
}
|
||||
|
||||
const bodyContent = content.slice(propertiesEnd);
|
||||
return {
|
||||
properties: existingProperties,
|
||||
propertiesText,
|
||||
propertiesEnd,
|
||||
bodyContent
|
||||
};
|
||||
}
|
||||
|
||||
parseTemplate(templateString: string, title: string): { templateProps: string[]; templateValues: TemplateValues } {
|
||||
const templateLines = templateString.split("\n");
|
||||
const templateProps: string[] = [];
|
||||
const templateValues: TemplateValues = {};
|
||||
let inProperties = false;
|
||||
|
||||
for (let i = 0; i < templateLines.length; i++) {
|
||||
const line = templateLines[i].trim();
|
||||
if (line === "---") {
|
||||
inProperties = !inProperties;
|
||||
if (!inProperties) {
|
||||
break; // Stop at second --- to exclude post-property content
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inProperties) {
|
||||
const match = line.match(/^(\w+):\s*(.+)?$/);
|
||||
if (match) {
|
||||
const [, key, value] = match;
|
||||
templateProps.push(key);
|
||||
if (KNOWN_ARRAY_KEYS.includes(key as any)) {
|
||||
// Handle template array keys
|
||||
if (value && value.startsWith("[")) {
|
||||
const items = value
|
||||
.replace(/[\[\]]/g, "")
|
||||
.split(",")
|
||||
.map(t => t.trim())
|
||||
.filter(t => t);
|
||||
templateValues[key] = items;
|
||||
} else {
|
||||
templateValues[key] = [];
|
||||
// Look ahead for item list
|
||||
for (let j = i + 1; j < templateLines.length; j++) {
|
||||
const nextLine = templateLines[j].trim();
|
||||
if (nextLine.startsWith("- ")) {
|
||||
const item = nextLine.replace(/^-\s*/, "").trim();
|
||||
if (item) templateValues[key].push(item);
|
||||
} else if (nextLine === "---") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
templateValues[key] = [ (value || "").replace(/\{\{title\}\}/g, title).replace(/\{\{date\}\}/g, window.moment(new Date()).format(this.settings.dateFormat)) ];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { templateProps, templateValues };
|
||||
}
|
||||
|
||||
buildFrontmatterContent(finalProps: Record<string, string[]>): string {
|
||||
let newContent = "---\n";
|
||||
for (const key in finalProps) {
|
||||
if (KNOWN_ARRAY_KEYS.includes(key as any)) {
|
||||
newContent += `${key}:\n`;
|
||||
if (finalProps[key].length > 0) {
|
||||
finalProps[key].forEach(item => {
|
||||
newContent += ` - ${item}\n`;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
newContent += `${key}: ${finalProps[key][0] || ""}\n`;
|
||||
}
|
||||
}
|
||||
newContent += "---";
|
||||
return newContent;
|
||||
}
|
||||
|
||||
async updateTitleInFrontmatter(file: TFile, newTitle: string, type: PostType): Promise<void> {
|
||||
const titleKey = this.getTitleKey(type);
|
||||
const content = await this.app.vault.read(file);
|
||||
let propertiesEnd = 0;
|
||||
let propertiesText = "";
|
||||
|
||||
if (content.startsWith("---")) {
|
||||
propertiesEnd = content.indexOf("\n---", 3);
|
||||
if (propertiesEnd === -1) {
|
||||
propertiesEnd = content.length;
|
||||
} else {
|
||||
propertiesEnd += 4;
|
||||
}
|
||||
propertiesText = content.slice(4, propertiesEnd - 4).trim();
|
||||
}
|
||||
|
||||
const propOrder: string[] = [];
|
||||
const existing: Record<string, any> = {};
|
||||
let currentKey: string | null = null;
|
||||
|
||||
propertiesText.split("\n").forEach((line) => {
|
||||
const match = line.match(/^(\w+):\s*(.+)?$/);
|
||||
if (match) {
|
||||
const [, key, value] = match;
|
||||
propOrder.push(key);
|
||||
currentKey = key;
|
||||
if (KNOWN_ARRAY_KEYS.includes(key as any)) {
|
||||
existing[key] = [];
|
||||
} else {
|
||||
existing[key] = value ? value.trim() : "";
|
||||
}
|
||||
} else if (currentKey && KNOWN_ARRAY_KEYS.includes(currentKey as any) && line.trim().startsWith("- ")) {
|
||||
const item = line.trim().replace(/^-\s*/, "");
|
||||
if (item) (existing[currentKey] as string[]).push(item);
|
||||
}
|
||||
});
|
||||
|
||||
const escapedTitle = newTitle.replace(/"/g, '\\"');
|
||||
const titleVal = newTitle.includes(" ") || newTitle.includes('"') ? `"${escapedTitle}"` : newTitle;
|
||||
existing[titleKey] = titleVal;
|
||||
|
||||
if (!propOrder.includes(titleKey)) {
|
||||
propOrder.push(titleKey);
|
||||
}
|
||||
|
||||
let newContent = "---\n";
|
||||
for (const key of propOrder) {
|
||||
const val = existing[key];
|
||||
if (Array.isArray(val)) {
|
||||
newContent += `${key}:\n`;
|
||||
if (val.length > 0) {
|
||||
val.forEach((item: string) => {
|
||||
newContent += ` - ${item}\n`;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
newContent += `${key}: ${val || ""}\n`;
|
||||
}
|
||||
}
|
||||
newContent += "---";
|
||||
|
||||
const bodyContent = content.slice(propertiesEnd);
|
||||
newContent += bodyContent;
|
||||
|
||||
await this.app.vault.modify(file, newContent);
|
||||
}
|
||||
|
||||
private getTitleKey(type: PostType): string {
|
||||
const template = type === "post" ? this.settings.defaultTemplate : this.settings.pageTemplate;
|
||||
const lines = template.split("\n");
|
||||
let inProperties = false;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "---") {
|
||||
inProperties = !inProperties;
|
||||
continue;
|
||||
}
|
||||
if (inProperties) {
|
||||
const match = trimmed.match(/^(\w+):\s*(.+)$/);
|
||||
if (match) {
|
||||
const key = match[1];
|
||||
const value = match[2];
|
||||
if (value.includes("{{title}}")) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "title";
|
||||
}
|
||||
}
|
||||
|
|
@ -4,21 +4,22 @@
|
|||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"target": "ES2018",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
"ES2018"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"0.5.3": "0.15.0"
|
||||
"0.5.5": "0.15.0"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue