Merge branch 'bugfix/2026-04-29' into 'main'
Fix plugin ID, package metadata, and formatter consistency issues See merge request schihei/obsidian-unicode-formatter!1
This commit is contained in:
@@ -39,7 +39,7 @@ The selected text is replaced in place.
|
|||||||
1. Build the plugin (see [Development](#development)) or download a release
|
1. Build the plugin (see [Development](#development)) or download a release
|
||||||
2. Copy `main.js` and `manifest.json` to your vault at:
|
2. Copy `main.js` and `manifest.json` to your vault at:
|
||||||
```
|
```
|
||||||
<VaultFolder>/.obsidian/plugins/unicode-text-formatter/
|
<VaultFolder>/.obsidian/plugins/obsidian-unicode-formatter/
|
||||||
```
|
```
|
||||||
3. In Obsidian: **Settings → Community Plugins → Installed Plugins** — enable **Unicode Text Formatter**
|
3. In Obsidian: **Settings → Community Plugins → Installed Plugins** — enable **Unicode Text Formatter**
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "unicode-text-formatter",
|
"id": "obsidian-unicode-formatter",
|
||||||
"name": "Unicode Text Formatter",
|
"name": "Unicode Text Formatter",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"minAppVersion": "0.15.0",
|
"minAppVersion": "0.15.0",
|
||||||
"description": "Format selected text as Unicode Sans-Serif Bold, Italic, or Bold-Italic for LinkedIn and Facebook posts.",
|
"description": "Format selected text as Unicode Sans-Serif Bold, Italic, or Bold-Italic for LinkedIn and Facebook posts.",
|
||||||
"author": "schihei",
|
"author": "schihei",
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "obsidian-sample-plugin",
|
"name": "obsidian-sample-plugin",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "obsidian-sample-plugin",
|
"name": "obsidian-sample-plugin",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"license": "0-BSD",
|
"license": "0-BSD",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"obsidian": "latest"
|
"obsidian": "latest"
|
||||||
|
|||||||
+1
-29
@@ -1,29 +1 @@
|
|||||||
{
|
{"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"}}
|
||||||
"name": "obsidian-sample-plugin",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+28
-12
@@ -1,7 +1,9 @@
|
|||||||
import { Plugin } from "obsidian";
|
import type UnicodeFormatterPlugin from "./main";
|
||||||
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: UnicodeFormatterPlugin, style: FormatStyle, name: string) {
|
||||||
plugin.addCommand({
|
plugin.addCommand({
|
||||||
id: `unicode-formatter:${style}`,
|
id: `unicode-formatter:${style}`,
|
||||||
name,
|
name,
|
||||||
@@ -14,13 +16,13 @@ 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, "bold", "Format as unicode bold");
|
||||||
addFormatCommand(plugin, "italic", "Format as Unicode Italic");
|
addFormatCommand(plugin, "italic", "Format as unicode italic");
|
||||||
addFormatCommand(plugin, "bold-italic", "Format as Unicode Bold Italic");
|
addFormatCommand(plugin, "bold-italic", "Format as unicode bold italic");
|
||||||
plugin.addCommand({
|
plugin.addCommand({
|
||||||
id: "unicode-formatter:clean",
|
id: "unicode-formatter:clean",
|
||||||
name: "Remove Unicode Formatting",
|
name: "Remove unicode formatting",
|
||||||
editorCallback: (editor) => {
|
editorCallback: (editor) => {
|
||||||
const selection = editor.getSelection();
|
const selection = editor.getSelection();
|
||||||
if (selection) {
|
if (selection) {
|
||||||
@@ -30,7 +32,7 @@ export function registerCommands(plugin: Plugin): void {
|
|||||||
});
|
});
|
||||||
plugin.addCommand({
|
plugin.addCommand({
|
||||||
id: "unicode-formatter:bullet-to-arrow",
|
id: "unicode-formatter:bullet-to-arrow",
|
||||||
name: "Convert List Bullets to Arrow",
|
name: "Convert list bullets to arrow",
|
||||||
editorCallback: (editor) => {
|
editorCallback: (editor) => {
|
||||||
const selection = editor.getSelection();
|
const selection = editor.getSelection();
|
||||||
if (selection) {
|
if (selection) {
|
||||||
@@ -40,7 +42,7 @@ export function registerCommands(plugin: Plugin): void {
|
|||||||
});
|
});
|
||||||
plugin.addCommand({
|
plugin.addCommand({
|
||||||
id: "unicode-formatter:bullet-to-emdash",
|
id: "unicode-formatter:bullet-to-emdash",
|
||||||
name: "Convert List Bullets to Em Dash",
|
name: "Convert list bullets to em dash",
|
||||||
editorCallback: (editor) => {
|
editorCallback: (editor) => {
|
||||||
const selection = editor.getSelection();
|
const selection = editor.getSelection();
|
||||||
if (selection) {
|
if (selection) {
|
||||||
@@ -50,7 +52,7 @@ export function registerCommands(plugin: Plugin): void {
|
|||||||
});
|
});
|
||||||
plugin.addCommand({
|
plugin.addCommand({
|
||||||
id: "unicode-formatter:numbered-list-slash",
|
id: "unicode-formatter:numbered-list-slash",
|
||||||
name: "Convert List to Numbered (Slash)",
|
name: "Convert list to numbered (slash)",
|
||||||
editorCallback: (editor) => {
|
editorCallback: (editor) => {
|
||||||
const selection = editor.getSelection();
|
const selection = editor.getSelection();
|
||||||
if (selection) {
|
if (selection) {
|
||||||
@@ -60,7 +62,7 @@ export function registerCommands(plugin: Plugin): void {
|
|||||||
});
|
});
|
||||||
plugin.addCommand({
|
plugin.addCommand({
|
||||||
id: "unicode-formatter:numbered-list-parens",
|
id: "unicode-formatter:numbered-list-parens",
|
||||||
name: "Convert List to Numbered (Parentheses)",
|
name: "Convert list to numbered (parentheses)",
|
||||||
editorCallback: (editor) => {
|
editorCallback: (editor) => {
|
||||||
const selection = editor.getSelection();
|
const selection = editor.getSelection();
|
||||||
if (selection) {
|
if (selection) {
|
||||||
@@ -70,7 +72,7 @@ export function registerCommands(plugin: Plugin): void {
|
|||||||
});
|
});
|
||||||
plugin.addCommand({
|
plugin.addCommand({
|
||||||
id: "unicode-formatter:markdown-to-linkedin",
|
id: "unicode-formatter:markdown-to-linkedin",
|
||||||
name: "Convert Markdown to Post Format",
|
name: "Convert Markdown to post format",
|
||||||
editorCallback: (editor) => {
|
editorCallback: (editor) => {
|
||||||
const selection = editor.getSelection();
|
const selection = editor.getSelection();
|
||||||
if (selection) {
|
if (selection) {
|
||||||
@@ -78,4 +80,18 @@ 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, {
|
||||||
|
maxChars: plugin.settings.threadMaxChars,
|
||||||
|
addNumbering: plugin.settings.threadAddNumbering,
|
||||||
|
});
|
||||||
|
new ThreadModal(plugin.app, tweets).open();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-52
@@ -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";
|
export type FormatStyle = "bold" | "italic" | "bold-italic";
|
||||||
|
|
||||||
@@ -14,7 +14,35 @@ export function transformText(text: string, style: FormatStyle): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function cleanText(text: string): 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+\/ /, "");
|
||||||
|
|
||||||
|
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bulletToEmdash(text: string): string {
|
export function bulletToEmdash(text: string): string {
|
||||||
@@ -47,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, italic) => {
|
|
||||||
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("- ")) {
|
|
||||||
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();
|
||||||
|
}
|
||||||
+5
-3
@@ -1,11 +1,13 @@
|
|||||||
import { Plugin } from 'obsidian';
|
import { Plugin } from 'obsidian';
|
||||||
import { registerCommands } from './commands';
|
import { registerCommands } from './commands';
|
||||||
|
import { UnicodeFormatterSettings, DEFAULT_SETTINGS, UnicodeFormatterSettingTab } from './settings';
|
||||||
|
|
||||||
export default class UnicodeFormatterPlugin extends Plugin {
|
export default class UnicodeFormatterPlugin extends Plugin {
|
||||||
|
settings: UnicodeFormatterSettings;
|
||||||
|
|
||||||
async onload() {
|
async onload() {
|
||||||
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()) as UnicodeFormatterSettings;
|
||||||
|
this.addSettingTab(new UnicodeFormatterSettingTab(this.app, this));
|
||||||
registerCommands(this);
|
registerCommands(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
onunload() {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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;
|
||||||
|
this.modalEl.addClass("unicode-thread-modal");
|
||||||
|
this.modalEl.style.width = "700px";
|
||||||
|
this.modalEl.style.maxWidth = "85vw";
|
||||||
|
contentEl.empty();
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
copyBtn.setText("Failed");
|
||||||
|
setTimeout(() => copyBtn.setText("Copy"), 2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
copyAllBtn.setText("Failed");
|
||||||
|
setTimeout(() => copyAllBtn.setText("Copy all"), 2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -31,3 +31,12 @@ export const UNICODE_TO_ASCII_MAP: Map<string, string> = new Map(
|
|||||||
Object.entries(m).map(([ascii, unicode]) => [unicode, ascii] as [string, string])
|
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<string, FontStyle> = 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]),
|
||||||
|
]);
|
||||||
|
|||||||
+49
-5
@@ -1,8 +1,52 @@
|
|||||||
/*
|
.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: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
max-width: 700px;
|
||||||
|
width: 85vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unicode-thread-modal .thread-tweet-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 120px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
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;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unicode-thread-modal .thread-copy-all-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
{
|
{
|
||||||
"1.0.0": "0.15.0"
|
"1.0.0": "0.15.0",
|
||||||
|
"1.0.1": "0.15.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user