548 lines
14 KiB
TypeScript
548 lines
14 KiB
TypeScript
import { App, Modal, Notice, Plugin, TFile, TFolder, normalizePath, MarkdownView } from 'obsidian';
|
|
|
|
interface PluginSettings {
|
|
|
|
}
|
|
|
|
const DEFAULT_SETTINGS: PluginSettings = {
|
|
|
|
}
|
|
|
|
const FOLDERS = {
|
|
MOCs: 'MOCs',
|
|
Notes: 'Notes',
|
|
Resources: 'Resources',
|
|
Prompts: 'Prompts'
|
|
} as const;
|
|
|
|
const SECTION_ORDER = ['MOCs', 'Notes', 'Resources', 'Prompts'] as const;
|
|
type SectionType = typeof SECTION_ORDER[number];
|
|
|
|
export default class MOCSystemPlugin extends Plugin {
|
|
settings: PluginSettings;
|
|
|
|
async onload() {
|
|
await this.loadSettings();
|
|
await this.ensureFolderStructure();
|
|
|
|
// Main command for context-aware creation
|
|
this.addCommand({
|
|
id: 'moc-context-create',
|
|
name: 'Create MOC or add content',
|
|
callback: () => this.handleContextCreate()
|
|
});
|
|
|
|
// Command to duplicate prompt iteration
|
|
this.addCommand({
|
|
id: 'duplicate-prompt-iteration',
|
|
name: 'Duplicate prompt iteration',
|
|
checkCallback: (checking: boolean) => {
|
|
const activeFile = this.app.workspace.getActiveFile();
|
|
if (activeFile && this.isPromptIteration(activeFile)) {
|
|
if (!checking) {
|
|
this.duplicatePromptIteration(activeFile);
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
});
|
|
|
|
// Command to open all LLM links
|
|
this.addCommand({
|
|
id: 'open-llm-links',
|
|
name: 'Open all LLM links',
|
|
checkCallback: (checking: boolean) => {
|
|
const activeFile = this.app.workspace.getActiveFile();
|
|
if (activeFile && this.isPromptHub(activeFile)) {
|
|
if (!checking) {
|
|
this.openLLMLinks(activeFile);
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
});
|
|
|
|
// Auto-cleanup on file deletion
|
|
this.registerEvent(
|
|
this.app.vault.on('delete', (file) => {
|
|
if (file instanceof TFile) {
|
|
this.cleanupBrokenLinks(file);
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
onunload() {
|
|
|
|
}
|
|
|
|
async loadSettings() {
|
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
|
}
|
|
|
|
async saveSettings() {
|
|
await this.saveData(this.settings);
|
|
}
|
|
|
|
async ensureFolderStructure() {
|
|
for (const folder of Object.values(FOLDERS)) {
|
|
const folderPath = normalizePath(folder);
|
|
if (!this.app.vault.getAbstractFileByPath(folderPath)) {
|
|
await this.app.vault.createFolder(folderPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
async handleContextCreate() {
|
|
const activeFile = this.app.workspace.getActiveFile();
|
|
|
|
if (!activeFile || !this.isMOC(activeFile)) {
|
|
// Not in a MOC, create new MOC
|
|
new CreateMOCModal(this.app, async (name: string) => {
|
|
await this.createMOC(name);
|
|
}).open();
|
|
} else {
|
|
// In a MOC, show options to add content
|
|
new AddToMOCModal(this.app, activeFile, this).open();
|
|
}
|
|
}
|
|
|
|
async createMOC(name: string): Promise<TFile> {
|
|
const fileName = `${name}.md`;
|
|
const content = `---\ntags:\n - moc\n---\n`;
|
|
|
|
const file = await this.app.vault.create(fileName, content);
|
|
await this.app.workspace.getLeaf().openFile(file);
|
|
new Notice(`Created MOC: ${name}`);
|
|
return file;
|
|
}
|
|
|
|
async createSubMOC(parentMOC: TFile, name: string): Promise<TFile> {
|
|
const fileName = `${FOLDERS.MOCs}/${name}.md`;
|
|
const content = `---\ntags:\n - moc\n---\n`;
|
|
|
|
const file = await this.app.vault.create(normalizePath(fileName), content);
|
|
await this.addToMOCSection(parentMOC, 'MOCs', file);
|
|
new Notice(`Created sub-MOC: ${name}`);
|
|
return file;
|
|
}
|
|
|
|
async createNote(parentMOC: TFile, name: string): Promise<TFile> {
|
|
const fileName = `${FOLDERS.Notes}/${name}.md`;
|
|
const content = '';
|
|
|
|
const file = await this.app.vault.create(normalizePath(fileName), content);
|
|
await this.addToMOCSection(parentMOC, 'Notes', file);
|
|
new Notice(`Created note: ${name}`);
|
|
return file;
|
|
}
|
|
|
|
async createResource(parentMOC: TFile, name: string): Promise<TFile> {
|
|
const fileName = `${FOLDERS.Resources}/${name}.md`;
|
|
const content = '';
|
|
|
|
const file = await this.app.vault.create(normalizePath(fileName), content);
|
|
await this.addToMOCSection(parentMOC, 'Resources', file);
|
|
new Notice(`Created resource: ${name}`);
|
|
return file;
|
|
}
|
|
|
|
async createPrompt(parentMOC: TFile, name: string): Promise<TFile> {
|
|
// Create prompt hub
|
|
const hubFileName = `${FOLDERS.Prompts}/${name}.md`;
|
|
const hubContent = `# ${name}\n\n## Iterations\n\n- [[${name} v1]]\n\n## LLM Links\n\n\`\`\`llm-links\n\n\`\`\`\n`;
|
|
|
|
const hubFile = await this.app.vault.create(normalizePath(hubFileName), hubContent);
|
|
|
|
// Create first iteration
|
|
const iterationFileName = `${FOLDERS.Prompts}/${name} v1.md`;
|
|
const iterationContent = '';
|
|
await this.app.vault.create(normalizePath(iterationFileName), iterationContent);
|
|
|
|
await this.addToMOCSection(parentMOC, 'Prompts', hubFile);
|
|
new Notice(`Created prompt: ${name}`);
|
|
return hubFile;
|
|
}
|
|
|
|
async addToMOCSection(moc: TFile, section: SectionType, newFile: TFile) {
|
|
const content = await this.app.vault.read(moc);
|
|
const lines = content.split('\n');
|
|
|
|
// Find or create section
|
|
let sectionIndex = -1;
|
|
let insertIndex = lines.length;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (lines[i].trim() === `## ${section}`) {
|
|
sectionIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (sectionIndex === -1) {
|
|
// Section doesn't exist, find where to insert it
|
|
const currentSectionIndices: Map<SectionType, number> = new Map();
|
|
|
|
// Find existing sections
|
|
for (let i = 0; i < lines.length; i++) {
|
|
for (const sectionName of SECTION_ORDER) {
|
|
if (lines[i].trim() === `## ${sectionName}`) {
|
|
currentSectionIndices.set(sectionName, i);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find where to insert new section
|
|
insertIndex = lines.length;
|
|
for (let i = SECTION_ORDER.indexOf(section) + 1; i < SECTION_ORDER.length; i++) {
|
|
if (currentSectionIndices.has(SECTION_ORDER[i])) {
|
|
insertIndex = currentSectionIndices.get(SECTION_ORDER[i])!;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Insert section header
|
|
const newSection = [`## ${section}`, '', `- [[${newFile.basename}]]`, ''];
|
|
lines.splice(insertIndex, 0, ...newSection);
|
|
} else {
|
|
// Section exists, add link to it
|
|
let linkInsertIndex = sectionIndex + 1;
|
|
|
|
// Skip empty lines after header
|
|
while (linkInsertIndex < lines.length && lines[linkInsertIndex].trim() === '') {
|
|
linkInsertIndex++;
|
|
}
|
|
|
|
// Find end of section
|
|
while (linkInsertIndex < lines.length &&
|
|
!lines[linkInsertIndex].startsWith('## ') &&
|
|
lines[linkInsertIndex].trim() !== '') {
|
|
linkInsertIndex++;
|
|
}
|
|
|
|
// Insert before empty line or next section
|
|
lines.splice(linkInsertIndex, 0, `- [[${newFile.basename}]]`);
|
|
}
|
|
|
|
await this.app.vault.modify(moc, lines.join('\n'));
|
|
}
|
|
|
|
async duplicatePromptIteration(file: TFile) {
|
|
const match = file.basename.match(/^(.+?)\s*v(\d+)(?:\s*-\s*(.+))?$/);
|
|
if (!match) return;
|
|
|
|
const [, baseName, currentVersion] = match;
|
|
|
|
// Find all iterations to get next available version
|
|
const promptFiles = this.app.vault.getMarkdownFiles()
|
|
.filter(f => f.path.startsWith(FOLDERS.Prompts) && f.basename.startsWith(baseName));
|
|
|
|
let maxVersion = 0;
|
|
for (const pFile of promptFiles) {
|
|
const vMatch = pFile.basename.match(/v(\d+)/);
|
|
if (vMatch) {
|
|
maxVersion = Math.max(maxVersion, parseInt(vMatch[1]));
|
|
}
|
|
}
|
|
|
|
const nextVersion = maxVersion + 1;
|
|
|
|
// Ask for description
|
|
new PromptDescriptionModal(this.app, async (description: string) => {
|
|
const newName = description
|
|
? `${baseName} v${nextVersion} - ${description}`
|
|
: `${baseName} v${nextVersion}`;
|
|
|
|
const newPath = `${FOLDERS.Prompts}/${newName}.md`;
|
|
const content = await this.app.vault.read(file);
|
|
|
|
const newFile = await this.app.vault.create(normalizePath(newPath), content);
|
|
|
|
// Update hub file
|
|
await this.updatePromptHub(baseName, newFile);
|
|
|
|
await this.app.workspace.getLeaf().openFile(newFile);
|
|
new Notice(`Created iteration: ${newName}`);
|
|
}).open();
|
|
}
|
|
|
|
async updatePromptHub(baseName: string, newIteration: TFile) {
|
|
const hubPath = `${FOLDERS.Prompts}/${baseName}.md`;
|
|
const hubFile = this.app.vault.getAbstractFileByPath(normalizePath(hubPath));
|
|
|
|
if (hubFile instanceof TFile) {
|
|
const content = await this.app.vault.read(hubFile);
|
|
const lines = content.split('\n');
|
|
|
|
// Find iterations section
|
|
let iterIndex = -1;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (lines[i].trim() === '## Iterations') {
|
|
iterIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (iterIndex !== -1) {
|
|
// Find where to insert
|
|
let insertIndex = iterIndex + 1;
|
|
while (insertIndex < lines.length &&
|
|
!lines[insertIndex].startsWith('## ') &&
|
|
lines[insertIndex].trim() !== '') {
|
|
insertIndex++;
|
|
}
|
|
|
|
// Insert before empty line or next section
|
|
lines.splice(insertIndex, 0, `- [[${newIteration.basename}]]`);
|
|
await this.app.vault.modify(hubFile, lines.join('\n'));
|
|
}
|
|
}
|
|
}
|
|
|
|
async openLLMLinks(file: TFile) {
|
|
const content = await this.app.vault.read(file);
|
|
const linkBlockMatch = content.match(/```llm-links\n([\s\S]*?)\n```/);
|
|
|
|
if (linkBlockMatch) {
|
|
const links = linkBlockMatch[1]
|
|
.split('\n')
|
|
.map(line => line.trim())
|
|
.filter(line => line.startsWith('http'));
|
|
|
|
if (links.length === 0) {
|
|
new Notice('No links found in llm-links block');
|
|
return;
|
|
}
|
|
|
|
// Open all links
|
|
for (const link of links) {
|
|
window.open(link, '_blank');
|
|
}
|
|
|
|
new Notice(`Opened ${links.length} links`);
|
|
} else {
|
|
new Notice('No llm-links block found');
|
|
}
|
|
}
|
|
|
|
async cleanupBrokenLinks(deletedFile: TFile) {
|
|
const allFiles = this.app.vault.getMarkdownFiles();
|
|
|
|
for (const file of allFiles) {
|
|
const content = await this.app.vault.read(file);
|
|
const linkPattern = new RegExp(`\\[\\[${deletedFile.basename}\\]\\]`, 'g');
|
|
|
|
if (linkPattern.test(content)) {
|
|
const lines = content.split('\n');
|
|
const newLines = lines.filter(line => !line.includes(`[[${deletedFile.basename}]]`));
|
|
|
|
if (lines.length !== newLines.length) {
|
|
await this.app.vault.modify(file, newLines.join('\n'));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
isMOC(file: TFile): boolean {
|
|
const cache = this.app.metadataCache.getFileCache(file);
|
|
return cache?.frontmatter?.tags?.includes('moc') ?? false;
|
|
}
|
|
|
|
isPromptIteration(file: TFile): boolean {
|
|
return file.path.startsWith(FOLDERS.Prompts) && /v\d+/.test(file.basename);
|
|
}
|
|
|
|
isPromptHub(file: TFile): boolean {
|
|
return file.path.startsWith(FOLDERS.Prompts) && !this.isPromptIteration(file);
|
|
}
|
|
}
|
|
|
|
class CreateMOCModal extends Modal {
|
|
constructor(app: App, private onSubmit: (name: string) => void) {
|
|
super(app);
|
|
}
|
|
|
|
onOpen() {
|
|
const { contentEl } = this;
|
|
contentEl.createEl('h2', { text: 'Create new MOC' });
|
|
|
|
const inputEl = contentEl.createEl('input', {
|
|
type: 'text',
|
|
placeholder: 'MOC name...'
|
|
});
|
|
inputEl.style.width = '100%';
|
|
inputEl.focus();
|
|
|
|
inputEl.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter' && inputEl.value) {
|
|
this.onSubmit(inputEl.value);
|
|
this.close();
|
|
}
|
|
});
|
|
|
|
const buttonEl = contentEl.createEl('button', { text: 'Create' });
|
|
buttonEl.addEventListener('click', () => {
|
|
if (inputEl.value) {
|
|
this.onSubmit(inputEl.value);
|
|
this.close();
|
|
}
|
|
});
|
|
}
|
|
|
|
onClose() {
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
}
|
|
}
|
|
|
|
class AddToMOCModal extends Modal {
|
|
constructor(
|
|
app: App,
|
|
private moc: TFile,
|
|
private plugin: MOCSystemPlugin
|
|
) {
|
|
super(app);
|
|
}
|
|
|
|
onOpen() {
|
|
const { contentEl } = this;
|
|
contentEl.createEl('h2', { text: 'Add to MOC' });
|
|
|
|
const options: Array<{ type: SectionType, label: string }> = [
|
|
{ type: 'MOCs', label: 'Sub-MOC' },
|
|
{ type: 'Notes', label: 'Note' },
|
|
{ type: 'Resources', label: 'Resource' },
|
|
{ type: 'Prompts', label: 'Prompt' }
|
|
];
|
|
|
|
options.forEach(option => {
|
|
const button = contentEl.createEl('button', {
|
|
text: `Create ${option.label}`,
|
|
cls: 'mod-cta'
|
|
});
|
|
button.style.display = 'block';
|
|
button.style.width = '100%';
|
|
button.style.marginBottom = '10px';
|
|
|
|
button.addEventListener('click', () => {
|
|
this.close();
|
|
new CreateItemModal(this.app, option.label, async (name: string) => {
|
|
switch (option.type) {
|
|
case 'MOCs':
|
|
await this.plugin.createSubMOC(this.moc, name);
|
|
break;
|
|
case 'Notes':
|
|
await this.plugin.createNote(this.moc, name);
|
|
break;
|
|
case 'Resources':
|
|
await this.plugin.createResource(this.moc, name);
|
|
break;
|
|
case 'Prompts':
|
|
await this.plugin.createPrompt(this.moc, name);
|
|
break;
|
|
}
|
|
}).open();
|
|
});
|
|
});
|
|
}
|
|
|
|
onClose() {
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
}
|
|
}
|
|
|
|
class CreateItemModal extends Modal {
|
|
constructor(
|
|
app: App,
|
|
private itemType: string,
|
|
private onSubmit: (name: string) => void
|
|
) {
|
|
super(app);
|
|
}
|
|
|
|
onOpen() {
|
|
const { contentEl } = this;
|
|
contentEl.createEl('h2', { text: `Create ${this.itemType}` });
|
|
|
|
const inputEl = contentEl.createEl('input', {
|
|
type: 'text',
|
|
placeholder: `${this.itemType} name...`
|
|
});
|
|
inputEl.style.width = '100%';
|
|
inputEl.focus();
|
|
|
|
inputEl.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter' && inputEl.value) {
|
|
this.onSubmit(inputEl.value);
|
|
this.close();
|
|
}
|
|
});
|
|
|
|
const buttonEl = contentEl.createEl('button', { text: 'Create' });
|
|
buttonEl.addEventListener('click', () => {
|
|
if (inputEl.value) {
|
|
this.onSubmit(inputEl.value);
|
|
this.close();
|
|
}
|
|
});
|
|
}
|
|
|
|
onClose() {
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
}
|
|
}
|
|
|
|
class PromptDescriptionModal extends Modal {
|
|
constructor(app: App, private onSubmit: (description: string) => void) {
|
|
super(app);
|
|
}
|
|
|
|
onOpen() {
|
|
const { contentEl } = this;
|
|
contentEl.createEl('h2', { text: 'Add iteration description (optional)' });
|
|
|
|
const inputEl = contentEl.createEl('input', {
|
|
type: 'text',
|
|
placeholder: 'Description (optional)...'
|
|
});
|
|
inputEl.style.width = '100%';
|
|
inputEl.focus();
|
|
|
|
const submitFn = () => {
|
|
this.onSubmit(inputEl.value);
|
|
this.close();
|
|
};
|
|
|
|
inputEl.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') {
|
|
submitFn();
|
|
}
|
|
});
|
|
|
|
const buttonContainer = contentEl.createDiv();
|
|
buttonContainer.style.display = 'flex';
|
|
buttonContainer.style.gap = '10px';
|
|
buttonContainer.style.marginTop = '10px';
|
|
|
|
const skipButton = buttonContainer.createEl('button', { text: 'Skip' });
|
|
skipButton.addEventListener('click', () => {
|
|
this.onSubmit('');
|
|
this.close();
|
|
});
|
|
|
|
const addButton = buttonContainer.createEl('button', {
|
|
text: 'Add Description',
|
|
cls: 'mod-cta'
|
|
});
|
|
addButton.addEventListener('click', submitFn);
|
|
}
|
|
|
|
onClose() {
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
}
|
|
} |