feat: convert save history from modal to sidebar view with select all

This commit is contained in:
@gapmiss 2026-06-20 15:10:43 -05:00
parent 8517794292
commit b5aaa9a1a9
3 changed files with 124 additions and 23 deletions

View file

@ -1,25 +1,37 @@
import { Modal, normalizePath, Notice, setIcon, TFile } from 'obsidian';
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';
export class HistoryModal extends Modal {
export const HISTORY_VIEW_TYPE = 'substack-clipper-history';
export class HistoryView extends ItemView {
private plugin: SubstackClipperPlugin;
private filterText = '';
private listEl: HTMLElement;
private statusEl: HTMLElement;
private selectedEntries: Set<number> = new Set();
constructor(plugin: SubstackClipperPlugin) {
super(plugin.app);
constructor(leaf: WorkspaceLeaf, plugin: SubstackClipperPlugin) {
super(leaf);
this.plugin = plugin;
}
onOpen(): void {
this.setTitle('Substack save history');
this.modalEl.addClass('substack-clipper-history-modal');
getViewType(): string {
return HISTORY_VIEW_TYPE;
}
getDisplayText(): string {
return 'Save history';
}
getIcon(): string {
return 'history';
}
async onOpen(): Promise<void> {
const { contentEl } = this;
contentEl.addClass('substack-clipper-history-view');
const searchInput = contentEl.createEl('input', {
type: 'text',
@ -51,6 +63,14 @@ export class HistoryModal extends Modal {
this.renderList();
}
async onClose(): Promise<void> {
this.contentEl.empty();
}
refresh(): void {
this.renderList();
}
private getFilteredEntries(): HistoryEntry[] {
const reversed = [...this.plugin.settings.history].reverse();
if (!this.filterText) return reversed;
@ -68,29 +88,49 @@ export class HistoryModal extends Modal {
if (entries.length === 0) {
this.listEl.createDiv({
text: 'No matching entries.',
text: this.plugin.settings.history.length === 0
? 'No saved posts yet.'
: 'No matching entries.',
cls: 'substack-clipper-history-empty',
});
return;
}
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', {
text: `${String(entries.length)} ${entries.length === 1 ? 'entry' : 'entries'}`,
cls: 'substack-clipper-history-count',
});
const rowCheckboxes: { checkbox: HTMLInputElement; idx: number }[] = [];
for (const entry of entries) {
const row = this.listEl.createDiv({ cls: 'substack-clipper-history-row' });
const checkbox = row.createEl('input', { type: 'checkbox' });
checkbox.setAttribute('aria-label', `Select ${entry.title}`);
const idx = this.plugin.settings.history.indexOf(entry);
rowCheckboxes.push({ checkbox, idx });
checkbox.addEventListener('change', () => {
if (checkbox.checked) {
this.selectedEntries.add(idx);
} else {
this.selectedEntries.delete(idx);
}
const total = rowCheckboxes.length;
const checked = rowCheckboxes.filter(r => r.checkbox.checked).length;
selectAllCb.checked = checked === total;
selectAllCb.indeterminate = checked > 0 && checked < total;
});
const info = row.createDiv({ cls: 'substack-clipper-history-info' });
info.createEl('span', { text: entry.title, cls: 'substack-clipper-history-title' });
const userLine = info.createDiv({ cls: 'substack-clipper-history-userline' });
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' });
if (entry.commentCount > 0) {
userLine.createEl('span', {
@ -99,12 +139,12 @@ export class HistoryModal extends Modal {
});
}
row.createEl('span', {
meta.createEl('span', {
text: new Date(entry.dateSaved).toLocaleDateString(),
cls: 'substack-clipper-history-date',
});
const actions = row.createDiv({ cls: 'substack-clipper-history-actions' });
const actions = meta.createDiv({ cls: 'substack-clipper-history-actions' });
const noteBtn = actions.createEl('button', { cls: 'clickable-icon' });
noteBtn.setAttribute('aria-label', 'Open saved note');
@ -117,7 +157,6 @@ export class HistoryModal extends Modal {
const file = this.app.vault.getAbstractFileByPath(notePath);
if (file instanceof TFile) {
void this.app.workspace.getLeaf(false).openFile(file);
this.close();
} else {
new Notice('Note not found in vault.');
}
@ -141,6 +180,18 @@ export class HistoryModal extends Modal {
this.renderList();
});
}
selectAllCb.addEventListener('change', () => {
const checked = selectAllCb.checked;
for (const { checkbox, idx } of rowCheckboxes) {
checkbox.checked = checked;
if (checked) {
this.selectedEntries.add(idx);
} else {
this.selectedEntries.delete(idx);
}
}
});
}
private async redownloadComments(btn: HTMLButtonElement): Promise<void> {

View file

@ -3,7 +3,7 @@ import { type SubstackClipperSettings, DEFAULT_SETTINGS } from './types';
import type { ParsedArticle, DownloadResult, HistoryEntry } from './types';
import { SettingsTab } from './settings';
import { ClipModal } from './modal';
import { HistoryModal } from './history-modal';
import { HistoryView, HISTORY_VIEW_TYPE } from './history-view';
import { fetchHtml, extractPreloads, parsePost, parseUrl, extractImages, extractVideos, extractAudios, extractAttachments } from './parser';
import { htmlToMarkdown } from './converter';
import { postprocessMarkdown } from './postprocess';
@ -17,6 +17,8 @@ export default class SubstackClipperPlugin extends Plugin {
await this.loadSettings();
this.addSettingTab(new SettingsTab(this.app, this));
this.registerView(HISTORY_VIEW_TYPE, (leaf) => new HistoryView(leaf, this));
this.addCommand({
id: 'clip-post',
name: 'Save substack post',
@ -33,13 +35,25 @@ export default class SubstackClipperPlugin extends Plugin {
this.addCommand({
id: 'view-history',
name: 'View history',
name: 'View save history',
callback: () => {
new HistoryModal(this).open();
void this.activateHistoryView();
},
});
}
async activateHistoryView(): Promise<void> {
const { workspace } = this.app;
let leaf = workspace.getLeavesOfType(HISTORY_VIEW_TYPE)[0];
if (!leaf) {
const rightLeaf = workspace.getRightLeaf(false);
if (!rightLeaf) return;
await rightLeaf.setViewState({ type: HISTORY_VIEW_TYPE, active: true });
leaf = rightLeaf;
}
await workspace.revealLeaf(leaf);
}
async loadSettings(): Promise<void> {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial<SubstackClipperSettings>);
}
@ -228,6 +242,12 @@ export default class SubstackClipperPlugin extends Plugin {
}
await this.saveSettings();
for (const leaf of this.app.workspace.getLeavesOfType(HISTORY_VIEW_TYPE)) {
if (leaf.view instanceof HistoryView) {
leaf.view.refresh();
}
}
} catch (e) {
notice.hide();
new Notice(`Save failed: ${e instanceof Error ? e.message : String(e)}`);

View file

@ -11,10 +11,17 @@
width: 60px;
}
.substack-clipper-history-view {
display: flex;
flex-direction: column;
height: 100%;
padding: var(--size-4-3);
gap: var(--size-4-2);
}
.substack-clipper-history-search {
width: 100%;
padding: var(--size-4-2);
margin-bottom: var(--size-4-2);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
background: var(--background-primary);
@ -23,16 +30,33 @@
}
.substack-clipper-history-list {
max-height: 400px;
flex: 1;
overflow-y: auto;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
}
.substack-clipper-history-row {
.substack-clipper-history-header {
display: flex;
align-items: center;
gap: var(--size-4-2);
padding: var(--size-4-1) var(--size-4-3);
border-bottom: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
position: sticky;
top: 0;
z-index: 1;
}
.substack-clipper-history-count {
color: var(--text-muted);
font-size: var(--font-ui-smaller);
}
.substack-clipper-history-row {
display: flex;
align-items: flex-start;
gap: var(--size-4-2);
padding: var(--size-4-2) var(--size-4-3);
min-height: 44px;
border-bottom: 1px solid var(--background-modifier-border);
@ -47,19 +71,24 @@
flex-direction: column;
flex: 1;
min-width: 0;
gap: var(--size-4-1);
}
.substack-clipper-history-title {
font-weight: var(--font-semibold);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.substack-clipper-history-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--size-4-1) var(--size-4-2);
}
.substack-clipper-history-userline {
display: flex;
align-items: center;
gap: var(--size-4-2);
gap: var(--size-4-1);
}
.substack-clipper-history-username {
@ -88,8 +117,9 @@
.substack-clipper-history-actions {
display: flex;
gap: var(--size-4-1);
gap: 0;
flex-shrink: 0;
margin-left: auto;
}
.substack-clipper-history-empty {