From 85d20b896a3d8cc59d5c711d4192690f59c05185 Mon Sep 17 00:00:00 2001 From: Heiko Joerg Schick Date: Wed, 29 Apr 2026 22:17:21 +0200 Subject: [PATCH 01/10] Fix plugin ID, package metadata, and formatter consistency issues - Correct plugin ID in manifest.json to match folder name (obsidian-unicode-formatter) - Update package.json name and description from sample template defaults - Make markdownToLinkedIn handle both '- ' and '* ' list markers - Enhance cleanText to also strip em-dash, arrow, and numbered-list prefixes --- manifest.json | 2 +- package.json | 6 +++--- src/formatter.ts | 11 +++++++++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/manifest.json b/manifest.json index 16b58df..71c0b96 100644 --- a/manifest.json +++ b/manifest.json @@ -1,5 +1,5 @@ { - "id": "unicode-text-formatter", + "id": "obsidian-unicode-formatter", "name": "Unicode Text Formatter", "version": "1.0.0", "minAppVersion": "0.15.0", diff --git a/package.json b/package.json index 17268d7..1f0cbab 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "obsidian-sample-plugin", - "version": "1.0.0", - "description": "This is a sample plugin for Obsidian (https://obsidian.md)", +"name": "obsidian-unicode-formatter", +"version": "1.0.0", +"description": "Format selected text as Unicode Sans-Serif Bold, Italic, or Bold-Italic for LinkedIn and Facebook posts.", "main": "main.js", "type": "module", "scripts": { diff --git a/src/formatter.ts b/src/formatter.ts index abb2198..c57a4cb 100644 --- a/src/formatter.ts +++ b/src/formatter.ts @@ -14,7 +14,14 @@ export function transformText(text: string, style: FormatStyle): string { } export function cleanText(text: string): string { - return [...text].map(ch => UNICODE_TO_ASCII_MAP.get(ch) ?? ch).join(""); + return text.split("\n").map(line => { + if (line.startsWith("— ")) line = line.slice(2); + else if (line.startsWith("→ ")) line = line.slice(2); + + line = line.replace(/^\(\d+\) /, "").replace(/^\d+\/ /, ""); + + return [...line].map(ch => UNICODE_TO_ASCII_MAP.get(ch) ?? ch).join(""); + }).join("\n"); } export function bulletToEmdash(text: string): string { @@ -79,7 +86,7 @@ export function markdownToLinkedIn(text: string): string { continue; } - if (line.startsWith("- ")) { + if (line.startsWith("- ") || line.startsWith("* ")) { if (!inListContext) { for (let i = 0; i < pendingBlanks; i++) output.push(""); } From e8e5760f99ad5ab3feed70f16fa05334e3c56ada Mon Sep 17 00:00:00 2001 From: Heiko Joerg Schick Date: Wed, 29 Apr 2026 22:20:49 +0200 Subject: [PATCH 02/10] Add editorCheckCallback to all commands and remove empty onunload - Add editorCheckCallback to hide commands when no Markdown editor is active - Remove no-op onunload override (base Plugin class provides default) --- src/commands.ts | 13 ++++++++++++- src/main.ts | 3 --- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 2cb90db..2ad0ea8 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,10 +1,15 @@ -import { Plugin } from "obsidian"; +import { Plugin, MarkdownView } from "obsidian"; import { transformText, cleanText, bulletToEmdash, bulletToArrow, numberedListSlash, numberedListParens, markdownToLinkedIn, FormatStyle } from "./formatter"; +function editorCheck(plugin: Plugin) { + return (_checking: boolean) => plugin.app.workspace.getActiveViewOfType(MarkdownView) !== null; +} + function addFormatCommand(plugin: Plugin, style: FormatStyle, name: string) { plugin.addCommand({ id: `unicode-formatter:${style}`, name, + editorCheckCallback: editorCheck(plugin), editorCallback: (editor) => { const selection = editor.getSelection(); if (selection) { @@ -21,6 +26,7 @@ export function registerCommands(plugin: Plugin): void { plugin.addCommand({ id: "unicode-formatter:clean", name: "Remove Unicode Formatting", + editorCheckCallback: editorCheck(plugin), editorCallback: (editor) => { const selection = editor.getSelection(); if (selection) { @@ -31,6 +37,7 @@ export function registerCommands(plugin: Plugin): void { plugin.addCommand({ id: "unicode-formatter:bullet-to-arrow", name: "Convert List Bullets to Arrow", + editorCheckCallback: editorCheck(plugin), editorCallback: (editor) => { const selection = editor.getSelection(); if (selection) { @@ -41,6 +48,7 @@ export function registerCommands(plugin: Plugin): void { plugin.addCommand({ id: "unicode-formatter:bullet-to-emdash", name: "Convert List Bullets to Em Dash", + editorCheckCallback: editorCheck(plugin), editorCallback: (editor) => { const selection = editor.getSelection(); if (selection) { @@ -51,6 +59,7 @@ export function registerCommands(plugin: Plugin): void { plugin.addCommand({ id: "unicode-formatter:numbered-list-slash", name: "Convert List to Numbered (Slash)", + editorCheckCallback: editorCheck(plugin), editorCallback: (editor) => { const selection = editor.getSelection(); if (selection) { @@ -61,6 +70,7 @@ export function registerCommands(plugin: Plugin): void { plugin.addCommand({ id: "unicode-formatter:numbered-list-parens", name: "Convert List to Numbered (Parentheses)", + editorCheckCallback: editorCheck(plugin), editorCallback: (editor) => { const selection = editor.getSelection(); if (selection) { @@ -71,6 +81,7 @@ export function registerCommands(plugin: Plugin): void { plugin.addCommand({ id: "unicode-formatter:markdown-to-linkedin", name: "Convert Markdown to Post Format", + editorCheckCallback: editorCheck(plugin), editorCallback: (editor) => { const selection = editor.getSelection(); if (selection) { diff --git a/src/main.ts b/src/main.ts index 5aa22f8..5625545 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,7 +5,4 @@ export default class UnicodeFormatterPlugin extends Plugin { async onload() { registerCommands(this); } - - onunload() { - } } From b824ad5ed5750074eab53978555841a9292cde89 Mon Sep 17 00:00:00 2001 From: Heiko Joerg Schick Date: Wed, 29 Apr 2026 22:21:33 +0200 Subject: [PATCH 03/10] 1.0.1 --- manifest.json | 4 ++-- package-lock.json | 4 ++-- package.json | 30 +----------------------------- 3 files changed, 5 insertions(+), 33 deletions(-) diff --git a/manifest.json b/manifest.json index 71c0b96..054143d 100644 --- a/manifest.json +++ b/manifest.json @@ -1,11 +1,11 @@ { "id": "obsidian-unicode-formatter", "name": "Unicode Text Formatter", - "version": "1.0.0", + "version": "1.0.1", "minAppVersion": "0.15.0", "description": "Format selected text as Unicode Sans-Serif Bold, Italic, or Bold-Italic for LinkedIn and Facebook posts.", "author": "schihei", "authorUrl": "", "fundingUrl": "", "isDesktopOnly": false -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d0dac39..7523703 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-sample-plugin", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-sample-plugin", - "version": "1.0.0", + "version": "1.0.1", "license": "0-BSD", "dependencies": { "obsidian": "latest" diff --git a/package.json b/package.json index 1f0cbab..d91b8d4 100644 --- a/package.json +++ b/package.json @@ -1,29 +1 @@ -{ -"name": "obsidian-unicode-formatter", -"version": "1.0.0", -"description": "Format selected text as Unicode Sans-Serif Bold, Italic, or Bold-Italic for LinkedIn and Facebook posts.", - "main": "main.js", - "type": "module", - "scripts": { - "dev": "node esbuild.config.mjs", - "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", - "version": "node version-bump.mjs && git add manifest.json versions.json", - "lint": "eslint ." - }, - "keywords": [], - "license": "0-BSD", - "devDependencies": { - "@types/node": "^16.11.6", - "esbuild": "0.25.5", - "eslint-plugin-obsidianmd": "0.1.9", - "globals": "14.0.0", - "tslib": "2.4.0", - "typescript": "^5.8.3", - "typescript-eslint": "8.35.1", - "@eslint/js": "9.30.1", - "jiti": "2.6.1" - }, - "dependencies": { - "obsidian": "latest" - } -} +{"name":"obsidian-unicode-formatter","version":"1.0.1","description":"Format selected text as Unicode Sans-Serif Bold, Italic, or Bold-Italic for LinkedIn and Facebook posts.","main":"main.js","type":"module","scripts":{"dev":"node esbuild.config.mjs","build":"tsc -noEmit -skipLibCheck && node esbuild.config.mjs production","version":"node version-bump.mjs && git add manifest.json versions.json","lint":"eslint ."},"keywords":[],"license":"0-BSD","devDependencies":{"@types/node":"^16.11.6","esbuild":"0.25.5","eslint-plugin-obsidianmd":"0.1.9","globals":"14.0.0","tslib":"2.4.0","typescript":"^5.8.3","typescript-eslint":"8.35.1","@eslint/js":"9.30.1","jiti":"2.6.1"},"dependencies":{"obsidian":"latest"}} From e5c3417a833665258a69f705a9395f5f2d28c0dc Mon Sep 17 00:00:00 2001 From: Heiko Joerg Schick Date: Wed, 29 Apr 2026 22:41:25 +0200 Subject: [PATCH 04/10] =?UTF-8?q?Revert=20editorCheckCallback=20=E2=80=94?= =?UTF-8?q?=20broke=20command=20palette=20visibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove editorCheckCallback from all 8 commands - Remove MarkdownView import and editorCheck helper function - fixup e8e5760 --- src/commands.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 2ad0ea8..2cb90db 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,15 +1,10 @@ -import { Plugin, MarkdownView } from "obsidian"; +import { Plugin } from "obsidian"; import { transformText, cleanText, bulletToEmdash, bulletToArrow, numberedListSlash, numberedListParens, markdownToLinkedIn, FormatStyle } from "./formatter"; -function editorCheck(plugin: Plugin) { - return (_checking: boolean) => plugin.app.workspace.getActiveViewOfType(MarkdownView) !== null; -} - function addFormatCommand(plugin: Plugin, style: FormatStyle, name: string) { plugin.addCommand({ id: `unicode-formatter:${style}`, name, - editorCheckCallback: editorCheck(plugin), editorCallback: (editor) => { const selection = editor.getSelection(); if (selection) { @@ -26,7 +21,6 @@ export function registerCommands(plugin: Plugin): void { plugin.addCommand({ id: "unicode-formatter:clean", name: "Remove Unicode Formatting", - editorCheckCallback: editorCheck(plugin), editorCallback: (editor) => { const selection = editor.getSelection(); if (selection) { @@ -37,7 +31,6 @@ export function registerCommands(plugin: Plugin): void { plugin.addCommand({ id: "unicode-formatter:bullet-to-arrow", name: "Convert List Bullets to Arrow", - editorCheckCallback: editorCheck(plugin), editorCallback: (editor) => { const selection = editor.getSelection(); if (selection) { @@ -48,7 +41,6 @@ export function registerCommands(plugin: Plugin): void { plugin.addCommand({ id: "unicode-formatter:bullet-to-emdash", name: "Convert List Bullets to Em Dash", - editorCheckCallback: editorCheck(plugin), editorCallback: (editor) => { const selection = editor.getSelection(); if (selection) { @@ -59,7 +51,6 @@ export function registerCommands(plugin: Plugin): void { plugin.addCommand({ id: "unicode-formatter:numbered-list-slash", name: "Convert List to Numbered (Slash)", - editorCheckCallback: editorCheck(plugin), editorCallback: (editor) => { const selection = editor.getSelection(); if (selection) { @@ -70,7 +61,6 @@ export function registerCommands(plugin: Plugin): void { plugin.addCommand({ id: "unicode-formatter:numbered-list-parens", name: "Convert List to Numbered (Parentheses)", - editorCheckCallback: editorCheck(plugin), editorCallback: (editor) => { const selection = editor.getSelection(); if (selection) { @@ -81,7 +71,6 @@ export function registerCommands(plugin: Plugin): void { plugin.addCommand({ id: "unicode-formatter:markdown-to-linkedin", name: "Convert Markdown to Post Format", - editorCheckCallback: editorCheck(plugin), editorCallback: (editor) => { const selection = editor.getSelection(); if (selection) { From 783278058b42b3876336180ff2742bd708b0da91 Mon Sep 17 00:00:00 2001 From: Heiko Joerg Schick Date: Wed, 29 Apr 2026 22:51:48 +0200 Subject: [PATCH 05/10] Revert Unicode formatted text back to Markdown syntax in cleanText - Add UNICODE_TO_STYLE reverse map to classify Unicode chars by font style - Rewrite cleanText to group same-style runs and wrap in Markdown (**bold**, _italic_, **_bold-italic_**) --- src/formatter.ts | 25 +++++++++++++++++++++++-- src/unicode-maps.ts | 9 +++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/formatter.ts b/src/formatter.ts index c57a4cb..aca768d 100644 --- a/src/formatter.ts +++ b/src/formatter.ts @@ -1,4 +1,4 @@ -import { BOLD_MAP, ITALIC_MAP, BOLD_ITALIC_MAP, UNICODE_TO_ASCII_MAP } from "./unicode-maps"; +import { BOLD_MAP, ITALIC_MAP, BOLD_ITALIC_MAP, UNICODE_TO_ASCII_MAP, UNICODE_TO_STYLE } from "./unicode-maps"; export type FormatStyle = "bold" | "italic" | "bold-italic"; @@ -20,7 +20,28 @@ export function cleanText(text: string): string { line = line.replace(/^\(\d+\) /, "").replace(/^\d+\/ /, ""); - return [...line].map(ch => UNICODE_TO_ASCII_MAP.get(ch) ?? ch).join(""); + const output: string[] = []; + let currentStyle: string | null = null; + let buf: string[] = []; + + const flush = () => { + if (buf.length === 0) return; + const s = buf.join(""); + if (currentStyle === "bold") output.push(`**${s}**`); + else if (currentStyle === "italic") output.push(`_${s}_`); + else if (currentStyle === "bold-italic") output.push(`**_${s}_**`); + else output.push(s); + buf = []; + }; + + for (const ch of line) { + const style = UNICODE_TO_STYLE.get(ch) ?? null; + const ascii = UNICODE_TO_ASCII_MAP.get(ch) ?? ch; + if (style !== currentStyle) { flush(); currentStyle = style; } + buf.push(ascii); + } + flush(); + return output.join(""); }).join("\n"); } diff --git a/src/unicode-maps.ts b/src/unicode-maps.ts index 3d973db..e09568c 100644 --- a/src/unicode-maps.ts +++ b/src/unicode-maps.ts @@ -31,3 +31,12 @@ export const UNICODE_TO_ASCII_MAP: Map = new Map( Object.entries(m).map(([ascii, unicode]) => [unicode, ascii] as [string, string]) ) ); + +export type FontStyle = "bold" | "italic" | "bold-italic"; + +// Reverse map: Unicode symbol → font style +export const UNICODE_TO_STYLE: Map = new Map([ + ...Object.values(BOLD_MAP).map(ch => [ch, "bold"] as [string, FontStyle]), + ...Object.values(ITALIC_MAP).map(ch => [ch, "italic"] as [string, FontStyle]), + ...Object.values(BOLD_ITALIC_MAP).map(ch => [ch, "bold-italic"] as [string, FontStyle]), +]); From a7db8ef418e189226292e66d45bec9d5c480f7d3 Mon Sep 17 00:00:00 2001 From: Heiko Joerg Schick Date: Wed, 29 Apr 2026 23:01:08 +0200 Subject: [PATCH 06/10] Fix line case in command names, type-safety in applyInlineMarkdown, and versions.json --- src/commands.ts | 18 +++++++++--------- src/formatter.ts | 4 ++-- versions.json | 3 ++- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 2cb90db..c39bf2a 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -15,12 +15,12 @@ function addFormatCommand(plugin: Plugin, style: FormatStyle, name: string) { } export function registerCommands(plugin: Plugin): void { - addFormatCommand(plugin, "bold", "Format as Unicode Bold"); - addFormatCommand(plugin, "italic", "Format as Unicode Italic"); - addFormatCommand(plugin, "bold-italic", "Format as Unicode Bold Italic"); + addFormatCommand(plugin, "bold", "Format as unicode bold"); + addFormatCommand(plugin, "italic", "Format as unicode italic"); + addFormatCommand(plugin, "bold-italic", "Format as unicode bold italic"); plugin.addCommand({ id: "unicode-formatter:clean", - name: "Remove Unicode Formatting", + name: "Remove unicode formatting", editorCallback: (editor) => { const selection = editor.getSelection(); if (selection) { @@ -30,7 +30,7 @@ export function registerCommands(plugin: Plugin): void { }); plugin.addCommand({ id: "unicode-formatter:bullet-to-arrow", - name: "Convert List Bullets to Arrow", + name: "Convert list bullets to arrow", editorCallback: (editor) => { const selection = editor.getSelection(); if (selection) { @@ -40,7 +40,7 @@ export function registerCommands(plugin: Plugin): void { }); plugin.addCommand({ id: "unicode-formatter:bullet-to-emdash", - name: "Convert List Bullets to Em Dash", + name: "Convert list bullets to em dash", editorCallback: (editor) => { const selection = editor.getSelection(); if (selection) { @@ -50,7 +50,7 @@ export function registerCommands(plugin: Plugin): void { }); plugin.addCommand({ id: "unicode-formatter:numbered-list-slash", - name: "Convert List to Numbered (Slash)", + name: "Convert list to numbered (slash)", editorCallback: (editor) => { const selection = editor.getSelection(); if (selection) { @@ -60,7 +60,7 @@ export function registerCommands(plugin: Plugin): void { }); plugin.addCommand({ id: "unicode-formatter:numbered-list-parens", - name: "Convert List to Numbered (Parentheses)", + name: "Convert list to numbered (parentheses)", editorCallback: (editor) => { const selection = editor.getSelection(); if (selection) { @@ -70,7 +70,7 @@ export function registerCommands(plugin: Plugin): void { }); plugin.addCommand({ id: "unicode-formatter:markdown-to-linkedin", - name: "Convert Markdown to Post Format", + name: "Convert Markdown to post format", editorCallback: (editor) => { const selection = editor.getSelection(); if (selection) { diff --git a/src/formatter.ts b/src/formatter.ts index aca768d..749b268 100644 --- a/src/formatter.ts +++ b/src/formatter.ts @@ -76,9 +76,9 @@ export function numberedListParens(text: string): string { } export function applyInlineMarkdown(text: string): string { - return text.replace(/\*\*(.+?)\*\*|_(.+?)_/g, (_match, bold, italic) => { + return text.replace(/\*\*(.+?)\*\*|_(.+?)_/g, (_match, bold: string | undefined, italic: string | undefined) => { if (bold !== undefined) return transformText(bold, "bold"); - return transformText(italic, "italic"); + return transformText(italic!, "italic"); }); } diff --git a/versions.json b/versions.json index 26382a1..189c17e 100644 --- a/versions.json +++ b/versions.json @@ -1,3 +1,4 @@ { - "1.0.0": "0.15.0" + "1.0.0": "0.15.0", + "1.0.1": "0.15.0" } From 834451f33b1d8acb94ab619c62ee8e0bda4546af Mon Sep 17 00:00:00 2001 From: Heiko Joerg Schick Date: Wed, 29 Apr 2026 23:28:28 +0200 Subject: [PATCH 07/10] Add enhanced Markdown to LinkedIn conversion and X thread splitter --- src/commands.ts | 13 +++ src/formatter.ts | 51 +----------- src/linkedin.ts | 115 +++++++++++++++++++++++++++ src/thread-modal.ts | 72 +++++++++++++++++ src/thread-splitter.ts | 174 +++++++++++++++++++++++++++++++++++++++++ styles.css | 47 +++++++++-- 6 files changed, 417 insertions(+), 55 deletions(-) create mode 100644 src/linkedin.ts create mode 100644 src/thread-modal.ts create mode 100644 src/thread-splitter.ts 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%; +} From 93f19858a9d352be4a7b7304c3b0bdcf167aef25 Mon Sep 17 00:00:00 2001 From: Heiko Joerg Schick Date: Wed, 29 Apr 2026 23:38:06 +0200 Subject: [PATCH 08/10] Widen thread modal and improve textarea spacing --- src/thread-modal.ts | 4 +++- styles.css | 11 +++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/thread-modal.ts b/src/thread-modal.ts index 2aeb9ab..29793a5 100644 --- a/src/thread-modal.ts +++ b/src/thread-modal.ts @@ -10,8 +10,10 @@ export class ThreadModal extends Modal { onOpen(): void { const { contentEl } = this; + this.modalEl.addClass("unicode-thread-modal"); + this.modalEl.style.width = "700px"; + this.modalEl.style.maxWidth = "85vw"; contentEl.empty(); - contentEl.addClass("unicode-thread-modal"); const headingText = this.tweets.length === 1 ? "X post" diff --git a/styles.css b/styles.css index bd27393..dad6343 100644 --- a/styles.css +++ b/styles.css @@ -8,7 +8,7 @@ .unicode-thread-modal .thread-tweet-item { display: flex; flex-direction: column; - gap: 4px; + gap: 8px; } .unicode-thread-modal .thread-tweet-header { @@ -23,9 +23,15 @@ color: var(--text-muted); } +.unicode-thread-modal { + max-width: 700px; + width: 85vw; +} + .unicode-thread-modal .thread-tweet-textarea { width: 100%; - min-height: 60px; + min-height: 120px; + box-sizing: border-box; resize: vertical; font-family: var(--font-text); font-size: var(--font-text-size); @@ -38,6 +44,7 @@ .unicode-thread-modal .thread-copy-button { align-self: flex-end; + margin-top: 4px; } .unicode-thread-modal .thread-copy-all-button { From 6bd9693ea6778f4c2905293cdf2c48517d451623 Mon Sep 17 00:00:00 2001 From: Heiko Joerg Schick Date: Tue, 5 May 2026 15:24:05 +0200 Subject: [PATCH 09/10] Fix README install path and add configurable thread settings --- README.md | 2 +- src/commands.ts | 11 +++++++---- src/main.ts | 5 +++++ src/settings.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 src/settings.ts diff --git a/README.md b/README.md index 8a23782..a41f908 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ The selected text is replaced in place. 1. Build the plugin (see [Development](#development)) or download a release 2. Copy `main.js` and `manifest.json` to your vault at: ``` - /.obsidian/plugins/unicode-text-formatter/ + /.obsidian/plugins/obsidian-unicode-formatter/ ``` 3. In Obsidian: **Settings → Community Plugins → Installed Plugins** — enable **Unicode Text Formatter** diff --git a/src/commands.ts b/src/commands.ts index 78d906d..57dfe14 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,9 +1,9 @@ -import { Plugin } from "obsidian"; +import type UnicodeFormatterPlugin from "./main"; 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: UnicodeFormatterPlugin, style: FormatStyle, name: string) { plugin.addCommand({ id: `unicode-formatter:${style}`, name, @@ -16,7 +16,7 @@ function addFormatCommand(plugin: Plugin, style: FormatStyle, name: string) { }); } -export function registerCommands(plugin: Plugin): void { +export function registerCommands(plugin: UnicodeFormatterPlugin): void { addFormatCommand(plugin, "bold", "Format as unicode bold"); addFormatCommand(plugin, "italic", "Format as unicode italic"); addFormatCommand(plugin, "bold-italic", "Format as unicode bold italic"); @@ -86,7 +86,10 @@ export function registerCommands(plugin: Plugin): void { editorCallback: (editor) => { const selection = editor.getSelection(); if (selection) { - const tweets = splitIntoThreads(selection); + const tweets = splitIntoThreads(selection, { + maxChars: plugin.settings.threadMaxChars, + addNumbering: plugin.settings.threadAddNumbering, + }); new ThreadModal(plugin.app, tweets).open(); } }, diff --git a/src/main.ts b/src/main.ts index 5625545..463b051 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,13 @@ import { Plugin } from 'obsidian'; import { registerCommands } from './commands'; +import { UnicodeFormatterSettings, DEFAULT_SETTINGS, UnicodeFormatterSettingTab } from './settings'; export default class UnicodeFormatterPlugin extends Plugin { + settings: UnicodeFormatterSettings; + async onload() { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()) as UnicodeFormatterSettings; + this.addSettingTab(new UnicodeFormatterSettingTab(this.app, this)); registerCommands(this); } } diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..9ad6f4a --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,49 @@ +import { App, PluginSettingTab, Setting } from "obsidian"; +import type UnicodeFormatterPlugin from "./main"; + +export interface UnicodeFormatterSettings { + threadMaxChars: number; + threadAddNumbering: boolean; +} + +export const DEFAULT_SETTINGS: UnicodeFormatterSettings = { + threadMaxChars: 280, + threadAddNumbering: true, +}; + +export class UnicodeFormatterSettingTab extends PluginSettingTab { + plugin: UnicodeFormatterPlugin; + + constructor(app: App, plugin: UnicodeFormatterPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + new Setting(containerEl) + .setName("Thread character limit") + .setDesc("Maximum characters per post.") + .addText(text => text + .setValue(String(this.plugin.settings.threadMaxChars)) + .onChange(async (value) => { + const num = parseInt(value, 10); + if (!isNaN(num) && num > 0) { + this.plugin.settings.threadMaxChars = num; + await this.plugin.saveData(this.plugin.settings); + } + })); + + new Setting(containerEl) + .setName("Add post numbering") + .setDesc("Prefix each post with numbering (e.g. 1/5) when splitting into a thread.") + .addToggle(toggle => toggle + .setValue(this.plugin.settings.threadAddNumbering) + .onChange(async (value) => { + this.plugin.settings.threadAddNumbering = value; + await this.plugin.saveData(this.plugin.settings); + })); + } +} From f13c19f880cf024d610dd20f1f6ab5aabdf5b189 Mon Sep 17 00:00:00 2001 From: Heiko Joerg Schick Date: Tue, 5 May 2026 15:36:49 +0200 Subject: [PATCH 10/10] Show error feedback when clipboard copy fails --- src/thread-modal.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/thread-modal.ts b/src/thread-modal.ts index 29793a5..24effd0 100644 --- a/src/thread-modal.ts +++ b/src/thread-modal.ts @@ -48,7 +48,10 @@ export class ThreadModal extends Modal { navigator.clipboard.writeText(tweet).then(() => { copyBtn.setText("Copied"); setTimeout(() => copyBtn.setText("Copy"), 2000); - }).catch(() => {}); + }).catch(() => { + copyBtn.setText("Failed"); + setTimeout(() => copyBtn.setText("Copy"), 2000); + }); }); } @@ -62,7 +65,10 @@ export class ThreadModal extends Modal { navigator.clipboard.writeText(all).then(() => { copyAllBtn.setText("Copied"); setTimeout(() => copyAllBtn.setText("Copy all"), 2000); - }).catch(() => {}); + }).catch(() => { + copyAllBtn.setText("Failed"); + setTimeout(() => copyAllBtn.setText("Copy all"), 2000); + }); }); } }