diff --git a/src/commands.ts b/src/commands.ts index c39bf2a..78d906d 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -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(); + } + }, + }); } diff --git a/src/formatter.ts b/src/formatter.ts index 749b268..7f0ed35 100644 --- a/src/formatter.ts +++ b/src/formatter.ts @@ -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"; diff --git a/src/linkedin.ts b/src/linkedin.ts new file mode 100644 index 0000000..5c00054 --- /dev/null +++ b/src/linkedin.ts @@ -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(); +} diff --git a/src/thread-modal.ts b/src/thread-modal.ts new file mode 100644 index 0000000..2aeb9ab --- /dev/null +++ b/src/thread-modal.ts @@ -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(); + } +} diff --git a/src/thread-splitter.ts b/src/thread-splitter.ts new file mode 100644 index 0000000..43e1c76 --- /dev/null +++ b/src/thread-splitter.ts @@ -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): 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; +} diff --git a/styles.css b/styles.css index 71cc60f..bd27393 100644 --- a/styles.css +++ b/styles.css @@ -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%; +}