Add enhanced Markdown to LinkedIn conversion and X thread splitter
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { Plugin } from "obsidian";
|
||||
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) {
|
||||
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");
|
||||
}
|
||||
|
||||
export function applyInlineMarkdown(text: string): string {
|
||||
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();
|
||||
}
|
||||
export { markdownToLinkedIn } from "./linkedin";
|
||||
|
||||
+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
|
||||
available in the app when your plugin is enabled.
|
||||
.unicode-thread-modal .thread-tweet-item {
|
||||
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