Compare commits

...

15 commits
0.1.5 ... main

Author SHA1 Message Date
@gapmiss
2829630e7c chore: release 0.1.12 2026-07-18 16:22:28 -05:00
@gapmiss
7a885e6aa7 fix: replace DOMParser createElement with regex to resolve prefer-create-el false positive 2026-07-18 16:22:19 -05:00
@gapmiss
5070666786 chore: release 0.1.11 2026-07-18 16:07:06 -05:00
@gapmiss
ce476bb678 fix: add eslint-disable for prefer-create-el false positive on DOMParser document 2026-07-18 16:06:53 -05:00
@gapmiss
abd4235867 chore: release 0.1.10 2026-07-18 03:44:34 -05:00
@gapmiss
44d48b31ab fix: upgrade eslint-plugin-obsidianmd to 0.4.1 and resolve prefer-create-el warnings 2026-07-18 03:44:27 -05:00
@gapmiss
afd54934cd chore: release 0.1.9 2026-07-17 20:46:21 -05:00
@gapmiss
882487103f feat: add obsidian:// URI handler for saving posts via protocol link 2026-07-17 20:45:56 -05:00
@gapmiss
cd4cfb0802 chore: release 0.1.8 2026-07-13 13:40:07 -05:00
@gapmiss
cc2300c5d6 fix: add ES2017.String and ES2020.Promise to tsconfig lib to resolve unsafe-any lint warnings 2026-07-13 13:40:02 -05:00
@gapmiss
defd646968 chore: release 0.1.7 2026-07-13 11:51:53 -05:00
@gapmiss
c9bd606f4c fix: unify comment counts across frontmatter, history sidebar, and comments file using recursive count 2026-07-13 11:51:46 -05:00
@gapmiss
4561bfe699 chore: release 0.1.6 2026-07-11 16:38:41 -05:00
@gapmiss
0129a2aca9 feat: improve history view with search icon/clear button, Notice toasts, and disabled state during re-download 2026-07-11 16:38:33 -05:00
@gapmiss
1c6cfefbbf fix: add is-loading class to Notice 2026-07-11 16:16:37 -05:00
11 changed files with 5483 additions and 5285 deletions

View file

@ -61,6 +61,22 @@ Substacks/
The main note includes frontmatter with title, subtitle, type, audience, date, comment count, and links to all media. Comments are embedded via `![[slug-comments]]`. The main note includes frontmatter with title, subtitle, type, audience, date, comment count, and links to all media. Comments are embedded via `![[slug-comments]]`.
### URI protocol
You can save a post directly via an `obsidian://` URI without opening the command palette:
```
obsidian://substack-clipper?vault=MyVault&url=https://alice.substack.com/p/my-article
```
| Parameter | Required | Description |
|-----------|----------|-------------|
| `vault` | no | Target vault name (Obsidian prompts if omitted and multiple vaults exist) |
| `url` | yes | Full Substack post URL (must contain `/p/`) |
| `open` | no | `true` or `false` — override the "open after saving" setting |
Spaces in vault names should be percent-encoded (`%20`). This works from browsers, scripts, iOS Shortcuts, bookmarklets, and anywhere else that can open a URL.
## Settings ## Settings
| Setting | Default | Description | | Setting | Default | Description |

View file

