feat: publish modal, settings tab, plugin entry
Co-Authored-By: Claude
This commit is contained in:
112
src/PublishModal.ts
Normal file
112
src/PublishModal.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { App, Modal, Setting } from "obsidian";
|
||||||
|
import { Pair, parseNote, resolveFrontmatter } from "./frontmatter";
|
||||||
|
import { Strategy } from "./images";
|
||||||
|
import { JekyllPublishSettings } from "./settings";
|
||||||
|
import { deriveDate, deriveSlug } from "./slug";
|
||||||
|
|
||||||
|
export interface ModalResult {
|
||||||
|
frontmatterPairs: Pair[];
|
||||||
|
slug: string;
|
||||||
|
date: string;
|
||||||
|
strategy: Strategy;
|
||||||
|
commitMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PublishModal extends Modal {
|
||||||
|
private result: ModalResult;
|
||||||
|
private mergeDoc = true;
|
||||||
|
private custom: Pair[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
app: App,
|
||||||
|
private settings: JekyllPublishSettings,
|
||||||
|
private noteText: string,
|
||||||
|
private filename: string,
|
||||||
|
private onSubmit: (r: ModalResult) => void
|
||||||
|
) {
|
||||||
|
super(app);
|
||||||
|
const { frontmatter } = parseNote(noteText);
|
||||||
|
const title = typeof frontmatter.title === "string" ? frontmatter.title : "";
|
||||||
|
this.result = {
|
||||||
|
frontmatterPairs: [],
|
||||||
|
slug: deriveSlug({ title, filename }),
|
||||||
|
date: deriveDate({ frontmatterDate: frontmatter.date, now: new Date() }),
|
||||||
|
strategy: settings.defaultImageStrategy,
|
||||||
|
commitMessage: settings.commitMessageTemplate.replace("{{title}}", title || filename),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private recompute() {
|
||||||
|
const { frontmatter } = parseNote(this.noteText);
|
||||||
|
this.result.frontmatterPairs = resolveFrontmatter(
|
||||||
|
this.settings.presetFrontmatter,
|
||||||
|
frontmatter,
|
||||||
|
this.custom,
|
||||||
|
this.mergeDoc
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen() {
|
||||||
|
this.recompute();
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.createEl("h2", { text: "Publish to Jekyll" });
|
||||||
|
|
||||||
|
new Setting(contentEl).setName("Slug").addText((t) =>
|
||||||
|
t.setValue(this.result.slug).onChange((v) => (this.result.slug = v))
|
||||||
|
);
|
||||||
|
new Setting(contentEl).setName("Date").addText((t) =>
|
||||||
|
t.setValue(this.result.date).onChange((v) => (this.result.date = v))
|
||||||
|
);
|
||||||
|
new Setting(contentEl).setName("Image strategy").addDropdown((d) =>
|
||||||
|
d.addOption("flat-slug", "Flat, renamed to slug")
|
||||||
|
.addOption("per-post-folder", "Per-post subfolder")
|
||||||
|
.setValue(this.result.strategy)
|
||||||
|
.onChange((v) => (this.result.strategy = v as Strategy))
|
||||||
|
);
|
||||||
|
new Setting(contentEl).setName("Merge document frontmatter").addToggle((t) =>
|
||||||
|
t.setValue(this.mergeDoc).onChange((v) => { this.mergeDoc = v; this.renderFrontmatter(); })
|
||||||
|
);
|
||||||
|
|
||||||
|
this.fmEl = contentEl.createDiv();
|
||||||
|
this.renderFrontmatter();
|
||||||
|
|
||||||
|
new Setting(contentEl).addButton((b) =>
|
||||||
|
b.setButtonText("Add property").onClick(() => {
|
||||||
|
this.custom.push({ key: "", value: "" });
|
||||||
|
this.renderFrontmatter();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
new Setting(contentEl).setName("Commit message").addText((t) =>
|
||||||
|
t.setValue(this.result.commitMessage).onChange((v) => (this.result.commitMessage = v))
|
||||||
|
);
|
||||||
|
new Setting(contentEl).addButton((b) =>
|
||||||
|
b.setButtonText("Publish").setCta().onClick(() => {
|
||||||
|
this.recompute();
|
||||||
|
this.close();
|
||||||
|
this.onSubmit(this.result);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fmEl!: HTMLElement;
|
||||||
|
private renderFrontmatter() {
|
||||||
|
this.recompute();
|
||||||
|
this.fmEl.empty();
|
||||||
|
this.fmEl.createEl("h3", { text: "Frontmatter (resolved)" });
|
||||||
|
for (const p of this.result.frontmatterPairs) {
|
||||||
|
this.fmEl.createDiv({ cls: "jekyll-publish-row", text: `${p.key}: ${p.value}` });
|
||||||
|
}
|
||||||
|
if (this.custom.length) {
|
||||||
|
this.fmEl.createEl("h4", { text: "Custom properties" });
|
||||||
|
this.custom.forEach((row, i) => {
|
||||||
|
const div = this.fmEl.createDiv({ cls: "jekyll-publish-row" });
|
||||||
|
const k = div.createEl("input", { value: row.key, placeholder: "key" });
|
||||||
|
const v = div.createEl("input", { value: row.value, placeholder: "value" });
|
||||||
|
k.oninput = () => { this.custom[i].key = k.value; this.recompute(); };
|
||||||
|
v.oninput = () => { this.custom[i].value = v.value; this.recompute(); };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() { this.contentEl.empty(); }
|
||||||
|
}
|
||||||
46
src/SettingsTab.ts
Normal file
46
src/SettingsTab.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { App, PluginSettingTab, Setting } from "obsidian";
|
||||||
|
import type JekyllPublishPlugin from "./main";
|
||||||
|
|
||||||
|
export class JekyllPublishSettingTab extends PluginSettingTab {
|
||||||
|
constructor(app: App, private plugin: JekyllPublishPlugin) {
|
||||||
|
super(app, plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
display(): void {
|
||||||
|
const { containerEl } = this;
|
||||||
|
containerEl.empty();
|
||||||
|
const s = this.plugin.settings;
|
||||||
|
const save = () => this.plugin.saveSettings();
|
||||||
|
|
||||||
|
new Setting(containerEl).setName("Remote URL").setDesc("https:// or ssh:// git URL")
|
||||||
|
.addText((t) => t.setValue(s.remoteUrl).onChange((v) => { s.remoteUrl = v; save(); }));
|
||||||
|
new Setting(containerEl).setName("Branch")
|
||||||
|
.addText((t) => t.setValue(s.branch).onChange((v) => { s.branch = v; save(); }));
|
||||||
|
new Setting(containerEl).setName("Posts directory")
|
||||||
|
.addText((t) => t.setValue(s.postsDir).onChange((v) => { s.postsDir = v; save(); }));
|
||||||
|
new Setting(containerEl).setName("Images directory")
|
||||||
|
.addText((t) => t.setValue(s.imagesDir).onChange((v) => { s.imagesDir = v; save(); }));
|
||||||
|
new Setting(containerEl).setName("Default image strategy")
|
||||||
|
.addDropdown((d) => d.addOption("flat-slug", "Flat, renamed to slug")
|
||||||
|
.addOption("per-post-folder", "Per-post subfolder")
|
||||||
|
.setValue(s.defaultImageStrategy)
|
||||||
|
.onChange((v) => { s.defaultImageStrategy = v as typeof s.defaultImageStrategy; save(); }));
|
||||||
|
new Setting(containerEl).setName("Commit message template").setDesc("{{title}} is substituted")
|
||||||
|
.addText((t) => t.setValue(s.commitMessageTemplate).onChange((v) => { s.commitMessageTemplate = v; save(); }));
|
||||||
|
new Setting(containerEl).setName("Author name (optional)")
|
||||||
|
.addText((t) => t.setValue(s.authorName).onChange((v) => { s.authorName = v; save(); }));
|
||||||
|
new Setting(containerEl).setName("Author email (optional)")
|
||||||
|
.addText((t) => t.setValue(s.authorEmail).onChange((v) => { s.authorEmail = v; save(); }));
|
||||||
|
|
||||||
|
containerEl.createEl("h3", { text: "Preset frontmatter" });
|
||||||
|
s.presetFrontmatter.forEach((row, i) => {
|
||||||
|
new Setting(containerEl)
|
||||||
|
.addText((t) => t.setPlaceholder("key").setValue(row.key).onChange((v) => { s.presetFrontmatter[i].key = v; save(); }))
|
||||||
|
.addText((t) => t.setPlaceholder("value").setValue(row.value).onChange((v) => { s.presetFrontmatter[i].value = v; save(); }))
|
||||||
|
.addExtraButton((b) => b.setIcon("trash").onClick(() => { s.presetFrontmatter.splice(i, 1); save(); this.display(); }));
|
||||||
|
});
|
||||||
|
new Setting(containerEl).addButton((b) =>
|
||||||
|
b.setButtonText("Add preset property").onClick(() => { s.presetFrontmatter.push({ key: "", value: "" }); save(); this.display(); })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/main.ts
Normal file
99
src/main.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { Modal, Notice, Plugin, Setting, TFile, normalizePath } from "obsidian";
|
||||||
|
import { DEFAULT_SETTINGS, JekyllPublishSettings } from "./settings";
|
||||||
|
import { JekyllPublishSettingTab } from "./SettingsTab";
|
||||||
|
import { ModalResult, PublishModal } from "./PublishModal";
|
||||||
|
import { ChildProcessGitClient } from "./git";
|
||||||
|
import { AskpassBridge } from "./askpass";
|
||||||
|
import { publish } from "./publish";
|
||||||
|
|
||||||
|
export default class JekyllPublishPlugin extends Plugin {
|
||||||
|
settings!: JekyllPublishSettings;
|
||||||
|
|
||||||
|
async onload() {
|
||||||
|
await this.loadSettings();
|
||||||
|
this.addSettingTab(new JekyllPublishSettingTab(this.app, this));
|
||||||
|
this.addCommand({
|
||||||
|
id: "publish-current-note",
|
||||||
|
name: "Publish current note to Jekyll",
|
||||||
|
checkCallback: (checking) => {
|
||||||
|
const file = this.app.workspace.getActiveFile();
|
||||||
|
if (!file) return false;
|
||||||
|
if (!checking) void this.openPublishModal(file);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSettings() {
|
||||||
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||||
|
}
|
||||||
|
async saveSettings() {
|
||||||
|
await this.saveData(this.settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openPublishModal(file: TFile) {
|
||||||
|
const noteText = await this.app.vault.read(file);
|
||||||
|
new PublishModal(this.app, this.settings, noteText, file.basename, (r) =>
|
||||||
|
void this.runPublish(noteText, r)
|
||||||
|
).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runPublish(noteText: string, r: ModalResult) {
|
||||||
|
if (!this.settings.remoteUrl.trim()) {
|
||||||
|
new Notice("Jekyll Publish: set a remote URL in settings first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dir = normalizePath(this.app.vault.configDir + "/plugins/jekyll-publish");
|
||||||
|
const bridge = new AskpassBridge({
|
||||||
|
dir: (this.app.vault.adapter as any).getFullPath
|
||||||
|
? (this.app.vault.adapter as any).getFullPath(dir)
|
||||||
|
: dir,
|
||||||
|
onPrompt: (prompt) => this.promptCredential(prompt),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const env = await bridge.start();
|
||||||
|
const git = new ChildProcessGitClient({ env });
|
||||||
|
const result = await publish(
|
||||||
|
{ noteText, ...r },
|
||||||
|
this.settings,
|
||||||
|
git,
|
||||||
|
(linktext) => this.resolveImage(linktext)
|
||||||
|
);
|
||||||
|
const warn = result.unresolved.length ? ` (${result.unresolved.length} image(s) unresolved)` : "";
|
||||||
|
new Notice(`Published ${result.postPath} with ${result.imageCount} image(s)${warn}`);
|
||||||
|
} catch (e) {
|
||||||
|
new Notice(`Publish failed: ${(e as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
bridge.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveImage(linktext: string): Promise<Buffer | null> {
|
||||||
|
const dest = this.app.metadataCache.getFirstLinkpathDest(linktext, "");
|
||||||
|
if (!dest) return null;
|
||||||
|
const ab = await this.app.vault.readBinary(dest);
|
||||||
|
return Buffer.from(ab);
|
||||||
|
}
|
||||||
|
|
||||||
|
private promptCredential(prompt: string): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const modal = new Modal(this.app);
|
||||||
|
modal.titleEl.setText("Git credentials");
|
||||||
|
modal.contentEl.createEl("p", { text: prompt });
|
||||||
|
let value = "";
|
||||||
|
const masked = /pass|secret|token/i.test(prompt);
|
||||||
|
new Setting(modal.contentEl).addText((t) => {
|
||||||
|
if (masked) t.inputEl.type = "password";
|
||||||
|
t.onChange((v) => (value = v));
|
||||||
|
t.inputEl.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") { modal.close(); resolve(value); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
new Setting(modal.contentEl).addButton((b) =>
|
||||||
|
b.setButtonText("OK").setCta().onClick(() => { modal.close(); resolve(value); })
|
||||||
|
);
|
||||||
|
modal.onClose = () => resolve(value);
|
||||||
|
modal.open();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user