Add enhanced Markdown to LinkedIn conversion and X thread splitter
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
import { Plugin } from "obsidian";
|
import { Plugin } from "obsidian";
|
||||||
import { transformText, cleanText, bulletToEmdash, bulletToArrow, numberedListSlash, numberedListParens, markdownToLinkedIn, FormatStyle } from "./formatter";
|
import { transformText, cleanText, bulletToEmdash, bulletToArrow, numberedListSlash, numberedListParens, markdownToLinkedIn, FormatStyle } from "./formatter";
|
||||||
|
import { splitIntoThreads } from "./thread-splitter";
|
||||||
|
import { ThreadModal } from "./thread-modal";
|
||||||
|
|
||||||
function addFormatCommand(plugin: Plugin, style: FormatStyle, name: string) {
|
function addFormatCommand(plugin: Plugin, style: FormatStyle, name: string) {
|
||||||
plugin.addCommand({
|
plugin.addCommand({
|
||||||
@@ -78,4 +80,15 @@ export function registerCommands(plugin: Plugin): void {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
plugin.addCommand({
|
||||||
|
id: "unicode-formatter:split-thread",
|
||||||
|
name: "Split into X thread",
|
||||||
|
editorCallback: (editor) => {
|
||||||
|
const selection = editor.getSelection();
|
||||||
|
if (selection) {
|
||||||
|
const tweets = splitIntoThreads(selection);
|
||||||
|
new ThreadModal(plugin.app, tweets).open();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-50
@@ -75,53 +75,4 @@ export function numberedListParens(text: string): string {
|
|||||||
}).join("\n");
|
}).join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyInlineMarkdown(text: string): string {
|
export { markdownToLinkedIn } from "./linkedin";
|
||||||
return text.replace(/\*\*(.+?)\*\*|_(.+?)_/g, (_match, bold: string | undefined, italic: string | undefined) => {
|
|
||||||
if (bold !== undefined) return transformText(bold, "bold");
|
|
||||||
return transformText(italic!, "italic");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function markdownToLinkedIn(text: string): string {
|
|
||||||
const lines = text.split("\n");
|
|
||||||
const output: string[] = [];
|
|
||||||
let pendingBlanks = 0;
|
|
||||||
let skipNextBlanks = false;
|
|
||||||
let inListContext = false;
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.trim() === "") {
|
|
||||||
if (!skipNextBlanks) pendingBlanks++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
skipNextBlanks = false;
|
|
||||||
|
|
||||||
if (line.startsWith("## ")) {
|
|
||||||
for (let i = 0; i < pendingBlanks; i++) output.push("");
|
|
||||||
pendingBlanks = 0;
|
|
||||||
inListContext = false;
|
|
||||||
const headingText = line.slice(3);
|
|
||||||
output.push(transformText(applyInlineMarkdown(headingText), "bold") + " ");
|
|
||||||
skipNextBlanks = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (line.startsWith("- ") || line.startsWith("* ")) {
|
|
||||||
if (!inListContext) {
|
|
||||||
for (let i = 0; i < pendingBlanks; i++) output.push("");
|
|
||||||
}
|
|
||||||
pendingBlanks = 0;
|
|
||||||
output.push("— " + applyInlineMarkdown(line.slice(2)) + " ");
|
|
||||||
inListContext = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < pendingBlanks; i++) output.push("");
|
|
||||||
pendingBlanks = 0;
|
|
||||||
inListContext = false;
|
|
||||||
output.push(applyInlineMarkdown(line));
|
|
||||||
}
|
|
||||||
|
|
||||||
return output.join("\n").replace(/[ \u00A0]\./g, ".").trimEnd();
|
|
||||||
}
|
|
||||||
|
|||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
import { transformText } from "./formatter";
|
||||||
|
|
||||||
|
function stripUnsupportedMarkdown(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/!\[[^\]]*\]\([^)]+\)/g, "")
|
||||||
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)")
|
||||||
|
.replace(/~~(.+?)~~/g, "$1")
|
||||||
|
.replace(/`(.+?)`/g, "$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyInlineMarkdown(text: string): string {
|
||||||
|
return text.replace(
|
||||||
|
/\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|_(.+?)_/g,
|
||||||
|
(_match: string, boldItalic: string | undefined, bold: string | undefined, italic: string | undefined) => {
|
||||||
|
if (boldItalic !== undefined) return transformText(boldItalic, "bold-italic");
|
||||||
|
if (bold !== undefined) return transformText(bold, "bold");
|
||||||
|
return transformText(italic!, "italic");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markdownToLinkedIn(text: string): string {
|
||||||
|
const processed = stripUnsupportedMarkdown(text);
|
||||||
|
const lines = processed.split("\n");
|
||||||
|
const output: string[] = [];
|
||||||
|
let pendingBlanks = 0;
|
||||||
|
let skipNextBlanks = false;
|
||||||
|
let inListContext = false;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim() === "") {
|
||||||
|
if (!skipNextBlanks) pendingBlanks++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
skipNextBlanks = false;
|
||||||
|
|
||||||
|
if (line.startsWith("# ")) {
|
||||||
|
for (let i = 0; i < pendingBlanks; i++) output.push("");
|
||||||
|
pendingBlanks = 0;
|
||||||
|
inListContext = false;
|
||||||
|
const headingText = line.slice(2).toUpperCase();
|
||||||
|
output.push(transformText(applyInlineMarkdown(headingText), "bold") + " ");
|
||||||
|
skipNextBlanks = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith("## ")) {
|
||||||
|
for (let i = 0; i < pendingBlanks; i++) output.push("");
|
||||||
|
pendingBlanks = 0;
|
||||||
|
inListContext = false;
|
||||||
|
const headingText = line.slice(3);
|
||||||
|
output.push(transformText(applyInlineMarkdown(headingText), "bold") + " ");
|
||||||
|
skipNextBlanks = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hMatch = line.match(/^#{3,6}\s/);
|
||||||
|
if (hMatch) {
|
||||||
|
for (let i = 0; i < pendingBlanks; i++) output.push("");
|
||||||
|
pendingBlanks = 0;
|
||||||
|
inListContext = false;
|
||||||
|
const headingText = line.slice(hMatch[0].length);
|
||||||
|
output.push(transformText(applyInlineMarkdown(headingText), "bold") + " ");
|
||||||
|
skipNextBlanks = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith("> ")) {
|
||||||
|
for (let i = 0; i < pendingBlanks; i++) output.push("");
|
||||||
|
pendingBlanks = 0;
|
||||||
|
inListContext = false;
|
||||||
|
output.push(transformText(applyInlineMarkdown(line.slice(2)), "italic"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith("- ") || line.startsWith("* ")) {
|
||||||
|
if (!inListContext) {
|
||||||
|
for (let i = 0; i < pendingBlanks; i++) output.push("");
|
||||||
|
}
|
||||||
|
pendingBlanks = 0;
|
||||||
|
output.push("— " + applyInlineMarkdown(line.slice(2)) + " ");
|
||||||
|
inListContext = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedMatch = line.match(/^(\d+)[.)]\s+(.+)/);
|
||||||
|
if (orderedMatch) {
|
||||||
|
if (!inListContext) {
|
||||||
|
for (let i = 0; i < pendingBlanks; i++) output.push("");
|
||||||
|
}
|
||||||
|
pendingBlanks = 0;
|
||||||
|
const num = orderedMatch[1]!;
|
||||||
|
const content = orderedMatch[2]!;
|
||||||
|
output.push(`${num}/ ${applyInlineMarkdown(content)} `);
|
||||||
|
inListContext = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^-{3,}$/.test(line.trim()) || /^\*{3,}$/.test(line.trim())) {
|
||||||
|
for (let i = 0; i < pendingBlanks; i++) output.push("");
|
||||||
|
pendingBlanks = 0;
|
||||||
|
inListContext = false;
|
||||||
|
output.push("");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < pendingBlanks; i++) output.push("");
|
||||||
|
pendingBlanks = 0;
|
||||||
|
inListContext = false;
|
||||||
|
output.push(applyInlineMarkdown(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.join("\n").replace(/[ \u00A0]\./g, ".").trimEnd();
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { App, Modal } from "obsidian";
|
||||||
|
|
||||||
|
export class ThreadModal extends Modal {
|
||||||
|
private tweets: string[];
|
||||||
|
|
||||||
|
constructor(app: App, tweets: string[]) {
|
||||||
|
super(app);
|
||||||
|
this.tweets = tweets;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen(): void {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
contentEl.addClass("unicode-thread-modal");
|
||||||
|
|
||||||
|
const headingText = this.tweets.length === 1
|
||||||
|
? "X post"
|
||||||
|
: `X thread (${this.tweets.length} posts)`;
|
||||||
|
contentEl.createEl("h3", { text: headingText });
|
||||||
|
|
||||||
|
const list = contentEl.createDiv({ cls: "thread-tweet-list" });
|
||||||
|
|
||||||
|
for (let i = 0; i < this.tweets.length; i++) {
|
||||||
|
const tweet = this.tweets[i]!;
|
||||||
|
const item = list.createDiv({ cls: "thread-tweet-item" });
|
||||||
|
|
||||||
|
const header = item.createDiv({ cls: "thread-tweet-header" });
|
||||||
|
if (this.tweets.length > 1) {
|
||||||
|
header.createSpan({
|
||||||
|
text: `Post ${i + 1}/${this.tweets.length}`,
|
||||||
|
cls: "thread-tweet-label",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
item.createEl("textarea", {
|
||||||
|
text: tweet,
|
||||||
|
attr: { readonly: "true" },
|
||||||
|
cls: "thread-tweet-textarea",
|
||||||
|
});
|
||||||
|
|
||||||
|
const copyBtn = item.createEl("button", {
|
||||||
|
text: "Copy",
|
||||||
|
cls: "thread-copy-button",
|
||||||
|
});
|
||||||
|
copyBtn.addEventListener("click", () => {
|
||||||
|
navigator.clipboard.writeText(tweet).then(() => {
|
||||||
|
copyBtn.setText("Copied");
|
||||||
|
setTimeout(() => copyBtn.setText("Copy"), 2000);
|
||||||
|
}).catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.tweets.length > 1) {
|
||||||
|
const copyAllBtn = contentEl.createEl("button", {
|
||||||
|
text: "Copy all",
|
||||||
|
cls: "thread-copy-all-button",
|
||||||
|
});
|
||||||
|
copyAllBtn.addEventListener("click", () => {
|
||||||
|
const all = this.tweets.join("\n\n");
|
||||||
|
navigator.clipboard.writeText(all).then(() => {
|
||||||
|
copyAllBtn.setText("Copied");
|
||||||
|
setTimeout(() => copyAllBtn.setText("Copy all"), 2000);
|
||||||
|
}).catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(): void {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
export interface ThreadOptions {
|
||||||
|
maxChars: number;
|
||||||
|
addNumbering: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS: ThreadOptions = {
|
||||||
|
maxChars: 280,
|
||||||
|
addNumbering: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const NUMBERING_RESERVE = 8;
|
||||||
|
|
||||||
|
function countChars(text: string): number {
|
||||||
|
let count = 0;
|
||||||
|
let lastIndex = 0;
|
||||||
|
const urlRe = /https?:\/\/\S+/g;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = urlRe.exec(text)) !== null) {
|
||||||
|
count += match.index - lastIndex;
|
||||||
|
count += 23;
|
||||||
|
lastIndex = urlRe.lastIndex;
|
||||||
|
}
|
||||||
|
count += text.length - lastIndex;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textFits(text: string, max: number): boolean {
|
||||||
|
return countChars(text) <= max;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitByWords(text: string, max: number): string[] {
|
||||||
|
const words = text.split(/\s+/);
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
|
||||||
|
for (const word of words) {
|
||||||
|
if (!current) {
|
||||||
|
current = word;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const candidate = current + " " + word;
|
||||||
|
if (countChars(candidate) <= max - 3) {
|
||||||
|
current = candidate;
|
||||||
|
} else {
|
||||||
|
chunks.push(current + "...");
|
||||||
|
current = word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current) chunks.push(current);
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitBySentences(text: string, max: number): string[] {
|
||||||
|
const sentences = text.match(/[^.!?\s][^.!?]*(?:[.!?]+|$)/g) || [text];
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
|
||||||
|
for (const sentence of sentences) {
|
||||||
|
if (!current) {
|
||||||
|
if (textFits(sentence, max)) {
|
||||||
|
current = sentence;
|
||||||
|
} else {
|
||||||
|
chunks.push(...splitByWords(sentence, max));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const candidate = current + sentence;
|
||||||
|
if (textFits(candidate, max)) {
|
||||||
|
current = candidate;
|
||||||
|
} else {
|
||||||
|
chunks.push(current);
|
||||||
|
if (textFits(sentence, max)) {
|
||||||
|
current = sentence;
|
||||||
|
} else {
|
||||||
|
chunks.push(...splitByWords(sentence, max));
|
||||||
|
current = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current) chunks.push(current);
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitBlock(text: string, max: number): string[] {
|
||||||
|
const lines = text.split("\n").filter(l => l.trim());
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!current) {
|
||||||
|
if (textFits(trimmed, max)) {
|
||||||
|
current = trimmed;
|
||||||
|
} else {
|
||||||
|
chunks.push(...splitBySentences(trimmed, max));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const candidate = current + "\n" + trimmed;
|
||||||
|
if (textFits(candidate, max)) {
|
||||||
|
current = candidate;
|
||||||
|
} else {
|
||||||
|
chunks.push(current);
|
||||||
|
if (textFits(trimmed, max)) {
|
||||||
|
current = trimmed;
|
||||||
|
} else {
|
||||||
|
chunks.push(...splitBySentences(trimmed, max));
|
||||||
|
current = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current) chunks.push(current);
|
||||||
|
return chunks.length > 0 ? chunks : [text];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitIntoThreads(text: string, options?: Partial<ThreadOptions>): string[] {
|
||||||
|
const opts: ThreadOptions = Object.assign({}, DEFAULT_OPTIONS, options);
|
||||||
|
const effectiveMax = opts.addNumbering ? opts.maxChars - NUMBERING_RESERVE : opts.maxChars;
|
||||||
|
const paragraphs = text.split(/\n{2,}/).filter(p => p.trim());
|
||||||
|
|
||||||
|
if (paragraphs.length === 0) return [];
|
||||||
|
|
||||||
|
const rawTweets: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
|
||||||
|
for (const para of paragraphs) {
|
||||||
|
const trimmed = para.trim();
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
if (textFits(trimmed, effectiveMax)) {
|
||||||
|
current = trimmed;
|
||||||
|
} else {
|
||||||
|
const chunks = splitBlock(trimmed, effectiveMax);
|
||||||
|
if (chunks.length > 1) {
|
||||||
|
rawTweets.push(...chunks.slice(0, -1));
|
||||||
|
current = chunks[chunks.length - 1]!;
|
||||||
|
} else {
|
||||||
|
current = chunks[0]!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const candidate = current + "\n\n" + trimmed;
|
||||||
|
if (textFits(candidate, effectiveMax)) {
|
||||||
|
current = candidate;
|
||||||
|
} else {
|
||||||
|
rawTweets.push(current);
|
||||||
|
if (textFits(trimmed, effectiveMax)) {
|
||||||
|
current = trimmed;
|
||||||
|
} else {
|
||||||
|
const chunks = splitBlock(trimmed, effectiveMax);
|
||||||
|
if (chunks.length > 1) {
|
||||||
|
rawTweets.push(...chunks.slice(0, -1));
|
||||||
|
current = chunks[chunks.length - 1]!;
|
||||||
|
} else {
|
||||||
|
current = chunks[0]!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current) rawTweets.push(current);
|
||||||
|
|
||||||
|
if (opts.addNumbering && rawTweets.length > 1) {
|
||||||
|
const total = rawTweets.length;
|
||||||
|
return rawTweets.map((tweet, i) => `${i + 1}/${total} ${tweet}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawTweets;
|
||||||
|
}
|
||||||
+42
-5
@@ -1,8 +1,45 @@
|
|||||||
/*
|
.unicode-thread-modal .thread-tweet-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
This CSS file will be included with your plugin, and
|
.unicode-thread-modal .thread-tweet-item {
|
||||||
available in the app when your plugin is enabled.
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
If your plugin does not need CSS, delete this file.
|
.unicode-thread-modal .thread-tweet-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
*/
|
.unicode-thread-modal .thread-tweet-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unicode-thread-modal .thread-tweet-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 60px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: var(--font-text);
|
||||||
|
font-size: var(--font-text-size);
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--background-secondary);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unicode-thread-modal .thread-copy-button {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unicode-thread-modal .thread-copy-all-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user