@ -1,7 +1,7 @@
{ {
"id": "substack-clipper", "id": "substack-clipper",
"name": "Substack Clipper", "name": "Substack Clipper",
"version": "0.1.5", "version": "0.1.12",
"minAppVersion": "1.13.0", "minAppVersion": "1.13.0",
"description": "Archive Substack posts as Markdown with images, media, and comments.", "description": "Archive Substack posts as Markdown with images, media, and comments.",
"author": "@gapmiss", "author": "@gapmiss",

10471
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "substack-clipper", "name": "substack-clipper",
"version": "0.1.5", "version": "0.1.12",
"description": "Clip substack articles", "description": "Clip substack articles",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
@ -26,7 +26,7 @@
"esbuild": "^0.28.1", "esbuild": "^0.28.1",
"eslint": "^9.30.1", "eslint": "^9.30.1",
"eslint-plugin-no-unsanitized": "^4.1.2", "eslint-plugin-no-unsanitized": "^4.1.2",
"eslint-plugin-obsidianmd": "^0.3.0", "eslint-plugin-obsidianmd": "^0.4.1",
"globals": "^14.0.0", "globals": "^14.0.0",
"jiti": "^2.6.1", "jiti": "^2.6.1",
"tslib": "^2.4.0", "tslib": "^2.4.0",

View file

@ -94,6 +94,21 @@ function renderComment(
return md; return md;
} }
function countNestedComments(comments: Comment[]): number {
let total = 0;
for (const c of comments) {
total += 1;
if (c.children) {
total += countNestedComments(c.children);
}
}
return total;
}
export function countAllComments(data: CommentsResponse): number {
return countNestedComments(data.comments);
}
export function renderComments( export function renderComments(
data: CommentsResponse, data: CommentsResponse,
slug: string, slug: string,

View file

@ -9,27 +9,16 @@ function isSubstackImageUrl(url: string): boolean {
const VIDEO_PLACEHOLDER_RE = /VIDEOEMBED([a-f0-9-]{36})ENDVIDEO/g; const VIDEO_PLACEHOLDER_RE = /VIDEOEMBED([a-f0-9-]{36})ENDVIDEO/g;
function replaceVideoDivs(html: string, downloadedUrls: Set<string>): string { function replaceVideoDivs(html: string, downloadedUrls: Set<string>): string {
const parser = new DOMParser(); return html.replace(
const doc = parser.parseFromString(html, 'text/html'); /<div[^>]+id="media-([^"]{36})"[^>]*>[\s\S]*?<\/div>/g,
(_match, videoId: string) => {
for (const div of Array.from(doc.querySelectorAll('div[id^="media-"]'))) { const videoUrl = `https://substack.com/api/v1/video/upload/${videoId}/src`;
const id = div.getAttribute('id') ?? ''; if (downloadedUrls.has(videoUrl)) {
if (id.length !== 42) continue; return `<p>VIDEOEMBED${videoId}ENDVIDEO</p>`;
const videoId = id.replace('media-', ''); }
const videoUrl = `https://substack.com/api/v1/video/upload/${videoId}/src`; return `<p><a href="${videoUrl}">Video</a></p>`;
const p = doc.createElement('p');
if (downloadedUrls.has(videoUrl)) {
p.textContent = `VIDEOEMBED${videoId}ENDVIDEO`;
} else {
const a = doc.createElement('a');
a.href = videoUrl;
a.textContent = 'Video';
p.appendChild(a);
} }
div.replaceWith(p); );
}
return doc.body.innerHTML;
} }
function restoreVideoWikilinks(md: string): string { function restoreVideoWikilinks(md: string): string {

View file

@ -1,7 +1,7 @@
import { ItemView, normalizePath, Notice, setIcon, TFile, type WorkspaceLeaf } from 'obsidian'; import { ItemView, normalizePath, Notice, setIcon, TFile, type WorkspaceLeaf } from 'obsidian';
import type SubstackClipperPlugin from './main'; import type SubstackClipperPlugin from './main';
import type { HistoryEntry } from './types'; import type { HistoryEntry } from './types';
import { fetchComments, renderComments } from './comments'; import { countAllComments, fetchComments, renderComments } from './comments';
export const HISTORY_VIEW_TYPE = 'substack-clipper-history'; export const HISTORY_VIEW_TYPE = 'substack-clipper-history';
@ -9,7 +9,7 @@ export class HistoryView extends ItemView {
private plugin: SubstackClipperPlugin; private plugin: SubstackClipperPlugin;
private filterText = ''; private filterText = '';
private listEl: HTMLElement; private listEl: HTMLElement;
private statusEl: HTMLElement; private redownloadBtn: HTMLButtonElement;
private selectedEntries: Set<number> = new Set(); private selectedEntries: Set<number> = new Set();
constructor(leaf: WorkspaceLeaf, plugin: SubstackClipperPlugin) { constructor(leaf: WorkspaceLeaf, plugin: SubstackClipperPlugin) {
@ -33,31 +33,49 @@ export class HistoryView extends ItemView {
const { contentEl } = this; const { contentEl } = this;
contentEl.addClass('substack-clipper-history-view'); contentEl.addClass('substack-clipper-history-view');
const searchInput = contentEl.createEl('input', { const searchContainer = contentEl.createDiv({ cls: 'substack-clipper-history-search-container' });
const searchIconEl = searchContainer.createSpan({ cls: 'substack-clipper-history-search-icon' });
setIcon(searchIconEl, 'search');
const searchInput = searchContainer.createEl('input', {
type: 'text', type: 'text',
placeholder: 'Filter by title or author...',
cls: 'substack-clipper-history-search', cls: 'substack-clipper-history-search',
attr: {
placeholder: 'Filter by title or author...',
'aria-label': 'Filter history entries',
},
});
const clearBtn = searchContainer.createEl('button', {
cls: 'substack-clipper-history-search-clear',
attr: {
'aria-label': 'Clear filter',
'data-tooltip-position': 'top',
},
});
setIcon(clearBtn, 'x');
clearBtn.addEventListener('click', () => {
searchInput.value = '';
this.filterText = '';
clearBtn.removeClass('is-visible');
this.renderList();
searchInput.focus();
}); });
searchInput.setAttribute('aria-label', 'Filter history entries');
searchInput.addEventListener('input', () => { searchInput.addEventListener('input', () => {
this.filterText = searchInput.value.toLowerCase(); this.filterText = searchInput.value.toLowerCase();
clearBtn.toggleClass('is-visible', searchInput.value.length > 0);
this.renderList(); this.renderList();
}); });
this.listEl = contentEl.createDiv({ cls: 'substack-clipper-history-list' }); this.listEl = contentEl.createDiv({ cls: 'substack-clipper-history-list' });
this.statusEl = contentEl.createDiv({ cls: 'substack-clipper-history-status' });
this.statusEl.setAttribute('role', 'status');
this.statusEl.setAttribute('aria-live', 'polite');
const footer = contentEl.createDiv({ cls: 'substack-clipper-history-footer' }); const footer = contentEl.createDiv({ cls: 'substack-clipper-history-footer' });
const btn = footer.createEl('button', { this.redownloadBtn = footer.createEl('button', {
text: 'Re-download comments', text: 'Re-download comments',
cls: 'mod-cta', cls: 'mod-cta',
attr: { 'aria-label': 'Re-download comments for selected entries' },
}); });
btn.setAttribute('aria-label', 'Re-download comments for selected entries'); this.redownloadBtn.disabled = true;
btn.addEventListener('click', () => { this.redownloadBtn.addEventListener('click', () => {
void this.redownloadComments(btn); void this.redownloadComments();
}); });
this.renderList(); this.renderList();
@ -80,9 +98,14 @@ export class HistoryView extends ItemView {
); );
} }
private syncRedownloadBtn(): void {
this.redownloadBtn.disabled = this.selectedEntries.size === 0;
}
private renderList(): void { private renderList(): void {
this.listEl.empty(); this.listEl.empty();
this.selectedEntries.clear(); this.selectedEntries.clear();
this.syncRedownloadBtn();
const entries = this.getFilteredEntries(); const entries = this.getFilteredEntries();
@ -99,7 +122,7 @@ export class HistoryView extends ItemView {
const header = this.listEl.createDiv({ cls: 'substack-clipper-history-header' }); const header = this.listEl.createDiv({ cls: 'substack-clipper-history-header' });
const selectAllCb = header.createEl('input', { type: 'checkbox' }); const selectAllCb = header.createEl('input', { type: 'checkbox' });
selectAllCb.setAttribute('aria-label', 'Select all entries'); selectAllCb.setAttribute('aria-label', 'Select all entries');
header.createEl('span', { header.createSpan({
text: `${String(entries.length)} ${entries.length === 1 ? 'entry' : 'entries'}`, text: `${String(entries.length)} ${entries.length === 1 ? 'entry' : 'entries'}`,
cls: 'substack-clipper-history-count', cls: 'substack-clipper-history-count',
}); });
@ -124,22 +147,23 @@ export class HistoryView extends ItemView {
const checked = rowCheckboxes.filter(r => r.checkbox.checked).length; const checked = rowCheckboxes.filter(r => r.checkbox.checked).length;
selectAllCb.checked = checked === total; selectAllCb.checked = checked === total;
selectAllCb.indeterminate = checked > 0 && checked < total; selectAllCb.indeterminate = checked > 0 && checked < total;
this.syncRedownloadBtn();
}); });
const info = row.createDiv({ cls: 'substack-clipper-history-info' }); const info = row.createDiv({ cls: 'substack-clipper-history-info' });
info.createEl('span', { text: entry.title, cls: 'substack-clipper-history-title' }); info.createSpan({ text: entry.title, cls: 'substack-clipper-history-title' });
const meta = info.createDiv({ cls: 'substack-clipper-history-meta' }); const meta = info.createDiv({ cls: 'substack-clipper-history-meta' });
const userLine = meta.createDiv({ cls: 'substack-clipper-history-userline' }); const userLine = meta.createDiv({ cls: 'substack-clipper-history-userline' });
userLine.createEl('span', { text: entry.username, cls: 'substack-clipper-history-username' }); userLine.createSpan({ text: entry.username, cls: 'substack-clipper-history-username' });
if (entry.commentCount > 0) { if (entry.commentCount > 0) {
userLine.createEl('span', { userLine.createSpan({
text: String(entry.commentCount), text: String(entry.commentCount),
cls: 'substack-clipper-history-badge', cls: 'substack-clipper-history-badge',
}); });
} }
meta.createEl('span', { meta.createSpan({
text: new Date(entry.dateSaved).toLocaleDateString(), text: new Date(entry.dateSaved).toLocaleDateString(),
cls: 'substack-clipper-history-date', cls: 'substack-clipper-history-date',
}); });
@ -191,17 +215,27 @@ export class HistoryView extends ItemView {
this.selectedEntries.delete(idx); this.selectedEntries.delete(idx);
} }
} }
this.syncRedownloadBtn();
}); });
} }
private async redownloadComments(btn: HTMLButtonElement): Promise<void> { private setViewDisabled(disabled: boolean): void {
this.contentEl.toggleClass('is-disabled', disabled);
const inputs = Array.from(this.contentEl.querySelectorAll<HTMLInputElement | HTMLButtonElement>('input, button'));
for (const el of inputs) {
el.disabled = disabled;
}
}
private async redownloadComments(): Promise<void> {
if (this.selectedEntries.size === 0) { if (this.selectedEntries.size === 0) {
new Notice('No entries selected.'); new Notice('No entries selected.');
return; return;
} }
btn.disabled = true; this.setViewDisabled(true);
this.statusEl.setText('Downloading...'); const notice = new Notice('Re-downloading comments...', 0);
notice.containerEl.addClass('is-loading');
const history = this.plugin.settings.history; const history = this.plugin.settings.history;
const indices = [...this.selectedEntries]; const indices = [...this.selectedEntries];
@ -222,7 +256,22 @@ export class HistoryView extends ItemView {
`${this.plugin.settings.saveDirectory}/${entry.username}/${entry.slug}-comments.md`, `${this.plugin.settings.saveDirectory}/${entry.username}/${entry.slug}-comments.md`,
); );
await this.plugin.writeFile(commentsPath, commentsMd); await this.plugin.writeFile(commentsPath, commentsMd);
entry.commentCount = commentsData.comments.length; entry.commentCount = countAllComments(commentsData);
const notePath = normalizePath(
`${this.plugin.settings.saveDirectory}/${entry.username}/${entry.slug}.md`,
);
const noteFile = this.app.vault.getAbstractFileByPath(notePath);
if (noteFile instanceof TFile) {
const content = await this.app.vault.read(noteFile);
const updated = content.replace(
/^comment-count: \d+$/m,
`comment-count: ${String(entry.commentCount)}`,
);
if (updated !== content) {
await this.app.vault.modify(noteFile, updated);
}
}
} }
entry.lastUpdated = new Date().toISOString(); entry.lastUpdated = new Date().toISOString();
succeeded++; succeeded++;
@ -233,18 +282,15 @@ export class HistoryView extends ItemView {
} }
await this.plugin.saveSettings(); await this.plugin.saveSettings();
notice.hide();
this.setViewDisabled(false);
this.renderList(); this.renderList();
btn.disabled = false;
if (failed === 0) { if (failed === 0) {
const msg = `Re-downloaded comments for ${String(succeeded)} posts`; new Notice(`Re-downloaded comments for ${String(succeeded)} posts`);
this.statusEl.setText(msg);
new Notice(msg);
} else { } else {
const total = succeeded + failed; const total = succeeded + failed;
const msg = `Re-downloaded ${String(succeeded)} of ${String(total)} (${String(failed)} failed)`; new Notice(`Re-downloaded ${String(succeeded)} of ${String(total)} (${String(failed)} failed)`);
this.statusEl.setText(msg);
new Notice(msg);
} }
} }
} }

