add all streams view

This commit is contained in:
Ben Floyd 2025-08-18 18:04:39 -06:00
parent a9a79beb98
commit 9ba5e5be75
5 changed files with 585 additions and 1 deletions

View file

@ -12,6 +12,7 @@
- Full calendar for each stream.
- New view for missing notes.
- New view for viewing all notes in a stream.
- "All Streams" dashboard view with statistics and quick actions.
## Getting started
- Add new stream in settings, "Daily Notes", pointing to where you store your daily notes. There will be a ribbon button and a command to get to Today's Daily Note. Enable Full Stream view to get a view into your stream history. Disable core Daily Note plugin.
@ -26,4 +27,15 @@
![Each Stream's Daily Note](assets/demo-today.gif)
## View Full Stream
![View Full Stream](assets/demo-full-stream.gif)
![View Full Stream](assets/demo-full-stream.gif)
## All Streams Dashboard
The new All Streams view provides a comprehensive overview of all your configured streams. Access it via:
- **Ribbon Button**: Click the dashboard icon in the left sidebar
- **Command Palette**: Use "Open All Streams View"
This view displays each stream as a card showing:
- Total number of files
- Files created this year and month
- Last modified date
- Quick action buttons to open today's note or view the full stream

34
main.ts
View file

@ -7,7 +7,9 @@ import { OpenTodayStreamCommand } from './src/commands/OpenTodayStreamCommand';
import { StreamSelectionModal } from './src/modals/StreamSelectionModal';
import { CREATE_FILE_VIEW_TYPE, CreateFileView } from './src/views/CreateFileView';
import { STREAM_VIEW_TYPE, StreamView } from './src/views/StreamView';
import { ALL_STREAMS_VIEW_TYPE, AllStreamsView } from './src/views/AllStreamsView';
import { OpenStreamViewCommand } from './src/commands/OpenStreamViewCommand';
import { OpenAllStreamsViewCommand } from './src/commands/OpenAllStreamsViewCommand';
const DEFAULT_SETTINGS: StreamsSettings = {
streams: [],
@ -36,6 +38,7 @@ export default class StreamsPlugin extends Plugin {
this.initializeMobileIntegration();
this.registerCalendarCommands();
this.registerLogCommands();
this.registerAllStreamsCommands();
this.addSettingTab(new StreamsSettingTab(this.app, this));
this.initializeActiveView();
@ -99,6 +102,12 @@ export default class StreamsPlugin extends Plugin {
STREAM_VIEW_TYPE,
(leaf) => this.createStreamViewFromState(leaf)
);
// Register AllStreamsView
this.registerView(
ALL_STREAMS_VIEW_TYPE,
(leaf) => new AllStreamsView(leaf, this.app)
);
}
private createStreamViewFromState(leaf: WorkspaceLeaf): StreamView {
@ -131,6 +140,9 @@ export default class StreamsPlugin extends Plugin {
}
private initializeAllRibbonIcons(): void {
// Create the main All Streams ribbon icon
this.createAllStreamsIcon();
// Create icons for all streams (even if hidden)
this.settings.streams.forEach(stream => {
this.createStreamIcons(stream);
@ -140,6 +152,17 @@ export default class StreamsPlugin extends Plugin {
this.updateAllIconVisibility();
}
private createAllStreamsIcon(): void {
this.addRibbonIcon(
'layout-dashboard',
'Streams: View All Streams',
() => {
const command = new OpenAllStreamsViewCommand(this.app);
command.execute();
}
);
}
private createStreamIcons(stream: Stream): void {
// Get or create entry for this stream
let streamIcons = this.ribbonIconsByStream.get(stream.id);
@ -743,6 +766,17 @@ export default class StreamsPlugin extends Plugin {
});
}
private registerAllStreamsCommands(): void {
this.addCommand({
id: 'open-all-streams-view',
name: 'Open All Streams View',
callback: () => {
const command = new OpenAllStreamsViewCommand(this.app);
command.execute();
}
});
}
private registerCalendarCommands(): void {
this.addCommand({
id: 'toggle-calendar-component',

View file

@ -0,0 +1,33 @@
import { App, WorkspaceLeaf } from 'obsidian';
import { ALL_STREAMS_VIEW_TYPE, AllStreamsView } from '../views/AllStreamsView';
export class OpenAllStreamsViewCommand {
private app: App;
constructor(app: App) {
this.app = app;
}
async execute(): Promise<void> {
// Look for existing All Streams view
let leaf: WorkspaceLeaf | null = null;
const existingLeaves = this.app.workspace.getLeavesOfType(ALL_STREAMS_VIEW_TYPE);
if (existingLeaves.length > 0) {
// Reuse existing leaf
leaf = existingLeaves[0];
} else {
// Create new leaf
leaf = this.app.workspace.getLeaf('tab');
}
// Set the view state
await leaf.setViewState({
type: ALL_STREAMS_VIEW_TYPE,
state: {}
});
// Activate the leaf
this.app.workspace.setActiveLeaf(leaf, { focus: true });
}
}

292
src/views/AllStreamsView.ts Normal file
View file

@ -0,0 +1,292 @@
import { App, ItemView, TFile, TFolder, WorkspaceLeaf } from 'obsidian';
import { Stream } from '../../types';
import { Logger } from '../utils/Logger';
export const ALL_STREAMS_VIEW_TYPE = 'streams-all-streams-view';
export class AllStreamsView extends ItemView {
public app: App;
private log: Logger;
private streamsContainer: HTMLElement;
private streams: Stream[] = [];
constructor(leaf: WorkspaceLeaf, app: App) {
super(leaf);
this.app = app;
this.log = new Logger();
}
getViewType(): string {
return ALL_STREAMS_VIEW_TYPE;
}
getDisplayText(): string {
return 'All Streams';
}
getIcon(): string {
return 'layout-dashboard';
}
async onOpen(): Promise<void> {
this.log.debug('Opening All Streams view');
const container = this.containerEl.children[1];
container.empty();
container.addClass('streams-all-streams-container');
// Get streams from plugin settings
const plugin = this.app.plugins.plugins['streams'];
if (plugin) {
this.streams = plugin.settings.streams;
}
// Create header
const header = container.createDiv('streams-all-streams-header');
header.createEl('h1', { text: 'All Streams' });
header.createEl('p', {
text: `You have ${this.streams.length} stream${this.streams.length !== 1 ? 's' : ''} configured`,
cls: 'streams-all-streams-subtitle'
});
// Create streams container
this.streamsContainer = container.createDiv('streams-all-streams-grid');
// Load and display streams
await this.loadStreamsData();
}
private async loadStreamsData(): Promise<void> {
if (this.streams.length === 0) {
this.showNoStreamsMessage();
return;
}
// Clear existing content
this.streamsContainer.empty();
// Create cards for each stream
for (const stream of this.streams) {
const card = await this.createStreamCard(stream);
this.streamsContainer.appendChild(card);
}
}
private async createStreamCard(stream: Stream): Promise<HTMLElement> {
const card = document.createElement('div');
card.className = 'streams-all-streams-card';
// Get stream statistics
const stats = await this.getStreamStats(stream);
// Create card header
const header = card.createDiv('streams-all-streams-card-header');
const icon = header.createDiv('streams-all-streams-card-icon');
// Use a simple icon representation
icon.innerHTML = `<span class="streams-all-streams-card-icon-text">${stream.icon.charAt(0).toUpperCase()}</span>`;
const title = header.createEl('h3', { text: stream.name });
title.className = 'streams-all-streams-card-title';
// Create card content
const content = card.createDiv('streams-all-streams-card-content');
// Folder path
const folderInfo = content.createDiv('streams-all-streams-card-folder');
folderInfo.innerHTML = `<strong>Folder:</strong> <code>${stream.folder || 'Root'}</code>`;
// Statistics
const statsContainer = content.createDiv('streams-all-streams-card-stats');
// Total files
const totalFiles = statsContainer.createDiv('streams-all-streams-stat');
totalFiles.innerHTML = `<span class="streams-all-streams-stat-label">Total Files:</span> <span class="streams-all-streams-stat-value">${stats.totalFiles}</span>`;
// This year files
const thisYearFiles = statsContainer.createDiv('streams-all-streams-stat');
thisYearFiles.innerHTML = `<span class="streams-all-streams-stat-label">This Year:</span> <span class="streams-all-streams-stat-value">${stats.thisYearFiles}</span>`;
// This month files
const thisMonthFiles = statsContainer.createDiv('streams-all-streams-stat');
thisMonthFiles.innerHTML = `<span class="streams-all-streams-stat-label">This Month:</span> <span class="streams-all-streams-stat-value">${stats.thisMonthFiles}</span>`;
// Last modified
if (stats.lastModified) {
const lastModified = statsContainer.createDiv('streams-all-streams-stat');
lastModified.innerHTML = `<span class="streams-all-streams-stat-label">Last Modified:</span> <span class="streams-all-streams-stat-value">${stats.lastModified}</span>`;
}
// Create card actions
const actions = card.createDiv('streams-all-streams-card-actions');
// Today button
const todayBtn = actions.createEl('button', { text: 'Today' });
todayBtn.className = 'streams-all-streams-card-btn streams-all-streams-card-btn-primary';
todayBtn.addEventListener('click', () => {
this.openTodayStream(stream);
});
// View All button
const viewAllBtn = actions.createEl('button', { text: 'View All' });
viewAllBtn.className = 'streams-all-streams-card-btn streams-all-streams-card-btn-secondary';
viewAllBtn.addEventListener('click', () => {
this.openStreamView(stream);
});
return card;
}
private async getStreamStats(stream: Stream): Promise<{
totalFiles: number;
thisYearFiles: number;
thisMonthFiles: number;
lastModified: string | null;
}> {
try {
if (!stream.folder) {
return { totalFiles: 0, thisYearFiles: 0, thisMonthFiles: 0, lastModified: null };
}
const folder = this.app.vault.getAbstractFileByPath(stream.folder);
if (!(folder instanceof TFolder)) {
return { totalFiles: 0, thisYearFiles: 0, thisMonthFiles: 0, lastModified: null };
}
const files = this.getFilesInFolder(folder);
const totalFiles = files.length;
const now = new Date();
const thisYear = now.getFullYear();
const thisMonth = now.getMonth();
let thisYearFiles = 0;
let thisMonthFiles = 0;
let lastModified: Date | null = null;
for (const file of files) {
if (file instanceof TFile) {
// Check if it's a date-based file (YYYY-MM-DD.md)
const dateMatch = file.basename.match(/^\d{4}-\d{2}-\d{2}$/);
if (dateMatch) {
const fileDate = new Date(file.basename);
if (fileDate.getFullYear() === thisYear) {
thisYearFiles++;
}
if (fileDate.getFullYear() === thisYear && fileDate.getMonth() === thisMonth) {
thisMonthFiles++;
}
}
// Track last modified
if (!lastModified || file.stat.mtime > lastModified.getTime()) {
lastModified = new Date(file.stat.mtime);
}
}
}
return {
totalFiles,
thisYearFiles,
thisMonthFiles,
lastModified: lastModified ? this.formatDate(lastModified) : null
};
} catch (error) {
this.log.error('Error getting stream stats:', error);
return { totalFiles: 0, thisYearFiles: 0, thisMonthFiles: 0, lastModified: null };
}
}
private getFilesInFolder(folder: TFolder): TFile[] {
const files: TFile[] = [];
function recurseFolder(currentFolder: TFolder) {
for (const child of currentFolder.children) {
if (child instanceof TFile) {
files.push(child);
} else if (child instanceof TFolder) {
recurseFolder(child);
}
}
}
recurseFolder(folder);
return files;
}
private formatDate(date: Date): string {
const now = new Date();
const diffTime = Math.abs(now.getTime() - date.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return 'Today';
} else if (diffDays === 1) {
return 'Yesterday';
} else if (diffDays < 7) {
return `${diffDays} days ago`;
} else if (diffDays < 30) {
const weeks = Math.floor(diffDays / 7);
return `${weeks} week${weeks !== 1 ? 's' : ''} ago`;
} else {
return date.toLocaleDateString();
}
}
private showNoStreamsMessage(): void {
this.streamsContainer.innerHTML = `
<div class="streams-all-streams-empty">
<div class="streams-all-streams-empty-icon">📝</div>
<h3>No Streams Configured</h3>
<p>You haven't configured any streams yet. Go to Settings Streams to add your first stream.</p>
</div>
`;
}
private openTodayStream(stream: Stream): void {
// Use the existing command to open today's stream
try {
this.app.commands.executeCommandById(`open-${stream.id}`);
} catch (error) {
this.log.error('Error executing today command:', error);
// Fallback: try to open the stream directly
this.openStreamDate(stream, new Date());
}
}
private openStreamView(stream: Stream): void {
// Use the existing command to open the full stream view
try {
this.app.commands.executeCommandById(`view-${stream.id}`);
} catch (error) {
this.log.error('Error executing view command:', error);
// Fallback: try to open the stream view directly
this.openStreamViewDirect(stream);
}
}
private async openStreamDate(stream: Stream, date: Date): Promise<void> {
// Direct fallback for opening stream date
const plugin = this.app.plugins.plugins['streams'];
if (plugin) {
// Import and use the utility function directly
const { openStreamDate } = await import('../utils/streamUtils');
openStreamDate(this.app, stream, date);
}
}
private openStreamViewDirect(stream: Stream): void {
// Direct fallback for opening stream view
const plugin = this.app.plugins.plugins['streams'];
if (plugin) {
// Import and use the command directly
import('../commands/OpenStreamViewCommand').then(({ OpenStreamViewCommand }) => {
const command = new OpenStreamViewCommand(this.app, stream);
command.execute();
});
}
}
async onClose(): Promise<void> {
this.log.debug('Closing All Streams view');
}
}

View file

@ -1,3 +1,216 @@
/*********************************************************
* ALL STREAMS VIEW STYLES
*********************************************************/
.streams-all-streams-container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.streams-all-streams-header {
text-align: center;
margin-bottom: 40px;
}
.streams-all-streams-header h1 {
font-size: 2.5em;
margin-bottom: 10px;
color: var(--text-normal);
}
.streams-all-streams-subtitle {
font-size: 1.2em;
color: var(--text-muted);
margin: 0;
}
.streams-all-streams-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 24px;
margin-bottom: 40px;
}
.streams-all-streams-card {
background: var(--background-secondary);
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border: 1px solid var(--background-modifier-border);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.streams-all-streams-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
border-color: var(--text-accent);
}
.streams-all-streams-card-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--background-modifier-border);
}
.streams-all-streams-card-icon {
width: 48px;
height: 48px;
background: var(--background-primary);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-accent);
border: 2px solid var(--background-modifier-border);
}
.streams-all-streams-card-icon-text {
font-size: 20px;
font-weight: bold;
color: var(--text-accent);
}
.streams-all-streams-card-title {
margin: 0;
font-size: 1.4em;
font-weight: 600;
color: var(--text-normal);
flex: 1;
}
.streams-all-streams-card-content {
margin-bottom: 24px;
}
.streams-all-streams-card-folder {
margin-bottom: 20px;
padding: 12px;
background: var(--background-primary);
border-radius: 8px;
border: 1px solid var(--background-modifier-border);
font-family: var(--font-monospace);
font-size: 0.9em;
}
.streams-all-streams-card-folder code {
background: var(--background-secondary);
padding: 2px 6px;
border-radius: 4px;
color: var(--text-accent);
}
.streams-all-streams-card-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.streams-all-streams-stat {
display: flex;
flex-direction: column;
gap: 4px;
}
.streams-all-streams-stat-label {
font-size: 0.85em;
color: var(--text-muted);
font-weight: 500;
}
.streams-all-streams-stat-value {
font-size: 1.2em;
font-weight: 600;
color: var(--text-normal);
}
.streams-all-streams-card-actions {
display: flex;
gap: 12px;
}
.streams-all-streams-card-btn {
flex: 1;
padding: 10px 16px;
border: none;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.9em;
}
.streams-all-streams-card-btn-primary {
background: var(--text-accent);
color: var(--text-on-accent);
}
.streams-all-streams-card-btn-primary:hover {
background: var(--text-accent-hover);
transform: translateY(-1px);
}
.streams-all-streams-card-btn-secondary {
background: var(--background-primary);
color: var(--text-normal);
border: 1px solid var(--background-modifier-border);
}
.streams-all-streams-card-btn-secondary:hover {
background: var(--background-secondary);
border-color: var(--text-accent);
}
.streams-all-streams-empty {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
}
.streams-all-streams-empty-icon {
font-size: 4em;
margin-bottom: 20px;
opacity: 0.5;
}
.streams-all-streams-empty h3 {
margin-bottom: 16px;
color: var(--text-normal);
}
.streams-all-streams-empty p {
font-size: 1.1em;
line-height: 1.6;
max-width: 400px;
margin: 0 auto;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.streams-all-streams-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.streams-all-streams-card {
padding: 20px;
}
.streams-all-streams-card-stats {
grid-template-columns: 1fr;
gap: 12px;
}
.streams-all-streams-card-actions {
flex-direction: column;
}
}
/*********************************************************
* LAYOUT & CONTAINER STYLES
*********************************************************/