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
|
||||
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
-3
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"id": "unicode-text-formatter",
|
||||
"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
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -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"
|
||||
|
||||
+1
-29
@@ -1,29 +1 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
{"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"}}
|
||||
|
||||
+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 { 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,
|
||||
@@ -14,13 +16,13 @@ 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");
|
||||
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");
|
||||
plugin.addCommand({
|
||||
id: "unicode-formatter:clean",
|
||||
name: "Remove Unicode Formatting",
|
||||
name: "Remove unicode formatting",
|
||||
editorCallback: (editor) => {
|
||||
const selection = editor.getSelection();
|
||||
if (selection) {
|
||||
@@ -30,7 +32,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 +42,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 +52,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 +62,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 +72,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) {
|
||||
@@ -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";
|
||||
|
||||
@@ -14,7 +14,35 @@ 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+\/ /, "");
|
||||
|
||||
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 {
|
||||
@@ -47,53 +75,4 @@ export function numberedListParens(text: string): string {
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
export function applyInlineMarkdown(text: string): string {
|
||||
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();
|
||||
}
|
||||
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();
|
||||
}
|
||||
+5
-3
@@ -1,11 +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);
|
||||
}
|
||||
|
||||
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])
|
||||
)
|
||||
);
|
||||
|
||||
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
|
||||
available in the app when your plugin is enabled.
|
||||
.unicode-thread-modal .thread-tweet-item {
|
||||
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