View file

@ -8,7 +8,7 @@ import { fetchHtml, extractPreloads, parsePost, parseUrl, extractImages, extract
import { htmlToMarkdown } from './converter'; import { htmlToMarkdown } from './converter';
import { postprocessMarkdown } from './postprocess'; import { postprocessMarkdown } from './postprocess';
import { downloadAllMedia } from './downloader'; import { downloadAllMedia } from './downloader';
import { fetchComments, renderComments } from './comments'; import { countAllComments, fetchComments, renderComments } from './comments';
export default class SubstackClipperPlugin extends Plugin { export default class SubstackClipperPlugin extends Plugin {
settings: SubstackClipperSettings; settings: SubstackClipperSettings;
@ -40,6 +40,18 @@ export default class SubstackClipperPlugin extends Plugin {
void this.activateHistoryView(); void this.activateHistoryView();
}, },
}); });
this.registerObsidianProtocolHandler('substack-clipper', (params) => {
const url = params.url;
if (!url || !url.includes('/p/')) {
new Notice('Invalid substack-clipper uri — a "URL" parameter containing /p/ is required.');
return;
}
const openAfterClip = params.open === 'true' ? true
: params.open === 'false' ? false
: this.settings.openAfterClip;
void this.clipPost(url, openAfterClip);
});
} }
async activateHistoryView(): Promise<void> { async activateHistoryView(): Promise<void> {
@ -64,6 +76,7 @@ export default class SubstackClipperPlugin extends Plugin {
private async clipPost(url: string, openAfterClip: boolean): Promise<void> { private async clipPost(url: string, openAfterClip: boolean): Promise<void> {
const notice = new Notice('Saving...', 0); const notice = new Notice('Saving...', 0);
notice.containerEl.addClass("is-loading");
try { try {
const { username, slug, domain } = parseUrl(url); const { username, slug, domain } = parseUrl(url);
@ -114,6 +127,7 @@ export default class SubstackClipperPlugin extends Plugin {
await this.writeFile(commentsJsonPath, JSON.stringify(commentsData, null, 2)); await this.writeFile(commentsJsonPath, JSON.stringify(commentsData, null, 2));
} }
article.commentCount = countAllComments(commentsData);
const commentsMd = renderComments(commentsData, slug, domain); const commentsMd = renderComments(commentsData, slug, domain);
const commentsPath = normalizePath(`${this.settings.saveDirectory}/${username}/${slug}-comments.md`); const commentsPath = normalizePath(`${this.settings.saveDirectory}/${username}/${slug}-comments.md`);
await this.writeFile(commentsPath, commentsMd); await this.writeFile(commentsPath, commentsMd);

View file

@ -19,16 +19,96 @@
gap: var(--size-4-2); gap: var(--size-4-2);
} }
.substack-clipper-history-search-container {
display: flex;
align-items: center;
gap: var(--size-4-1);
min-height: 32px;
padding: 0 var(--size-4-2);
background: var(--background-secondary);
border: var(--input-border-width) solid var(--background-modifier-border);
border-radius: var(--radius-m);
}
.substack-clipper-history-search-container:focus-within {
box-shadow: inset 0 0 0 1px var(--background-modifier-border-focus);
}
.substack-clipper-history-search-icon {
display: flex;
align-items: center;
color: var(--text-faint);
flex-shrink: 0;
}
.substack-clipper-history-search-icon svg {
width: 16px;
height: 16px;
}
.substack-clipper-history-search { .substack-clipper-history-search {
width: 100%; flex: 1;
padding: var(--size-4-2); min-width: 0;
border: 1px solid var(--background-modifier-border); padding: var(--size-4-1) 0;
border-radius: var(--radius-s); border: none;
background: var(--background-primary); border-radius: 0;
outline: none;
box-shadow: none;
background: transparent;
color: var(--text-normal); color: var(--text-normal);
font-size: var(--font-ui-medium); font-size: var(--font-ui-medium);
} }
.substack-clipper-history-search:hover,
.substack-clipper-history-search:focus,
.substack-clipper-history-search:focus-visible {
border: none;
outline: none;
box-shadow: none;
background: transparent;
}
.substack-clipper-history-view .substack-clipper-history-search-container input[type="text"] {
border: none;
outline: none;
box-shadow: none;
background: transparent;
}
.substack-clipper-history-search::placeholder {
color: var(--text-faint);
}
.substack-clipper-history-search-clear {
display: none;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
background: transparent;
color: var(--text-faint);
cursor: pointer;
flex-shrink: 0;
height: 20px;
width: 20px;
margin: auto;
padding: 0;
}
.substack-clipper-history-search-clear:hover {
color: var(--text-muted);
background: var(--background-modifier-hover);
}
.substack-clipper-history-search-clear.is-visible {
display: flex;
}
.substack-clipper-history-search-clear svg {
width: 14px;
height: 14px;
}
.substack-clipper-history-list { .substack-clipper-history-list {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@ -128,11 +208,9 @@
text-align: center; text-align: center;
} }
.substack-clipper-history-status { .substack-clipper-history-view.is-disabled {
padding: var(--size-4-2) 0; pointer-events: none;
color: var(--text-muted); opacity: 0.5;
font-size: var(--font-ui-small);
min-height: var(--size-4-4);
} }
.substack-clipper-history-footer { .substack-clipper-history-footer {

View file

@ -14,7 +14,9 @@
"DOM", "DOM",
"ES5", "ES5",
"ES6", "ES6",
"ES7" "ES7",
"ES2017.String",
"ES2020.Promise"
] ]
}, },
"include": [ "include": [

View file

@ -3,5 +3,12 @@
"0.1.2": "1.13.0", "0.1.2": "1.13.0",
"0.1.3": "1.13.0", "0.1.3": "1.13.0",
"0.1.4": "1.13.0", "0.1.4": "1.13.0",
"0.1.5": "1.13.0" "0.1.5": "1.13.0",
"0.1.6": "1.13.0",
"0.1.7": "1.13.0",
"0.1.8": "1.13.0",
"0.1.9": "1.13.0",
"0.1.10": "1.13.0",
"0.1.11": "1.13.0",
"0.1.12": "1.13.0"
} }