mirror of
https://github.com/youfoundjk/TeXcore.git
synced 2026-07-22 07:33:31 +00:00
feat(snippets): streamline snippet management and introduce text transformation features
This commit is contained in:
parent
d1d809f029
commit
80f7042a57
6 changed files with 223 additions and 312 deletions
|
|
@ -1,126 +1,51 @@
|
|||
# LaTeX Snippets
|
||||
# Command Snippets
|
||||
|
||||
Create reusable LaTeX code snippets for quick insertion via command palette.
|
||||
Use command palette snippets for fast note metadata and text transformations.
|
||||
|
||||
## Overview
|
||||
## Available Commands
|
||||
|
||||
The snippet system allows you to:
|
||||
### Add Tags
|
||||
|
||||
- Save frequently used LaTeX code
|
||||
- Insert snippets via command palette
|
||||
- Each snippet becomes its own command
|
||||
Adds this frontmatter block at the top of the active note:
|
||||
|
||||
## Managing Snippets
|
||||
```yaml
|
||||
---
|
||||
tags:
|
||||
-
|
||||
aliases:
|
||||
-
|
||||
---
|
||||
```
|
||||
|
||||
### Adding a Snippet
|
||||
|
||||
1. Open **Settings** → **LaTeX Equation Referencer** → **Snippets**
|
||||
2. Click **Add Snippet**
|
||||
3. Enter:
|
||||
- **Name**: Display name (e.g., "Matrix 2x2")
|
||||
- **Content**: The LaTeX code to insert
|
||||
|
||||
### Editing a Snippet
|
||||
|
||||
Click the pencil icon next to any snippet to edit its name or content.
|
||||
|
||||
### Deleting a Snippet
|
||||
|
||||
Click the trash icon next to a snippet and confirm deletion.
|
||||
|
||||
!!! note "Reload Required"
|
||||
After adding or editing snippets, you may need to reload Obsidian for command palette changes to take effect.
|
||||
|
||||
## Using Snippets
|
||||
|
||||
### Main Snippet Command
|
||||
How to use:
|
||||
|
||||
1. Open Command Palette (`Ctrl/Cmd + P`)
|
||||
2. Search for "Insert LaTeX Snippet"
|
||||
3. Select from the suggestion modal
|
||||
4. Snippet content is inserted at cursor
|
||||
2. Search for "Add Tags"
|
||||
3. Run the command
|
||||
|
||||
### Individual Snippet Commands
|
||||
Behavior:
|
||||
|
||||
Each snippet also creates its own command:
|
||||
- Inserts the frontmatter block at the top of the file
|
||||
- If frontmatter already exists at the top of the note, no change is made
|
||||
|
||||
- Command name: `Insert Snippet: <snippet name>`
|
||||
- Example: `Insert Snippet: Matrix 2x2`
|
||||
### Run Text Transform Snippet
|
||||
|
||||
This allows you to:
|
||||
Applies a built-in transformation to the selected text, or to the current line if no text is selected.
|
||||
|
||||
- Assign hotkeys to specific snippets
|
||||
- Access frequently used snippets directly
|
||||
How to use:
|
||||
|
||||
## Example Snippets
|
||||
1. Open Command Palette (`Ctrl/Cmd + P`)
|
||||
2. Search for "Run Text Transform Snippet"
|
||||
3. Choose a transform from the list
|
||||
|
||||
### Fraction Template
|
||||
```latex
|
||||
\frac{numerator}{denominator}
|
||||
```
|
||||
Built-in transforms:
|
||||
|
||||
### Matrix 2×2
|
||||
```latex
|
||||
\begin{pmatrix}
|
||||
a & b \\
|
||||
c & d
|
||||
\end{pmatrix}
|
||||
```
|
||||
- Kebab Case (`my-selected-text`)
|
||||
- Title Kebab Case (`My-Selected-Text`)
|
||||
- Title Case (`My Selected Text`)
|
||||
- Clean Zotero Highlight Line (`<mark>...</mark>` to quote + source format)
|
||||
|
||||
### Aligned Equations
|
||||
```latex
|
||||
\begin{aligned}
|
||||
a &= b + c \\
|
||||
d &= e + f
|
||||
\end{aligned}
|
||||
```
|
||||
## Notes
|
||||
|
||||
### Summation
|
||||
```latex
|
||||
\sum_{i=1}^{n} x_i
|
||||
```
|
||||
|
||||
### Integral
|
||||
```latex
|
||||
\int_{a}^{b} f(x) \, dx
|
||||
```
|
||||
|
||||
### Equation with ID Template
|
||||
```latex
|
||||
$$
|
||||
% equation here
|
||||
% id: eq-
|
||||
$$
|
||||
```
|
||||
|
||||
### Greek Letters Set
|
||||
```latex
|
||||
\alpha, \beta, \gamma, \delta, \epsilon, \theta, \lambda, \mu, \pi, \sigma, \omega
|
||||
```
|
||||
|
||||
## Snippet Tips
|
||||
|
||||
### Cursor Placement
|
||||
Currently, snippets insert content as-is. For multi-part snippets, use placeholder text:
|
||||
|
||||
```latex
|
||||
\frac{NUM}{DEN}
|
||||
```
|
||||
|
||||
### Complex Snippets
|
||||
For multi-line content, the full text is inserted. Example output:
|
||||
|
||||
```latex
|
||||
\begin{cases}
|
||||
x & \text{if } x > 0 \\
|
||||
-x & \text{if } x \leq 0
|
||||
\end{cases}
|
||||
```
|
||||
|
||||
### Integration with Equations
|
||||
Combine snippets with the equation system:
|
||||
|
||||
1. Insert equation template snippet
|
||||
2. Fill in the math content
|
||||
3. Add your `% id: eq-xxx` comment
|
||||
4. Reference with `\eqref` autocomplete
|
||||
- Multi-cursor selections are supported for text transforms
|
||||
- Transform commands show a notice indicating whether changes were applied
|
||||
|
|
|
|||
|
|
@ -60,19 +60,6 @@ export interface PluginSettings {
|
|||
debug: boolean;
|
||||
enabledCss: boolean;
|
||||
concurrency: string;
|
||||
|
||||
// Snippets
|
||||
snippets: Snippet[];
|
||||
}
|
||||
|
||||
export interface Snippet {
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
replacement?: string; // Optional: for future regex replacement support
|
||||
options?: {
|
||||
mode?: "insert" | "replace"; // Future proofing
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Required<PluginSettings> = {
|
||||
|
|
@ -129,7 +116,4 @@ export const DEFAULT_SETTINGS: Required<PluginSettings> = {
|
|||
debug: false,
|
||||
enabledCss: false,
|
||||
concurrency: "5",
|
||||
|
||||
// Snippets
|
||||
snippets: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { App, PluginSettingTab, Setting, TextAreaComponent } from "obsidian";
|
||||
import LatexReferencer from "../../main";
|
||||
import { NUMBER_STYLES } from "./settings";
|
||||
import { SnippetEditModal } from "../snippets/modal";
|
||||
|
||||
export class MathSettingTab extends PluginSettingTab {
|
||||
constructor(app: App, public plugin: LatexReferencer) {
|
||||
|
|
@ -285,46 +284,6 @@ export class MathSettingTab extends PluginSettingTab {
|
|||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
// Snippets Section
|
||||
containerEl.createEl("h2", { text: "Snippets" });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Manage Snippets")
|
||||
.setDesc("Add or edit your LaTeX snippets.")
|
||||
.addButton(btn => btn
|
||||
.setButtonText("Add Snippet")
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
new SnippetEditModal(this.plugin.app, async (result) => {
|
||||
await this.plugin.snippetManager.addSnippet(result);
|
||||
this.display(); // Refresh settings
|
||||
}).open();
|
||||
}));
|
||||
|
||||
// List existing snippets
|
||||
for (const snippet of this.plugin.settings.snippets) {
|
||||
new Setting(containerEl)
|
||||
.setName(snippet.name)
|
||||
.setDesc(snippet.content.length > 50 ? snippet.content.substring(0, 50) + "..." : snippet.content)
|
||||
.addButton(btn => btn
|
||||
.setIcon("pencil")
|
||||
.setTooltip("Edit")
|
||||
.onClick(() => {
|
||||
new SnippetEditModal(this.plugin.app, async (updated) => {
|
||||
await this.plugin.snippetManager.updateSnippet(snippet.id, updated);
|
||||
this.display();
|
||||
}, snippet).open();
|
||||
}))
|
||||
.addButton(btn => btn
|
||||
.setIcon("trash")
|
||||
.setTooltip("Delete")
|
||||
.setWarning()
|
||||
.onClick(async () => {
|
||||
await this.plugin.snippetManager.deleteSnippet(snippet.id);
|
||||
this.display();
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,69 +1,34 @@
|
|||
import { MarkdownView } from "obsidian";
|
||||
import { Notice } from "obsidian";
|
||||
import type LatexReferencer from "../../main";
|
||||
import type { Snippet } from "../settings/settings";
|
||||
import { SnippetSuggestModal } from "./modal";
|
||||
import { TextTransformSuggestModal } from "./modal";
|
||||
|
||||
export class SnippetManager {
|
||||
constructor(private plugin: LatexReferencer) { }
|
||||
|
||||
async addSnippet(snippet: Snippet) {
|
||||
this.plugin.settings.snippets.push(snippet);
|
||||
await this.plugin.saveSettings();
|
||||
this.registerSnippetCommand(snippet);
|
||||
}
|
||||
|
||||
async updateSnippet(id: string, updatedSnippet: Snippet) {
|
||||
const index = this.plugin.settings.snippets.findIndex(s => s.id === id);
|
||||
if (index !== -1) {
|
||||
this.plugin.settings.snippets[index] = updatedSnippet;
|
||||
await this.plugin.saveSettings();
|
||||
// Re-register command likely requires a reload or sophisticated command management
|
||||
// For now, prompt user to reload or rely on simple command overwriting if ID is stable?
|
||||
// Obsidian doesn't easily allow unregistering commands dynamically without private APIs.
|
||||
// However, we can update the callback if we keep track of it, but easiest is to just
|
||||
// keep using the ID. Since ID is typically stable, the command ID won't change.
|
||||
// But the Name might change.
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSnippet(id: string) {
|
||||
const index = this.plugin.settings.snippets.findIndex(s => s.id === id);
|
||||
if (index !== -1) {
|
||||
this.plugin.settings.snippets.splice(index, 1);
|
||||
await this.plugin.saveSettings();
|
||||
// Again, dynamic unregistering is hard.
|
||||
}
|
||||
}
|
||||
|
||||
getSnippets(): Snippet[] {
|
||||
return this.plugin.settings.snippets;
|
||||
}
|
||||
|
||||
registerSnippetCommand(snippet: Snippet) {
|
||||
const commandId = `insert-snippet-${snippet.id}`;
|
||||
|
||||
this.plugin.addCommand({
|
||||
id: commandId,
|
||||
name: `Insert Snippet: ${snippet.name}`,
|
||||
editorCallback: (editor, view) => {
|
||||
editor.replaceSelection(snippet.content);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onLoad() {
|
||||
// Register main command
|
||||
this.plugin.addCommand({
|
||||
id: "insert-latex-snippet",
|
||||
name: "Insert LaTeX Snippet",
|
||||
callback: () => {
|
||||
new SnippetSuggestModal(this.plugin.app, this.plugin).open();
|
||||
id: "add-tags-frontmatter",
|
||||
name: "Add Tags",
|
||||
editorCallback: (editor) => {
|
||||
const content = editor.getValue().replace(/\r\n/g, "\n");
|
||||
const frontmatterPattern = /^---\n[\s\S]*?\n---\n?/;
|
||||
if (frontmatterPattern.test(content)) {
|
||||
new Notice("Frontmatter already exists at the top of this note.");
|
||||
return;
|
||||
}
|
||||
|
||||
const tagsFrontmatter = "---\ntags:\n - \naliases:\n - \n---\n\n";
|
||||
editor.replaceRange(tagsFrontmatter, { line: 0, ch: 0 });
|
||||
new Notice("Added tags frontmatter.");
|
||||
}
|
||||
});
|
||||
|
||||
// Register individual commands
|
||||
for (const snippet of this.plugin.settings.snippets) {
|
||||
this.registerSnippetCommand(snippet);
|
||||
}
|
||||
this.plugin.addCommand({
|
||||
id: "run-text-transform-snippet",
|
||||
name: "Run Text Transform Snippet",
|
||||
editorCallback: (editor) => {
|
||||
new TextTransformSuggestModal(this.plugin.app, editor).open();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,101 +1,31 @@
|
|||
import { App, FuzzySuggestModal, Modal, Setting, Notice, MarkdownView } from "obsidian";
|
||||
import type { Snippet } from "../settings/settings";
|
||||
import type LatexReferencer from "../../main";
|
||||
import { App, Editor, FuzzyMatch, FuzzySuggestModal, Notice } from "obsidian";
|
||||
import { BUILTIN_TEXT_TRANSFORM_SNIPPETS, runTextTransformSnippet, TextTransformSnippet } from "./transforms";
|
||||
|
||||
export class SnippetSuggestModal extends FuzzySuggestModal<Snippet> {
|
||||
constructor(app: App, private plugin: LatexReferencer) {
|
||||
export class TextTransformSuggestModal extends FuzzySuggestModal<TextTransformSnippet> {
|
||||
constructor(app: App, private editor: Editor) {
|
||||
super(app);
|
||||
this.setPlaceholder("Select a snippet to insert...");
|
||||
this.setPlaceholder("Select a text transform snippet...");
|
||||
}
|
||||
|
||||
getItems(): Snippet[] {
|
||||
return this.plugin.settings.snippets;
|
||||
getItems(): TextTransformSnippet[] {
|
||||
return BUILTIN_TEXT_TRANSFORM_SNIPPETS;
|
||||
}
|
||||
|
||||
getItemText(item: Snippet): string {
|
||||
return item.name;
|
||||
getItemText(item: TextTransformSnippet): string {
|
||||
return `${item.name} ${item.keywords.join(" ")}`;
|
||||
}
|
||||
|
||||
onChooseItem(item: Snippet, evt: MouseEvent | KeyboardEvent): void {
|
||||
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (view) {
|
||||
const editor = view.editor;
|
||||
editor.replaceSelection(item.content);
|
||||
renderSuggestion(item: FuzzyMatch<TextTransformSnippet>, el: HTMLElement): void {
|
||||
el.createEl("div", { text: item.item.name });
|
||||
el.createEl("small", { text: item.item.description });
|
||||
}
|
||||
|
||||
onChooseItem(item: TextTransformSnippet, evt: MouseEvent | KeyboardEvent): void {
|
||||
const result = runTextTransformSnippet(this.editor, item);
|
||||
if (result.changedCount > 0) {
|
||||
new Notice(`Applied ${item.name} to ${result.changedCount} ${result.appliedOn}(s).`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class SnippetEditModal extends Modal {
|
||||
name: string;
|
||||
content: string;
|
||||
editingSnippet: Snippet | null = null;
|
||||
onSubmit: (result: Snippet) => void;
|
||||
|
||||
constructor(app: App, onSubmit: (result: Snippet) => void, snippet: Snippet | null = null) {
|
||||
super(app);
|
||||
this.onSubmit = onSubmit;
|
||||
this.editingSnippet = snippet;
|
||||
this.name = snippet?.name || "";
|
||||
this.content = snippet?.content || "";
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
|
||||
contentEl.createEl("h2", { text: this.editingSnippet ? "Edit Snippet" : "Add Snippet" });
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName("Name")
|
||||
.setDesc("The name of the snippet (used for searching and command palette)")
|
||||
.addText(text => text
|
||||
.setValue(this.name)
|
||||
.onChange(value => {
|
||||
this.name = value;
|
||||
}));
|
||||
|
||||
const contentSetting = new Setting(contentEl)
|
||||
.setName("Content")
|
||||
.setDesc("The LaTeX content to insert");
|
||||
|
||||
// Use a larger text area for content
|
||||
const textArea = contentSetting.controlEl.createEl("textarea", {
|
||||
attr: {
|
||||
rows: "5",
|
||||
cols: "50",
|
||||
style: "width: 100%; min-height: 150px; font-family: monospace;"
|
||||
}
|
||||
});
|
||||
textArea.value = this.content;
|
||||
textArea.addEventListener("input", (e) => {
|
||||
this.content = (e.target as HTMLTextAreaElement).value;
|
||||
});
|
||||
// Align textarea properly
|
||||
contentSetting.settingEl.style.display = "block";
|
||||
contentSetting.infoEl.style.marginBottom = "10px";
|
||||
|
||||
|
||||
new Setting(contentEl)
|
||||
.addButton(btn => btn
|
||||
.setButtonText("Save")
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
if (!this.name || !this.content) {
|
||||
new Notice("Name and content are required.");
|
||||
return;
|
||||
}
|
||||
this.onSubmit({
|
||||
id: this.editingSnippet?.id || crypto.randomUUID(),
|
||||
name: this.name,
|
||||
content: this.content,
|
||||
options: this.editingSnippet?.options
|
||||
});
|
||||
this.close();
|
||||
}));
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
new Notice(`${item.name} made no changes to the current ${result.appliedOn}.`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
148
src/features/snippets/transforms.ts
Normal file
148
src/features/snippets/transforms.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import type { Editor, EditorPosition, EditorSelection } from "obsidian";
|
||||
|
||||
export interface TextTransformSnippet {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
keywords: string[];
|
||||
transform: (input: string) => string;
|
||||
}
|
||||
|
||||
export interface TextTransformResult {
|
||||
changedCount: number;
|
||||
appliedOn: "selection" | "line";
|
||||
}
|
||||
|
||||
function comparePositionsDesc(a: EditorPosition, b: EditorPosition): number {
|
||||
if (a.line !== b.line) {
|
||||
return b.line - a.line;
|
||||
}
|
||||
return b.ch - a.ch;
|
||||
}
|
||||
|
||||
function isSelectionEmpty(selection: EditorSelection): boolean {
|
||||
return selection.anchor.line === selection.head.line && selection.anchor.ch === selection.head.ch;
|
||||
}
|
||||
|
||||
function getSelectionRange(selection: EditorSelection): { from: EditorPosition; to: EditorPosition } {
|
||||
const anchorFirst =
|
||||
selection.anchor.line < selection.head.line ||
|
||||
(selection.anchor.line === selection.head.line && selection.anchor.ch <= selection.head.ch);
|
||||
|
||||
return anchorFirst
|
||||
? { from: selection.anchor, to: selection.head }
|
||||
: { from: selection.head, to: selection.anchor };
|
||||
}
|
||||
|
||||
function normalizeWords(input: string): string[] {
|
||||
const trimmed = input.replace(/^[^a-zA-Z0-9]+/, "").replace(/[^a-zA-Z0-9]+$/, "");
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
return trimmed.split(/[^a-zA-Z0-9]+/g).filter(Boolean);
|
||||
}
|
||||
|
||||
function toKebabCase(input: string): string {
|
||||
return normalizeWords(input).map((w) => w.toLowerCase()).join("-");
|
||||
}
|
||||
|
||||
function toTitleKebabCase(input: string): string {
|
||||
return normalizeWords(input)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||
.join("-");
|
||||
}
|
||||
|
||||
function toTitleCase(input: string): string {
|
||||
return normalizeWords(input)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function cleanZoteroHighlightLine(input: string): string {
|
||||
return input.replace(
|
||||
/<mark[^>]*>\s*[\u0022\u201C\u201D]?(.*?)[\u0022\u201C\u201D]?\s*<\/mark>\s*(.*)/g,
|
||||
(_match, highlightedText: string, trailingText: string) => {
|
||||
const normalized = highlightedText.replace(/[\.,]$/, "");
|
||||
return `\"${normalized}.\" \u2014 ${trailingText}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const BUILTIN_TEXT_TRANSFORM_SNIPPETS: TextTransformSnippet[] = [
|
||||
{
|
||||
id: "kebab-case",
|
||||
name: "Kebab Case",
|
||||
description: "my-selected-text",
|
||||
keywords: ["kebab", "slug", "lower"],
|
||||
transform: toKebabCase,
|
||||
},
|
||||
{
|
||||
id: "title-kebab-case",
|
||||
name: "Title Kebab Case",
|
||||
description: "My-Selected-Text",
|
||||
keywords: ["title kebab", "slug", "dash"],
|
||||
transform: toTitleKebabCase,
|
||||
},
|
||||
{
|
||||
id: "title-case",
|
||||
name: "Title Case",
|
||||
description: "My Selected Text",
|
||||
keywords: ["title", "heading", "capitalize"],
|
||||
transform: toTitleCase,
|
||||
},
|
||||
{
|
||||
id: "clean-zotero-highlight-line",
|
||||
name: "Clean Zotero Highlight Line",
|
||||
description: "Format <mark>...</mark> highlights into quote + source",
|
||||
keywords: ["zotero", "mark", "highlight", "citation"],
|
||||
transform: cleanZoteroHighlightLine,
|
||||
},
|
||||
];
|
||||
|
||||
export function runTextTransformSnippet(editor: Editor, snippet: TextTransformSnippet): TextTransformResult {
|
||||
const allSelections = editor.listSelections();
|
||||
const nonEmptySelections = allSelections.filter((selection) => !isSelectionEmpty(selection));
|
||||
|
||||
if (nonEmptySelections.length > 0) {
|
||||
const ranges = nonEmptySelections
|
||||
.map((selection) => getSelectionRange(selection))
|
||||
.sort((a, b) => comparePositionsDesc(a.from, b.from));
|
||||
|
||||
let changedCount = 0;
|
||||
for (const range of ranges) {
|
||||
const original = editor.getRange(range.from, range.to);
|
||||
const transformed = snippet.transform(original);
|
||||
if (transformed !== original) {
|
||||
editor.replaceRange(transformed, range.from, range.to);
|
||||
changedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changedCount,
|
||||
appliedOn: "selection",
|
||||
};
|
||||
}
|
||||
|
||||
const cursor = editor.getCursor();
|
||||
const lineNumber = cursor.line;
|
||||
const originalLine = editor.getLine(lineNumber);
|
||||
const transformedLine = snippet.transform(originalLine);
|
||||
|
||||
if (transformedLine !== originalLine) {
|
||||
editor.replaceRange(
|
||||
transformedLine,
|
||||
{ line: lineNumber, ch: 0 },
|
||||
{ line: lineNumber, ch: originalLine.length },
|
||||
);
|
||||
return {
|
||||
changedCount: 1,
|
||||
appliedOn: "line",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
changedCount: 0,
|
||||
appliedOn: "line",
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue