Compare commits

...

12 commits
0.1.6 ... main

10 changed files with 5340 additions and 5253 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]]`.
### 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
| Setting | Default | Description |

View file

@ -1,7 +1,7 @@
{
"id": "substack-clipper",
"name": "Substack Clipper",
"version": "0.1.6",
"version": "0.1.12",
"minAppVersion": "1.13.0",
"description": "Archive Substack posts as Markdown with images, media, and comments.",
"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",
"version": "0.1.6",
"version": "0.1.12",
"description": "Clip substack articles",
"main": "main.js",
"scripts": {
@ -26,7 +26,7 @@
"esbuild": "^0.28.1",
"eslint": "^9.30.1",
"eslint-plugin-no-unsanitized": "^4.1.2",
"eslint-plugin-obsidianmd": "^0.3.0",
"eslint-plugin-obsidianmd": "^0.4.1",
"globals": "^14.0.0",
"jiti": "^2.6.1",
"tslib": "^2.4.0",

View file

@ -94,6 +94,21 @@ function renderComment(
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(
data: CommentsResponse,
slug: string,

View file

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

View file

@ -1,7 +1,7 @@
import { ItemView, normalizePath, Notice, setIcon, TFile, type WorkspaceLeaf } from 'obsidian';
import type SubstackClipperPlugin from './main';
import type { HistoryEntry } from './types';
import { fetchComments, renderComments } from './comments';
import { countAllComments, fetchComments, renderComments } from './comments';
export const HISTORY_VIEW_TYPE = 'substack-clipper-history';
@ -122,7 +122,7 @@ export class HistoryView extends ItemView {
const header = this.listEl.createDiv({ cls: 'substack-clipper-history-header' });
const selectAllCb = header.createEl('input', { type: 'checkbox' });
selectAllCb.setAttribute('aria-label', 'Select all entries');
header.createEl('span', {
header.createSpan({
text: `${String(entries.length)} ${entries.length === 1 ? 'entry' : 'entries'}`,
cls: 'substack-clipper-history-count',
});
@ -151,19 +151,19 @@ export class HistoryView extends ItemView {
});
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 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) {
userLine.createEl('span', {
userLine.createSpan({
text: String(entry.commentCount),
cls: 'substack-clipper-history-badge',
});
}
meta.createEl('span', {
meta.createSpan({
text: new Date(entry.dateSaved).toLocaleDateString(),
cls: 'substack-clipper-history-date',
});
@ -256,7 +256,22 @@ export class HistoryView extends ItemView {
`${this.plugin.settings.saveDirectory}/${entry.username}/${entry.slug}-comments.md`,
);
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();
succeeded++;

View file

@ -8,7 +8,7 @@ import { fetchHtml, extractPreloads, parsePost, parseUrl, extractImages, extract
import { htmlToMarkdown } from './converter';
import { postprocessMarkdown } from './postprocess';
import { downloadAllMedia } from './downloader';
import { fetchComments, renderComments } from './comments';
import { countAllComments, fetchComments, renderComments } from './comments';
export default class SubstackClipperPlugin extends Plugin {
settings: SubstackClipperSettings;
@ -40,6 +40,18 @@ export default class SubstackClipperPlugin extends Plugin {
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> {
@ -115,6 +127,7 @@ export default class SubstackClipperPlugin extends Plugin {
await this.writeFile(commentsJsonPath, JSON.stringify(commentsData, null, 2));
}
article.commentCount = countAllComments(commentsData);
const commentsMd = renderComments(commentsData, slug, domain);
const commentsPath = normalizePath(`${this.settings.saveDirectory}/${username}/${slug}-comments.md`);
await this.writeFile(commentsPath, commentsMd);

View file

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

View file

@ -4,5 +4,11 @@
"0.1.3": "1.13.0",
"0.1.4": "1.13.0",
"0.1.5": "1.13.0",
"0.1.6": "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"
}