mirror of
https://github.com/bfloydd/streams.git
synced 2026-07-22 12:50:25 +00:00
Compare commits
54 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab2e58bd85 | ||
|
|
a9cde02c78 | ||
|
|
37eeb11247 | ||
|
|
f1afd09bda | ||
|
|
f85a795dcc | ||
|
|
4e109b0798 | ||
|
|
d7ce138f91 | ||
|
|
24c52fa626 | ||
|
|
a120018138 | ||
|
|
ee989d755c | ||
|
|
2766228af4 | ||
|
|
4577611ce5 | ||
|
|
8eebf31015 | ||
|
|
335e05567f | ||
|
|
cd83a82c58 | ||
|
|
74cf520b4a | ||
|
|
c91b2a1460 | ||
|
|
5dfdc808ea | ||
|
|
6d0d067071 | ||
|
|
3866e55779 | ||
|
|
f6f92ce247 | ||
|
|
654ba6f8f7 | ||
|
|
ae909be484 | ||
|
|
5c489e3bb3 | ||
|
|
748bb00644 | ||
|
|
7bedfbdd9c | ||
|
|
0a23247f59 | ||
|
|
2c7bbfd1fa | ||
|
|
2672340764 | ||
|
|
d82be7a37a | ||
|
|
c751370c60 | ||
|
|
46d2ed166c | ||
|
|
1f3999373c | ||
|
|
d821571b34 | ||
|
|
8565102a2e | ||
|
|
140aa5075f | ||
|
|
65626b04b0 | ||
|
|
025403010b | ||
|
|
47079fa96a | ||
|
|
1639c47070 | ||
|
|
f2d0d17248 | ||
|
|
20ddade42b | ||
|
|
4649080bad | ||
|
|
26317ba5ba | ||
|
|
cff316754e | ||
|
|
c39eff0189 | ||
|
|
4a982b85bc | ||
|
|
840f9854c6 | ||
|
|
6a20ed1c50 | ||
|
|
5ac2fb2227 | ||
|
|
2c21e67c6d | ||
|
|
c6787712aa | ||
|
|
490b368446 | ||
|
|
a69e1e57a4 |
49 changed files with 1919 additions and 671 deletions
41
.agent/skills/obsidian-plugin/SKILL.md
Normal file
41
.agent/skills/obsidian-plugin/SKILL.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
name: managing-obsidian-plugin
|
||||
description: Manages the development, refactoring, and maintenance of an Obsidian plugin coding project. Use when the user requests feature additions, bug fixes, UI improvements, or refactoring in the Obsidian plugin codebase.
|
||||
---
|
||||
|
||||
# Managing Obsidian Plugin
|
||||
|
||||
## When to use this skill
|
||||
- Adding new features or settings to the Obsidian plugin.
|
||||
- Refactoring existing plugin components (e.g., UI, logic).
|
||||
- Fixing bugs or testing plugin functionality.
|
||||
- Managing Obsidian API interactions.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Plan & Analyze
|
||||
- [ ] Read relevant workspace files to locate the affected components (e.g., `main.ts`, view files, or service classes).
|
||||
- [ ] Check `manifest.json` for required plugin version bumps if deploying a new release.
|
||||
- [ ] Identify dependencies between UI components, styles, and core logic.
|
||||
|
||||
### 2. Validate
|
||||
- [ ] Validate changes against existing test suites using relevant testing scripts.
|
||||
- [ ] Check if the changes necessitate an update to settings menus or serialized data structures.
|
||||
|
||||
### 3. Execute
|
||||
- [ ] Apply code changes using progressive and isolated module updates.
|
||||
- [ ] Run `npm run build` to verify compilation without errors.
|
||||
|
||||
## Instructions
|
||||
- Keep interactions with the Obsidian API well-isolated and properly typed.
|
||||
- Prioritize updating or creating unit tests alongside feature development to verify logic safely.
|
||||
- If a build script fails, do not proceed blindly; troubleshoot the specific error output before continuing.
|
||||
|
||||
## Architecture
|
||||
- We use a vertical slice architecture.
|
||||
- Strict adherance to cleancode rules.
|
||||
- Use the provided .agent/skills/obsidian-plugin/SKILL.md as the main source of truth for coding standards and best practices.
|
||||
- Use ADRs to document design decisions in adr/. Refer to the ADRs folder for more information and ensure compliance with existing ADRs.
|
||||
|
||||
## Resources
|
||||
- None
|
||||
47
.github/workflows/release.yml
vendored
Normal file
47
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
name: Release Obsidian Plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build plugin
|
||||
run: npm run build
|
||||
|
||||
- name: Attest build provenance
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-path: |
|
||||
dist/main.js
|
||||
dist/manifest.json
|
||||
dist/styles.css
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
dist/main.js
|
||||
dist/manifest.json
|
||||
dist/styles.css
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
57
CONTRIBUTING.md
Normal file
57
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Contributing to Streams
|
||||
|
||||
Welcome! We gladly accept contributions from the community. If you'd like to help improve Streams, follow the steps below.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Fork the repository** and clone it to your local machine.
|
||||
2. **Install dependencies** by running `npm install`.
|
||||
3. **Create a new branch** for your feature or bug fix (`git checkout -b feature/your-feature-name`).
|
||||
4. **Make your changes**. You can use `npm run dev` to watch for changes and compile the plugin, then test them locally in your Obsidian vault.
|
||||
5. **Commit your changes** with clear, descriptive commit messages.
|
||||
6. **Push to your fork** and open a Pull Request against our `main` branch.
|
||||
|
||||
All Pull Requests will be reviewed and merged by the maintainer.
|
||||
|
||||
---
|
||||
|
||||
## Maintainer Guide: Releasing a New Version
|
||||
*(The following instructions are for project maintainers only)*
|
||||
|
||||
The release process is fully automated via GitHub Actions. **You do not need to build the plugin or generate attestations manually.**
|
||||
|
||||
Whenever a new tag is pushed, a background GitHub Action will automatically:
|
||||
- **Build the plugin** (`main.js`, `styles.css`) from source.
|
||||
- **Generate cryptographic artifact attestations** for security and provenance.
|
||||
- **Publish a GitHub Release** and attach the built artifacts.
|
||||
|
||||
Before triggering a release, **you must bump the version numbers**:
|
||||
1. Run `npm run version` (or manually update `manifest.json` and `versions.json`).
|
||||
2. Commit and push the changes to the `main` branch.
|
||||
|
||||
Once the versions are bumped, you can trigger the automated release using either the GitHub UI or the command line.
|
||||
|
||||
### Option 1: Using the GitHub Releases UI (Recommended)
|
||||
This is the simplest method if you prefer a visual interface.
|
||||
|
||||
1. Navigate to the **Releases** page on your GitHub repository.
|
||||
2. Click the **Draft a new release** button.
|
||||
3. In the "Choose a tag" dropdown, type your new version tag (e.g., `1.0.6`) and click **Create new tag: 1.0.6 on publish**.
|
||||
4. (Optional) Fill out the release title and notes.
|
||||
5. Click **Publish release**.
|
||||
|
||||
> **Note:** As soon as you click publish, GitHub will trigger the Action in the background. Navigate to the **Actions** tab to watch it build, sign, and attach the files to your newly drafted release!
|
||||
|
||||
### Option 2: Using the Command Line (CLI)
|
||||
If you prefer terminal commands, you can push the tag directly via Git.
|
||||
|
||||
1. Create a local tag for your new version:
|
||||
```bash
|
||||
git tag 1.0.6
|
||||
```
|
||||
2. Push the tag to GitHub:
|
||||
```bash
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
> **Note:** Pushing the tag automatically triggers the GitHub Action. The Action will build the assets and create the GitHub Release for you automatically. You can edit the release notes in the GitHub UI afterward if needed.
|
||||
63
adr/0001-leaf-selection-behavior.md
Normal file
63
adr/0001-leaf-selection-behavior.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# ADR-0001: Leaf selection behavior for stream navigation
|
||||
|
||||
**Date:** 2026-03-25
|
||||
**Status:** Accepted
|
||||
**Affects:** `LeafSelectionService.ts`, `RibbonService.ts`, `OpenTodayStreamCommand.ts`
|
||||
|
||||
## Context
|
||||
|
||||
When a user clicks a ribbon icon (or runs a command) to open a stream's daily note, the plugin must decide *which* workspace tab (leaf) to open the file in. Obsidian's workspace has three areas:
|
||||
|
||||
- **Main editor area** (`workspace.rootSplit`) — the central pane(s) where notes are edited
|
||||
- **Left sidebar** (`workspace.leftSplit`) — file explorer, bookmarks, etc.
|
||||
- **Right sidebar** (`workspace.rightSplit`) — backlinks, outgoing links, etc.
|
||||
|
||||
Prior to this decision, the plugin had several issues:
|
||||
|
||||
1. **Pinned tabs were overwritten** — clicking a ribbon icon while a pinned tab was active would load the stream note into that pinned tab, violating user intent.
|
||||
2. **New tabs were always created** — when the active tab couldn't be reused (e.g., it was pinned), the plugin created a brand-new tab rather than reusing an existing unpinned one, leading to tab proliferation.
|
||||
3. **Side panes could be targeted** — the leaf selection logic didn't distinguish between main editor leaves and sidebar leaves, so a stream note could open inside a sidebar panel.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Never navigate into pinned tabs
|
||||
|
||||
All leaf selection paths check `(leaf as any).pinned === true` before reusing a leaf. If the active leaf is pinned, it is skipped entirely.
|
||||
|
||||
### 2. Prefer reusing an existing unpinned tab over creating a new one
|
||||
|
||||
When the active leaf can't be used (pinned or in a side pane), `findNextUnpinnedLeaf()` scans the workspace for the first unpinned, eligible leaf in the main editor area. A new tab is only created as a last resort when no candidate is found.
|
||||
|
||||
### 3. Restrict all leaf selection to the main editor area
|
||||
|
||||
Every leaf candidate is checked via `leaf.getRoot() === app.workspace.rootSplit`. Leaves in left/right sidebars are never selected for navigation. This applies to:
|
||||
|
||||
- `findNextUnpinnedLeaf()` — only scans main editor leaves
|
||||
- `reuseCurrentLeaf()` — won't reuse the active leaf if it's in a sidebar
|
||||
- `selectOrCreateLeaf()` — same guard
|
||||
- `selectLeafForFile()` — won't match existing leaves in sidebars
|
||||
|
||||
### 4. Ctrl/Cmd+click forces a new tab
|
||||
|
||||
Ribbon icon callbacks capture the `MouseEvent`. When `ctrlKey` (Windows/Linux) or `metaKey` (macOS) is held, a fresh leaf is created via `app.workspace.getLeaf('tab')` and passed as `targetLeaf`, bypassing all reuse logic. This matches standard Obsidian/browser conventions.
|
||||
|
||||
## Leaf selection priority
|
||||
|
||||
For a normal click, the selection order is:
|
||||
|
||||
1. **Active leaf** — if it's unpinned, in the main editor area, and passes any view-type filter
|
||||
2. **Existing leaf with same file** — (for `selectLeafForFile` only) if already open in the main editor
|
||||
3. **Next unpinned leaf** — first unpinned leaf found in the main editor area
|
||||
4. **New tab** — created via `getLeaf('tab')` as a last resort
|
||||
|
||||
For Ctrl/Cmd+click:
|
||||
|
||||
1. **New tab** — always, via `getLeaf('tab')` passed as `targetLeaf`
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Pinned tabs are always respected.** Users can pin a tab and know it won't be overwritten by ribbon navigation.
|
||||
- **Tab count stays manageable.** Reusing unpinned tabs prevents accumulation of redundant tabs.
|
||||
- **Sidebar integrity is preserved.** File explorer, backlinks, and other sidebar views are never replaced with stream content.
|
||||
- **Ctrl+click provides an escape hatch.** Users who *do* want a new tab can explicitly request one.
|
||||
- **Relies on internal API.** The `pinned` property on `WorkspaceLeaf` is not part of Obsidian's public TypeScript API. This is a commonly used internal property but could change in future Obsidian versions.
|
||||
42
adr/0002-ribbon-click-behavior.md
Normal file
42
adr/0002-ribbon-click-behavior.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# ADR-0002: Ribbon click behavior
|
||||
|
||||
**Date:** 2026-03-25
|
||||
**Status:** Accepted
|
||||
**Affects:** `RibbonService.ts`, `OpenTodayStreamCommand.ts`, `OpenTodayPrimaryStreamCommand.ts`
|
||||
|
||||
## Context
|
||||
|
||||
Users interact with stream ribbon icons to quickly open a stream's daily note. Before this change, every click behaved identically — there was no way to control *how* the note opened (reuse a tab vs. open a new one) at click time. Users who wanted to open the same stream in a second tab had no mechanism to do so from the ribbon.
|
||||
|
||||
Standard Obsidian and browser conventions use **Ctrl+click** (or **Cmd+click** on macOS) to force-open a link in a new tab.
|
||||
|
||||
## Decision
|
||||
|
||||
### Normal click (no modifier)
|
||||
|
||||
Follows the reuse-first strategy defined in [ADR-0001](0001-leaf-selection-behavior.md):
|
||||
|
||||
1. Reuse the active leaf if it's unpinned and in the main editor area
|
||||
2. Otherwise find an existing unpinned leaf in the main editor area
|
||||
3. Create a new tab only as a last resort
|
||||
|
||||
This respects the user's `reuseCurrentTab` setting and keeps tab count manageable.
|
||||
|
||||
### Ctrl+click / Cmd+click
|
||||
|
||||
Always opens in a **new tab**, bypassing all reuse logic:
|
||||
|
||||
1. The ribbon callback captures the `MouseEvent`
|
||||
2. If `evt.ctrlKey` (Windows/Linux) or `evt.metaKey` (macOS) is `true`, a fresh leaf is created via `app.workspace.getLeaf('tab')`
|
||||
3. This leaf is passed as `targetLeaf` to the command, which forwards it to `openStreamDate()`
|
||||
4. When `targetLeaf` is provided, `LeafSelectionService` is not consulted at all
|
||||
|
||||
### Implementation
|
||||
|
||||
Both ribbon callbacks (`createAllStreamsIcon` and `createStreamIcons`) were updated from `() => { ... }` to `(evt: MouseEvent) => { ... }`. The `OpenTodayStreamCommand` constructor was extended with an optional `targetLeaf` parameter (matching `OpenTodayPrimaryStreamCommand`, which already had one).
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Matches platform conventions.** Ctrl/Cmd+click for "open in new tab" is muscle memory for most users.
|
||||
- **No new settings required.** The modifier key approach is discoverable and doesn't add UI complexity.
|
||||
- **Commands (palette) are unaffected.** Only ribbon icon clicks receive the mouse event; command palette invocations continue to use the standard reuse-first strategy.
|
||||
40
adr/0003-stream-bar-date-sync.md
Normal file
40
adr/0003-stream-bar-date-sync.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# ADR-0003: Stream bar date synchronization on external navigation
|
||||
|
||||
**Date:** 2026-03-25
|
||||
**Status:** Accepted
|
||||
**Affects:** `StreamsBarComponent.ts`
|
||||
|
||||
## Context
|
||||
|
||||
The stream bar displays the currently viewed date and provides calendar navigation within a stream. When a user opens a stream file via the plugin (ribbon icon, command palette, calendar click), the plugin explicitly calls `DateStateManager.setCurrentDate()` as part of the `openStreamDate()` flow, keeping the bar in sync.
|
||||
|
||||
However, users can also navigate to stream files through **external** means:
|
||||
|
||||
- Clicking a file in the file explorer
|
||||
- Following an internal link from another note
|
||||
- Using Obsidian's Quick Switcher
|
||||
- Navigating browser history (back/forward)
|
||||
|
||||
In these cases, the plugin's `openStreamDate()` is never called. The `StreamsBarComponent`'s `file-open` handler detected the correct stream via `updateStreamContext()`, but **never extracted the date from the filename**. This left the stream bar showing a stale date from whatever was previously viewed.
|
||||
|
||||
## Decision
|
||||
|
||||
`updateStreamContext()` now always extracts the date from the opened file's basename when the file is identified as belonging to a stream. The date is parsed from the `YYYY-MM-DD` prefix pattern and pushed to `DateStateManager.setCurrentDate()`.
|
||||
|
||||
```typescript
|
||||
// Extract date from filename (e.g., "2026-03-25" from "2026-03-25.md")
|
||||
const match = file.basename.match(/^\d{4}-\d{2}-\d{2}/);
|
||||
if (match) {
|
||||
const [year, month, day] = match[0].split('-').map(n => parseInt(n, 10));
|
||||
this.dateStateManager.setCurrentDate(new Date(year, month - 1, day));
|
||||
}
|
||||
```
|
||||
|
||||
This runs on every `file-open` event where the file matches a stream, regardless of how the navigation was initiated.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Stream bar stays in sync** regardless of how the user navigated to the file.
|
||||
- **Calendar highlights the correct date**, enabling adjacent-day navigation from the right starting point.
|
||||
- **No redundant updates** — `DateStateManager` already deduplicates if the date hasn't changed, so plugin-initiated navigation (which also calls `setCurrentDate`) does not cause double renders.
|
||||
- **Assumes `YYYY-MM-DD` prefix** in filenames. Files that belong to a stream folder but don't follow this naming convention will not update the date display.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
import { builtinModules } from "module";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ const context = await esbuild.context({
|
|||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins],
|
||||
...builtinModules],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
|
|
|
|||
49
main.ts
49
main.ts
|
|
@ -39,13 +39,13 @@ export default class StreamsPlugin extends Plugin implements StreamsAPI {
|
|||
const loadedData = await this.loadData();
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedData);
|
||||
|
||||
// Migration: ensure barStyle exists
|
||||
if (!this.settings.barStyle) {
|
||||
this.settings.barStyle = 'default';
|
||||
// Migration: ensure primaryStreamId exists
|
||||
if (this.settings.primaryStreamId === undefined) {
|
||||
this.settings.primaryStreamId = null;
|
||||
await this.saveSettings();
|
||||
}
|
||||
|
||||
// Migration: ensure encryptThisStream exists for existing streams
|
||||
// Migration: ensure encryptThisStream, disabled, and merge 'folder' into 'dateFormat' for existing streams
|
||||
let needsSave = false;
|
||||
for (const stream of this.settings.streams) {
|
||||
if (stream.encryptThisStream === undefined) {
|
||||
|
|
@ -56,13 +56,50 @@ export default class StreamsPlugin extends Plugin implements StreamsAPI {
|
|||
stream.disabled = false;
|
||||
needsSave = true;
|
||||
}
|
||||
|
||||
// Ensure dateFormat has a default if missing
|
||||
if (stream.dateFormat === undefined) {
|
||||
stream.dateFormat = 'YYYY-MM-DD';
|
||||
needsSave = true;
|
||||
}
|
||||
|
||||
// Migration: merge 'folder' into 'dateFormat' and delete 'folder'
|
||||
if (stream.folder !== undefined) {
|
||||
let newDateFormat = stream.dateFormat;
|
||||
if (!newDateFormat.includes('{') && !newDateFormat.includes('}')) {
|
||||
newDateFormat = `{${newDateFormat}}`;
|
||||
}
|
||||
|
||||
if (stream.folder.trim() !== '') {
|
||||
// Clean folder slashes
|
||||
let cleanFolder = stream.folder.trim().replace(/\/+$/, '');
|
||||
|
||||
// If the legacy dateFormat was an absolute path, it overrides the folder
|
||||
// (as per previous backward compatibility logic). So only prefix if it's not absolute.
|
||||
if (!stream.dateFormat.startsWith('/')) {
|
||||
stream.dateFormat = `${cleanFolder}/${newDateFormat}`;
|
||||
} else {
|
||||
stream.dateFormat = newDateFormat;
|
||||
}
|
||||
} else {
|
||||
stream.dateFormat = newDateFormat;
|
||||
}
|
||||
|
||||
// Remove the deprecated property
|
||||
delete stream.folder;
|
||||
needsSave = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup: Remove legacy activeStreamId
|
||||
// Cleanup: Remove legacy activeStreamId and calendarCompactState
|
||||
if ((this.settings as any).activeStreamId !== undefined) {
|
||||
delete (this.settings as any).activeStreamId;
|
||||
needsSave = true;
|
||||
}
|
||||
if ((this.settings as any).calendarCompactState !== undefined) {
|
||||
delete (this.settings as any).calendarCompactState;
|
||||
needsSave = true;
|
||||
}
|
||||
|
||||
if (needsSave) {
|
||||
await this.saveSettings();
|
||||
|
|
@ -150,8 +187,6 @@ export default class StreamsPlugin extends Plugin implements StreamsAPI {
|
|||
return serviceRegistry.api?.getVersion() || { version: '1.0.0', minAppVersion: '0.15.0', name: 'Streams', id: 'streams' };
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Internal Services (for plugin functionality)
|
||||
getFileOperationsService(): FileOperationsService | undefined {
|
||||
return serviceRegistry.fileOperations;
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
{
|
||||
"id": "streams",
|
||||
"name": "Streams",
|
||||
"version": "1.2.5",
|
||||
"version": "1.3.4",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Create and manage multiple Daily Note streams. Stream <-> Daily Notes <-> Backlink.",
|
||||
"author": "Floyd",
|
||||
"authorUrl": "http://floyd.pro",
|
||||
"authorUrl": "https://floyd.pro",
|
||||
"fundingUrl": {
|
||||
"GitHub Sponsor": "https://github.com/sponsors/bfloydd",
|
||||
"Buy Me a Coffee": "https://buymeacoffee.com/floydpro",
|
||||
"Venmo": "https://venmo.com/floydpro?txn=pay",
|
||||
"PayPal": "https://paypal.me/bfloydd"
|
||||
},
|
||||
"isDesktopOnly": false,
|
||||
"providesApi": true
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
18
package-lock.json
generated
18
package-lock.json
generated
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "streams",
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
|
@ -13,7 +13,6 @@
|
|||
"@types/node": "^16.11.6",
|
||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||
"@typescript-eslint/parser": "5.29.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"jest": "^29.5.0",
|
||||
"jest-environment-jsdom": "^29.5.0",
|
||||
|
|
@ -2376,19 +2375,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/builtin-modules": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz",
|
||||
"integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
|
|
@ -6502,4 +6488,4 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "streams",
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.4",
|
||||
"description": "Streams plugin for Obsidian",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
@ -19,7 +19,6 @@
|
|||
"@types/node": "^16.11.6",
|
||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||
"@typescript-eslint/parser": "5.29.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"jest": "^29.5.0",
|
||||
"jest-environment-jsdom": "^29.5.0",
|
||||
|
|
@ -28,4 +27,4 @@
|
|||
"tslib": "2.4.0",
|
||||
"typescript": "4.7.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -34,11 +34,11 @@ export interface SystemLimitsConfiguration {
|
|||
|
||||
export interface DefaultSettingsConfiguration {
|
||||
readonly streams: readonly [];
|
||||
readonly primaryStreamId: string | null;
|
||||
readonly showStreamsBarComponent: boolean;
|
||||
readonly reuseCurrentTab: boolean;
|
||||
|
||||
readonly debugLoggingEnabled: boolean;
|
||||
readonly barStyle: 'default';
|
||||
}
|
||||
|
||||
// Main configuration interface
|
||||
|
|
@ -73,7 +73,6 @@ export class ConfigurationService extends BaseSliceService implements Configurat
|
|||
view: {
|
||||
CREATE_FILE_VIEW_TYPE: 'streams-create-file-view',
|
||||
CALENDAR_ENABLED_VIEW_TYPES: [
|
||||
'empty',
|
||||
'file-explorer',
|
||||
'search',
|
||||
'graph',
|
||||
|
|
@ -105,11 +104,11 @@ export class ConfigurationService extends BaseSliceService implements Configurat
|
|||
|
||||
defaults: {
|
||||
streams: [],
|
||||
primaryStreamId: null,
|
||||
showStreamsBarComponent: true,
|
||||
reuseCurrentTab: false,
|
||||
|
||||
debugLoggingEnabled: false,
|
||||
barStyle: 'default'
|
||||
debugLoggingEnabled: false
|
||||
} as const
|
||||
};
|
||||
|
||||
|
|
@ -221,11 +220,11 @@ export class ConfigurationService extends BaseSliceService implements Configurat
|
|||
getDefaultSettings(): StreamsSettings {
|
||||
return {
|
||||
streams: [...this.config.defaults.streams],
|
||||
primaryStreamId: this.config.defaults.primaryStreamId,
|
||||
showStreamsBarComponent: this.config.defaults.showStreamsBarComponent,
|
||||
reuseCurrentTab: this.config.defaults.reuseCurrentTab,
|
||||
|
||||
debugLoggingEnabled: this.config.defaults.debugLoggingEnabled,
|
||||
barStyle: this.config.defaults.barStyle
|
||||
debugLoggingEnabled: this.config.defaults.debugLoggingEnabled
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { TFile } from 'obsidian';
|
||||
import { Stream } from './types';
|
||||
import { getStreamBaseFolder } from '../slices/file-operations/streamUtils';
|
||||
import { FileUtils } from './utils/FileUtils';
|
||||
|
||||
export class StreamContextService {
|
||||
/**
|
||||
|
|
@ -30,13 +32,9 @@ export class StreamContextService {
|
|||
* @param stream The stream to check against.
|
||||
*/
|
||||
public isFileInStream(file: TFile, stream: Stream): boolean {
|
||||
if (!file.path || !stream.folder) return false;
|
||||
if (!file.path || !stream.dateFormat) return false;
|
||||
|
||||
// Normalize paths for comparison (remove trailing slashes if any, though obsidian paths usually don't have them)
|
||||
const streamFolder = stream.folder.replace(/\/$/, '');
|
||||
|
||||
// Exact match (file is the folder? unlikely) or subdirectory match
|
||||
// We verify it starts with "folder/"
|
||||
return file.path.startsWith(streamFolder + '/');
|
||||
const streamFolder = getStreamBaseFolder(stream);
|
||||
return FileUtils.fileBelongsToStream(file.path, streamFolder);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,10 +21,10 @@ export const CALENDAR_ENABLED_VIEW_TYPES = [
|
|||
*/
|
||||
export const DEFAULT_SETTINGS = {
|
||||
streams: [],
|
||||
primaryStreamId: null,
|
||||
showStreamsBarComponent: true,
|
||||
reuseCurrentTab: false,
|
||||
debugLoggingEnabled: false,
|
||||
barStyle: 'default' as const
|
||||
debugLoggingEnabled: false
|
||||
} as const;
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -129,4 +129,3 @@ export function getCommandById(app: App, commandId: string): ObsidianCommand | u
|
|||
export interface ViewWithEmptyStateObserver extends View {
|
||||
emptyStateObserver?: MutationObserver;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,21 +21,26 @@ export type LucideIcon =
|
|||
export interface Stream {
|
||||
id: string;
|
||||
name: string;
|
||||
folder: string;
|
||||
icon: LucideIcon;
|
||||
showTodayInRibbon: boolean;
|
||||
addCommand: boolean;
|
||||
encryptThisStream: boolean; // New field for encryption toggle
|
||||
disabled: boolean; // New field for disabling streams
|
||||
dateFormat: string; // New field for flexible date formatting
|
||||
folder?: string; // DEPRECATED: Migrated to dateFormat
|
||||
}
|
||||
|
||||
export interface StreamsSettings {
|
||||
streams: Stream[];
|
||||
/**
|
||||
* The single "primary" stream used by "Go to primary stream".
|
||||
* Null means no primary stream is configured.
|
||||
*/
|
||||
primaryStreamId: string | null;
|
||||
showStreamsBarComponent: boolean;
|
||||
reuseCurrentTab: boolean;
|
||||
|
||||
debugLoggingEnabled: boolean; // Whether debug logging is enabled by default
|
||||
barStyle: 'default' | 'modern'; // Style variant for the streams bar
|
||||
}
|
||||
|
||||
// Specific error data types for better type safety
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export const VIEW_CONFIGS: ViewConfig[] = [
|
|||
showTodayInRibbon: false,
|
||||
addCommand: false,
|
||||
encryptThisStream: false,
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
disabled: false
|
||||
},
|
||||
extraArgs: [new Date()]
|
||||
|
|
@ -41,6 +42,7 @@ export const VIEW_CONFIGS: ViewConfig[] = [
|
|||
showTodayInRibbon: false,
|
||||
addCommand: false,
|
||||
encryptThisStream: true,
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { StreamsAPI, StreamInfo, PluginVersion } from './StreamsAPI';
|
|||
import { eventBus, EVENTS } from '../../shared/EventBus';
|
||||
import { DateUtils } from '../../shared/utils/DateUtils';
|
||||
import { FileUtils } from '../../shared/utils/FileUtils';
|
||||
import { getStreamBaseFolder } from '../file-operations/streamUtils';
|
||||
|
||||
export class APIService extends PluginAwareSliceService implements StreamsAPI {
|
||||
async initialize(): Promise<void> {
|
||||
|
|
@ -57,7 +58,7 @@ export class APIService extends PluginAwareSliceService implements StreamsAPI {
|
|||
if (!folderPath) return [];
|
||||
|
||||
return this.filterStreams(stream => {
|
||||
const streamFolder = FileUtils.normalizePath(stream.folder);
|
||||
const streamFolder = FileUtils.normalizePath(getStreamBaseFolder(stream));
|
||||
const searchFolder = FileUtils.normalizePath(folderPath);
|
||||
|
||||
return streamFolder === searchFolder ||
|
||||
|
|
@ -102,9 +103,9 @@ export class APIService extends PluginAwareSliceService implements StreamsAPI {
|
|||
if (!filePath) return null;
|
||||
|
||||
// Find streams that match this file path
|
||||
const matchingStreams = this.getStreams().filter(stream =>
|
||||
FileUtils.fileBelongsToStream(filePath, stream.folder)
|
||||
);
|
||||
const matchingStreams = this.getStreams().filter(stream => {
|
||||
return FileUtils.fileBelongsToStream(filePath, getStreamBaseFolder(stream));
|
||||
});
|
||||
|
||||
// Return the first match, or null if none found
|
||||
return matchingStreams.length > 0 ? matchingStreams[0] : null;
|
||||
|
|
@ -122,7 +123,7 @@ export class APIService extends PluginAwareSliceService implements StreamsAPI {
|
|||
return {
|
||||
id: stream.id,
|
||||
name: stream.name,
|
||||
folder: stream.folder,
|
||||
folder: getStreamBaseFolder(stream),
|
||||
icon: stream.icon,
|
||||
isActive: false // Global active stream concept removed
|
||||
};
|
||||
|
|
|
|||
|
|
@ -92,11 +92,6 @@ export class ComponentLifecycleManager {
|
|||
component.updateReuseCurrentTab(settings.reuseCurrentTab);
|
||||
}
|
||||
|
||||
// Refresh bar style for all existing components
|
||||
if (component && typeof component.refreshBarStyle === 'function') {
|
||||
component.refreshBarStyle();
|
||||
}
|
||||
|
||||
// Update streams list for all existing components
|
||||
if (component && typeof component.updateStreamsList === 'function' && settings.streams) {
|
||||
component.updateStreamsList(settings.streams);
|
||||
|
|
@ -316,6 +311,12 @@ export class ComponentLifecycleManager {
|
|||
return;
|
||||
}
|
||||
|
||||
// Validate view type before creation
|
||||
const viewType = leaf.view.getViewType();
|
||||
if (!this.calendarViewService?.shouldCreateCalendarForViewType(viewType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get active stream or default stream
|
||||
const streamToUse = this.getStreamToUse();
|
||||
if (!streamToUse) {
|
||||
|
|
|
|||
|
|
@ -2,32 +2,20 @@ import { Stream } from '../../shared/types';
|
|||
import { setIcon } from 'obsidian';
|
||||
import { DateNavigationService } from './DateNavigationService';
|
||||
|
||||
/**
|
||||
* Interface for plugin that provides settings
|
||||
*/
|
||||
interface PluginInterface {
|
||||
settings: {
|
||||
barStyle?: 'default' | 'modern';
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages component state for StreamsBarComponent
|
||||
* Extracted to follow Single Responsibility Principle
|
||||
*/
|
||||
export class ComponentStateManager {
|
||||
private plugin: PluginInterface | null;
|
||||
private streams: Stream[];
|
||||
private selectedStream: Stream;
|
||||
private dateNavigationService: DateNavigationService;
|
||||
|
||||
constructor(
|
||||
plugin: PluginInterface | null,
|
||||
streams: Stream[],
|
||||
selectedStream: Stream,
|
||||
dateNavigationService: DateNavigationService
|
||||
) {
|
||||
this.plugin = plugin;
|
||||
this.streams = streams;
|
||||
this.selectedStream = selectedStream;
|
||||
this.dateNavigationService = dateNavigationService;
|
||||
|
|
@ -75,24 +63,6 @@ export class ComponentStateManager {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply bar style to a component based on settings
|
||||
*/
|
||||
applyBarStyle(component: HTMLElement): void {
|
||||
if (!this.plugin?.settings) {
|
||||
return;
|
||||
}
|
||||
|
||||
const barStyle = this.plugin.settings.barStyle;
|
||||
|
||||
// Remove existing style classes
|
||||
component.removeClass('modern-style');
|
||||
|
||||
// Apply the appropriate style class
|
||||
if (barStyle === 'modern') {
|
||||
component.addClass('modern-style');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the today button text based on the current date
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { App, setIcon, WorkspaceLeaf } from 'obsidian';
|
||||
import { App, MarkdownView, Notice, setIcon, WorkspaceLeaf } from 'obsidian';
|
||||
import { Stream } from '../../shared/types';
|
||||
import { SettingsManager, UIController } from '../../shared/interfaces';
|
||||
import { OpenTodayCurrentStreamCommand } from '../file-operations/OpenTodayCurrentStreamCommand';
|
||||
import { getSetting } from '../../shared/obsidian-types';
|
||||
import { CalendarRenderer } from './CalendarRenderer';
|
||||
import { StreamSelector } from './StreamSelector';
|
||||
|
|
@ -12,6 +11,10 @@ import { TouchGestureHandler } from './TouchGestureHandler';
|
|||
import { DocumentEventHandler } from './DocumentEventHandler';
|
||||
import { ComponentStateManager } from './ComponentStateManager';
|
||||
import { DateStateManager } from '../../shared/DateStateManager';
|
||||
import { StreamContextService } from '../../shared/StreamContextService';
|
||||
import { openStreamDate } from '../file-operations/streamUtils';
|
||||
import { CREATE_FILE_VIEW_TYPE } from '../file-operations/CreateFileView';
|
||||
import { CREATE_FILE_VIEW_ENCRYPTED_TYPE } from '../file-operations/CreateFileViewEncrypted';
|
||||
|
||||
/**
|
||||
* Interface for UI element references that will be set by the builder
|
||||
|
|
@ -60,6 +63,7 @@ export class ComponentUIBuilder {
|
|||
private contentIndicatorService: ContentIndicatorService;
|
||||
private callbacks: ComponentCallbacks;
|
||||
private leaf: WorkspaceLeaf;
|
||||
private streamContextService: StreamContextService;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
|
|
@ -87,6 +91,7 @@ export class ComponentUIBuilder {
|
|||
this.contentIndicatorService = contentIndicatorService;
|
||||
this.callbacks = callbacks;
|
||||
this.leaf = leaf;
|
||||
this.streamContextService = new StreamContextService();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -221,7 +226,7 @@ export class ComponentUIBuilder {
|
|||
|
||||
private setupNavigationControls(navControls: HTMLElement, collapsedView: HTMLElement): void {
|
||||
const prevDayButton = navControls.createDiv('streams-bar-day-nav prev-day');
|
||||
prevDayButton.setText('←');
|
||||
setIcon(prevDayButton, 'chevron-left');
|
||||
prevDayButton.setAttribute('aria-label', 'Previous day');
|
||||
this.eventRegistry.register(prevDayButton, 'click', async (e: Event) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -237,7 +242,7 @@ export class ComponentUIBuilder {
|
|||
});
|
||||
|
||||
const nextDayButton = navControls.createDiv('streams-bar-day-nav next-day');
|
||||
nextDayButton.setText('→');
|
||||
setIcon(nextDayButton, 'chevron-right');
|
||||
nextDayButton.setAttribute('aria-label', 'Next day');
|
||||
this.eventRegistry.register(nextDayButton, 'click', async (e: Event) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -251,22 +256,51 @@ export class ComponentUIBuilder {
|
|||
private setupHomeButton(navControls: HTMLElement): void {
|
||||
const homeButton = navControls.createDiv('streams-bar-home-button');
|
||||
setIcon(homeButton, 'home');
|
||||
homeButton.setAttribute('aria-label', 'Go to current stream today');
|
||||
homeButton.setAttribute('aria-label', 'Go to today in current stream');
|
||||
this.eventRegistry.register(homeButton, 'click', async (e: Event) => {
|
||||
e.stopPropagation();
|
||||
const command = new OpenTodayCurrentStreamCommand(
|
||||
const streamInView = this.resolveCurrentLeafStream();
|
||||
if (!streamInView) {
|
||||
new Notice('No stream found for this view.');
|
||||
return;
|
||||
}
|
||||
|
||||
await openStreamDate(
|
||||
this.app,
|
||||
this.streams,
|
||||
streamInView,
|
||||
new Date(),
|
||||
this.reuseCurrentTab,
|
||||
(this.settingsManager as any) ?? undefined,
|
||||
this.stateManager.getActiveStream(),
|
||||
this.leaf,
|
||||
this.dateStateManager
|
||||
);
|
||||
await command.execute();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the stream that is currently "in view" for this component's leaf.
|
||||
*
|
||||
* - Markdown views: infer stream from the open file's path.
|
||||
* - Streams custom views (CreateFileView / CreateFileViewEncrypted): read stream from view state.
|
||||
*/
|
||||
private resolveCurrentLeafStream(): Stream | null {
|
||||
const view = this.leaf.view;
|
||||
|
||||
if (view instanceof MarkdownView) {
|
||||
return this.streamContextService.getStreamForFile(view.file, this.streams);
|
||||
}
|
||||
|
||||
const viewType = view.getViewType?.();
|
||||
if (viewType === CREATE_FILE_VIEW_TYPE || viewType === CREATE_FILE_VIEW_ENCRYPTED_TYPE) {
|
||||
const state = (view as unknown as { getState?: () => unknown }).getState?.();
|
||||
if (state && typeof state === 'object' && 'stream' in state) {
|
||||
const stream = (state as { stream?: Stream }).stream;
|
||||
return stream ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private setupSettingsButton(navControls: HTMLElement): void {
|
||||
const settingsButton = navControls.createDiv('streams-bar-settings-button');
|
||||
setIcon(settingsButton, 'settings');
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { encryptionDetectionService } from '../../shared/EncryptionDetectionServ
|
|||
import { configurationService } from '../../shared/ConfigurationService';
|
||||
import { MeldDetectionService } from '../meld-integration';
|
||||
import { centralizedLogger } from '../../shared/CentralizedLogger';
|
||||
import { resolveStreamFilePath } from '../file-operations/streamUtils';
|
||||
|
||||
/**
|
||||
* Content indicator for calendar day display
|
||||
|
|
@ -36,17 +37,7 @@ export class ContentIndicatorService {
|
|||
* @returns Promise resolving to content indicator
|
||||
*/
|
||||
async getContentIndicator(date: Date): Promise<ContentIndicator> {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const fileName = `${year}-${month}-${day}.md`;
|
||||
|
||||
const folderPath = this.stream.folder
|
||||
.split(/[/\\]/)
|
||||
.filter(Boolean)
|
||||
.join('/');
|
||||
|
||||
const filePath = folderPath ? `${folderPath}/${fileName}` : fileName;
|
||||
const filePath = resolveStreamFilePath(this.stream, date);
|
||||
let file = this.app.vault.getAbstractFileByPath(filePath);
|
||||
let isEncrypted = false;
|
||||
|
||||
|
|
@ -63,14 +54,14 @@ export class ContentIndicatorService {
|
|||
|
||||
// Check if file is encrypted by extension or content
|
||||
if (!isEncrypted) {
|
||||
isEncrypted = encryptionDetectionService.isEncryptedFileByPath(file.path) ||
|
||||
await encryptionDetectionService.isFileEncrypted(this.app, file);
|
||||
isEncrypted = encryptionDetectionService.isEncryptedFileByPath(file.path) ||
|
||||
await encryptionDetectionService.isFileEncrypted(this.app, file);
|
||||
}
|
||||
|
||||
const fileSize = file.stat.size;
|
||||
const fileSizeConfig = configurationService.getFileSizeConfig();
|
||||
const size = fileSize < fileSizeConfig.SMALL_THRESHOLD ? 'small' :
|
||||
fileSize < fileSizeConfig.MEDIUM_THRESHOLD ? 'medium' : 'large';
|
||||
fileSize < fileSizeConfig.MEDIUM_THRESHOLD ? 'medium' : 'large';
|
||||
|
||||
// Determine if encrypted file is locked or unlocked
|
||||
let isLocked = false;
|
||||
|
|
@ -78,11 +69,11 @@ export class ContentIndicatorService {
|
|||
isLocked = await this.isEncryptedFileLocked(file);
|
||||
}
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
size,
|
||||
isEncrypted,
|
||||
isLocked
|
||||
return {
|
||||
exists: true,
|
||||
size,
|
||||
isEncrypted,
|
||||
isLocked
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +90,7 @@ export class ContentIndicatorService {
|
|||
|
||||
// Try to read the file content to see if it's accessible
|
||||
const content = await this.app.vault.cachedRead(file);
|
||||
|
||||
|
||||
// If we can read the content and it's not encrypted patterns, it's unlocked
|
||||
if (content && !encryptionDetectionService.isEncryptedContent(content)) {
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export class StreamDataService implements StreamProvider, FilePathProvider {
|
|||
showTodayInRibbon: true,
|
||||
addCommand: true,
|
||||
encryptThisStream: false,
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
disabled: false
|
||||
};
|
||||
}
|
||||
|
|
@ -57,13 +58,7 @@ export class StreamDataService implements StreamProvider, FilePathProvider {
|
|||
return `${stream.folder}/${fileName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently active stream
|
||||
* @deprecated Global active stream is removed. Returns undefined.
|
||||
*/
|
||||
getActiveStream(): Stream | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get stream to use (default stream)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export class StreamProviderService implements StreamProvider {
|
|||
* Creates a new StreamProviderService with injected StreamDataService dependency
|
||||
* @param streamDataService - The service that handles actual stream data operations
|
||||
*/
|
||||
constructor(private streamDataService: StreamDataService) {}
|
||||
constructor(private streamDataService: StreamDataService) { }
|
||||
|
||||
/**
|
||||
* Get all available streams
|
||||
|
|
@ -38,15 +38,10 @@ export class StreamProviderService implements StreamProvider {
|
|||
showTodayInRibbon: true,
|
||||
addCommand: true,
|
||||
encryptThisStream: false,
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
disabled: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently active stream
|
||||
* @returns The active stream or undefined if none is active
|
||||
*/
|
||||
getActiveStream(): Stream | undefined {
|
||||
return this.streamDataService.getActiveStream();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import { DateStateManager } from '../../shared/DateStateManager';
|
|||
import { centralizedLogger } from '../../shared/CentralizedLogger';
|
||||
|
||||
interface PluginInterface {
|
||||
settings: {
|
||||
settings?: {
|
||||
primaryStreamId?: string | null;
|
||||
};
|
||||
setActiveStream(streamId: string, force?: boolean, suppressEvent?: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -57,6 +57,8 @@ export class StreamSelector extends Component {
|
|||
|
||||
this.dropdown.empty();
|
||||
|
||||
const primaryStreamId = this.plugin?.settings?.primaryStreamId ?? null;
|
||||
|
||||
// Filter out disabled streams
|
||||
const enabledStreams = this.streams.filter(stream => !stream.disabled);
|
||||
|
||||
|
|
@ -73,6 +75,14 @@ export class StreamSelector extends Component {
|
|||
const streamName = streamItem.createDiv('streams-bar-stream-item-name');
|
||||
streamName.setText(stream.name);
|
||||
|
||||
// Subtle indicator for the primary stream (configured in settings)
|
||||
if (primaryStreamId && stream.id === primaryStreamId) {
|
||||
streamItem.addClass('streams-bar-stream-item-primary');
|
||||
const primaryIndicator = streamItem.createDiv('streams-bar-stream-item-primary-indicator');
|
||||
primaryIndicator.setAttribute('title', 'Primary stream');
|
||||
primaryIndicator.setAttribute('aria-label', 'Primary stream');
|
||||
}
|
||||
|
||||
// Add encryption icon if stream is encrypted
|
||||
if (stream.encryptThisStream) {
|
||||
const encryptionIcon = streamItem.createDiv('streams-bar-stream-item-encryption');
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@ import { ViewContainerService } from './ViewContainerService';
|
|||
import { ComponentEventSubscriptionManager } from './ComponentEventSubscriptionManager';
|
||||
import { ComponentStateManager } from './ComponentStateManager';
|
||||
import { ComponentUIBuilder, ComponentCallbacks } from './ComponentUIBuilder';
|
||||
import { getStreamBaseFolder } from '../file-operations/streamUtils';
|
||||
|
||||
interface PluginInterface {
|
||||
settings: {
|
||||
barStyle?: 'default' | 'modern';
|
||||
};
|
||||
saveSettings(): void;
|
||||
setActiveStream(streamId: string, force?: boolean, suppressEvent?: boolean): Promise<void>;
|
||||
|
|
@ -64,9 +64,7 @@ export class StreamsBarComponent extends Component {
|
|||
|
||||
private streamContextService: StreamContextService;
|
||||
|
||||
public get activeStreamId(): string {
|
||||
return this.selectedStream?.id || '';
|
||||
}
|
||||
|
||||
|
||||
public updateReuseCurrentTab(reuseCurrentTab: boolean): void {
|
||||
this.reuseCurrentTab = reuseCurrentTab;
|
||||
|
|
@ -101,12 +99,11 @@ export class StreamsBarComponent extends Component {
|
|||
this.eventRegistry = new EventHandlerRegistry();
|
||||
this.viewContainerService = new ViewContainerService();
|
||||
this.eventSubscriptionManager = new ComponentEventSubscriptionManager(this.dateStateManager);
|
||||
this.stateManager = new ComponentStateManager(plugin, streams, stream, this.dateNavigationService);
|
||||
this.stateManager = new ComponentStateManager(streams, stream, this.dateNavigationService);
|
||||
this.currentMonthView = new Date();
|
||||
|
||||
this.component = document.createElement('div');
|
||||
this.component.addClass('streams-bar-component');
|
||||
this.stateManager.applyBarStyle(this.component);
|
||||
this.initializeDateState(leaf);
|
||||
|
||||
this.eventSubscriptionManager.subscribeToDateChanges((state) => {
|
||||
|
|
@ -117,15 +114,9 @@ export class StreamsBarComponent extends Component {
|
|||
this.handleSettingsChange(settings);
|
||||
});
|
||||
|
||||
const contentContainer = this.viewContainerService.findContentContainer(leaf);
|
||||
if (!contentContainer) {
|
||||
centralizedLogger.error('Could not find content container');
|
||||
return;
|
||||
}
|
||||
|
||||
this.viewContainerService.removeExistingComponents(leaf, '.streams-bar-component');
|
||||
|
||||
if (!this.viewContainerService.attachComponent(this.component, leaf, contentContainer)) {
|
||||
if (!this.viewContainerService.attachComponent(this.component, leaf)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -190,6 +181,20 @@ export class StreamsBarComponent extends Component {
|
|||
if (this.selectedStream?.id !== stream.id) {
|
||||
this.updateActiveStream(stream);
|
||||
}
|
||||
|
||||
// Always update the date from the filename so the bar shows the correct
|
||||
// date even when navigating to a stream file from outside the plugin
|
||||
if (file) {
|
||||
const match = file.basename.match(/^\d{4}-\d{2}-\d{2}/);
|
||||
if (match) {
|
||||
const [year, month, day] = match[0].split('-').map(n => parseInt(n, 10));
|
||||
const date = new Date(year, month - 1, day);
|
||||
if (!isNaN(date.getTime())) {
|
||||
this.dateStateManager.setCurrentDate(date);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.component.show();
|
||||
} else {
|
||||
// If no stream found for file, hide the component
|
||||
|
|
@ -229,10 +234,7 @@ export class StreamsBarComponent extends Component {
|
|||
}
|
||||
|
||||
private handleFileModify(file: TFile) {
|
||||
const streamPath = this.selectedStream.folder.split(/[/\\]/).filter(Boolean);
|
||||
const filePath = file.path.split(/[/\\]/).filter(Boolean);
|
||||
|
||||
const isInStream = streamPath.every((part, index) => streamPath[index] === filePath[index]);
|
||||
const isInStream = this.streamContextService.isFileInStream(file, this.selectedStream);
|
||||
|
||||
if (isInStream && this.calendarRenderer) {
|
||||
this.calendarRenderer.updateGridContent();
|
||||
|
|
@ -321,14 +323,7 @@ export class StreamsBarComponent extends Component {
|
|||
let isInStream = true;
|
||||
|
||||
if (view instanceof MarkdownView && view.file) {
|
||||
const streamPath = this.selectedStream.folder.split(/[/\\]/).filter(Boolean);
|
||||
const filePath = view.file.path.split(/[/\\]/).filter(Boolean);
|
||||
|
||||
if (filePath.length >= streamPath.length) {
|
||||
isInStream = streamPath.every((part, index) => streamPath[index] === filePath[index]);
|
||||
} else {
|
||||
isInStream = false;
|
||||
}
|
||||
isInStream = this.streamContextService.isFileInStream(view.file, this.selectedStream);
|
||||
}
|
||||
|
||||
if (!isInStream) {
|
||||
|
|
@ -544,8 +539,6 @@ export class StreamsBarComponent extends Component {
|
|||
}
|
||||
|
||||
private handleSettingsChange(settings: StreamsSettings): void {
|
||||
this.stateManager.applyBarStyle(this.component);
|
||||
|
||||
if (settings.streams) {
|
||||
// Update streams list in selector
|
||||
this.updateStreamsList(settings.streams);
|
||||
|
|
@ -555,7 +548,7 @@ export class StreamsBarComponent extends Component {
|
|||
const updatedStream = settings.streams.find(s => s.id === this.selectedStream.id);
|
||||
if (updatedStream) {
|
||||
|
||||
this.handleStreamSwitch(updatedStream);
|
||||
this.updateActiveStream(updatedStream);
|
||||
} else {
|
||||
console.warn(`[StreamsBarComponent] Current stream ${this.selectedStream.id} not found in new settings.`);
|
||||
}
|
||||
|
|
@ -563,8 +556,5 @@ export class StreamsBarComponent extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
public refreshBarStyle(): void {
|
||||
this.stateManager.applyBarStyle(this.component);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { WorkspaceLeaf, MarkdownView, View } from 'obsidian';
|
||||
import { WorkspaceLeaf, MarkdownView, View, Platform } from 'obsidian';
|
||||
import { centralizedLogger } from '../../shared/CentralizedLogger';
|
||||
import { CREATE_FILE_VIEW_TYPE } from '../file-operations/CreateFileView';
|
||||
import { INSTALL_MELD_VIEW_TYPE } from '../file-operations/InstallMeldView';
|
||||
|
|
@ -14,57 +14,6 @@ interface ViewWithContentEl extends View {
|
|||
* Extracted from StreamsBarComponent to follow Single Responsibility Principle
|
||||
*/
|
||||
export class ViewContainerService {
|
||||
/**
|
||||
* Find the content container for a given leaf based on its view type
|
||||
* @param leaf The workspace leaf to find the container for
|
||||
* @returns The content container element, or null if not found
|
||||
*/
|
||||
findContentContainer(leaf: WorkspaceLeaf): HTMLElement | null {
|
||||
const viewType = leaf.view.getViewType();
|
||||
let contentContainer: HTMLElement | null = null;
|
||||
|
||||
if (viewType === 'markdown') {
|
||||
const markdownView = leaf.view as MarkdownView;
|
||||
contentContainer = markdownView.contentEl;
|
||||
|
||||
} else if (viewType === CREATE_FILE_VIEW_TYPE ||
|
||||
viewType === INSTALL_MELD_VIEW_TYPE ||
|
||||
viewType === CREATE_FILE_VIEW_ENCRYPTED_TYPE) {
|
||||
const view = leaf.view as unknown as ViewWithContentEl;
|
||||
if (!view) {
|
||||
centralizedLogger.error(`View is null for viewType: ${viewType}`);
|
||||
return null;
|
||||
}
|
||||
contentContainer = view.contentEl;
|
||||
|
||||
} else if (viewType === 'empty') {
|
||||
// For empty views, try to find the view-content element
|
||||
const viewContent = leaf.view.containerEl.querySelector('.view-content');
|
||||
if (viewContent) {
|
||||
contentContainer = viewContent as HTMLElement;
|
||||
} else {
|
||||
centralizedLogger.error('Could not find view-content for empty view');
|
||||
return null;
|
||||
}
|
||||
} else if (viewType === 'file-explorer') {
|
||||
// For file explorer, add to the main content area
|
||||
const mainContent = leaf.view.containerEl.querySelector('.nav-files-container') ||
|
||||
leaf.view.containerEl.querySelector('.nav-files') ||
|
||||
leaf.view.containerEl;
|
||||
contentContainer = mainContent as HTMLElement;
|
||||
|
||||
} else {
|
||||
const view = leaf.view as unknown as ViewWithContentEl;
|
||||
if (!view) {
|
||||
centralizedLogger.error('View is null');
|
||||
return null;
|
||||
}
|
||||
contentContainer = view.contentEl;
|
||||
}
|
||||
|
||||
return contentContainer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a leaf is in the main editor area (not a sidebar)
|
||||
* @param leaf The workspace leaf to check
|
||||
|
|
@ -86,18 +35,19 @@ export class ViewContainerService {
|
|||
existingComponents.forEach(component => {
|
||||
component.remove();
|
||||
});
|
||||
|
||||
leafContainer.removeClass('streams-leaf-active');
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a component to the DOM in the appropriate location
|
||||
* @param component The component element to attach
|
||||
* @param leaf The workspace leaf to attach to
|
||||
* @param contentContainer The content container element
|
||||
* @returns True if attachment was successful, false otherwise
|
||||
*/
|
||||
attachComponent(component: HTMLElement, leaf: WorkspaceLeaf, contentContainer: HTMLElement): boolean {
|
||||
// Add class to content container
|
||||
contentContainer.addClass('streams-markdown-view-content');
|
||||
attachComponent(component: HTMLElement, leaf: WorkspaceLeaf): boolean {
|
||||
// Add state class to the root leaf container to reliably target layout CSS
|
||||
leaf.view.containerEl.addClass('streams-leaf-active');
|
||||
|
||||
// Only add the calendar component if we're in the main editor area
|
||||
if (!this.isMainEditorLeaf(leaf)) {
|
||||
|
|
@ -115,9 +65,10 @@ export class ViewContainerService {
|
|||
// Find the view-header within this specific leaf
|
||||
const viewHeader = leafContainer.querySelector('.view-header');
|
||||
|
||||
if (viewHeader && viewHeader.parentElement) {
|
||||
// Insert after the view-header for this specific leaf
|
||||
viewHeader.parentElement.insertBefore(component, viewHeader.nextSibling);
|
||||
if (viewHeader) {
|
||||
// Append inside the view-header for all platforms (mobile, tablet, desktop)
|
||||
// to ensure consistent placement across the app
|
||||
viewHeader.appendChild(component);
|
||||
} else {
|
||||
// Fallback: attach to the leaf container itself
|
||||
leafContainer.insertBefore(component, leafContainer.firstChild);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ describe('FilePathProviderService', () => {
|
|||
showTodayInRibbon: true,
|
||||
addCommand: true,
|
||||
encryptThisStream: false,
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
disabled: false
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -148,10 +148,10 @@ describe('ServiceCoordinator', () => {
|
|||
it('should update existing components when settings change', () => {
|
||||
const newSettings: StreamsSettings = {
|
||||
streams: [],
|
||||
primaryStreamId: null,
|
||||
showStreamsBarComponent: true,
|
||||
reuseCurrentTab: false,
|
||||
debugLoggingEnabled: false,
|
||||
barStyle: 'default'
|
||||
debugLoggingEnabled: false
|
||||
};
|
||||
|
||||
serviceCoordinator.onSettingsChanged(newSettings);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ describe('StreamProviderService', () => {
|
|||
let streamProviderService: StreamProviderService;
|
||||
let mockStreams: Stream[];
|
||||
let mockDefaultStream: Stream;
|
||||
let mockActiveStream: Stream;
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock streams
|
||||
|
|
@ -23,6 +23,7 @@ describe('StreamProviderService', () => {
|
|||
showTodayInRibbon: true,
|
||||
addCommand: true,
|
||||
encryptThisStream: false,
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
|
|
@ -33,18 +34,19 @@ describe('StreamProviderService', () => {
|
|||
showTodayInRibbon: false,
|
||||
addCommand: false,
|
||||
encryptThisStream: true,
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
disabled: false
|
||||
}
|
||||
];
|
||||
|
||||
mockDefaultStream = mockStreams[0];
|
||||
mockActiveStream = mockStreams[1];
|
||||
|
||||
|
||||
// Mock the StreamDataService
|
||||
streamDataService = new StreamDataService(jest.fn()) as jest.Mocked<StreamDataService>;
|
||||
streamDataService.getStreams.mockReturnValue(mockStreams);
|
||||
streamDataService.getDefaultStream.mockReturnValue(mockDefaultStream);
|
||||
streamDataService.getActiveStream.mockReturnValue(mockActiveStream);
|
||||
|
||||
|
||||
// Create the service under test
|
||||
streamProviderService = new StreamProviderService(streamDataService);
|
||||
|
|
@ -93,6 +95,7 @@ describe('StreamProviderService', () => {
|
|||
showTodayInRibbon: true,
|
||||
addCommand: true,
|
||||
encryptThisStream: false,
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
disabled: false
|
||||
});
|
||||
expect(streamDataService.getDefaultStream).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -111,36 +114,12 @@ describe('StreamProviderService', () => {
|
|||
showTodayInRibbon: true,
|
||||
addCommand: true,
|
||||
encryptThisStream: false,
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
disabled: false
|
||||
});
|
||||
expect(streamDataService.getDefaultStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActiveStream', () => {
|
||||
it('should return active stream from StreamDataService', () => {
|
||||
const result = streamProviderService.getActiveStream();
|
||||
|
||||
expect(result).toBe(mockActiveStream);
|
||||
expect(streamDataService.getActiveStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return undefined when no active stream', () => {
|
||||
streamDataService.getActiveStream.mockReturnValue(undefined);
|
||||
|
||||
const result = streamProviderService.getActiveStream();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(streamDataService.getActiveStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return null when StreamDataService returns null', () => {
|
||||
streamDataService.getActiveStream.mockReturnValue(null as any);
|
||||
|
||||
const result = streamProviderService.getActiveStream();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(streamDataService.getActiveStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ import { Stream } from '../../../shared/types';
|
|||
|
||||
// Mock Obsidian
|
||||
jest.mock('obsidian', () => ({
|
||||
Component: class MockComponent {},
|
||||
ItemView: class MockItemView {},
|
||||
WorkspaceLeaf: class MockWorkspaceLeaf {},
|
||||
PluginSettingTab: class MockPluginSettingTab {},
|
||||
Modal: class MockModal {},
|
||||
Component: class MockComponent { },
|
||||
ItemView: class MockItemView { },
|
||||
WorkspaceLeaf: class MockWorkspaceLeaf { },
|
||||
PluginSettingTab: class MockPluginSettingTab { },
|
||||
Modal: class MockModal { },
|
||||
App: class MockApp {
|
||||
workspace = {
|
||||
getLeavesOfType: jest.fn(),
|
||||
|
|
@ -47,14 +47,14 @@ describe('Calendar Navigation Integration Tests', () => {
|
|||
showTodayInRibbon: true,
|
||||
addCommand: true,
|
||||
encryptThisStream: false,
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
disabled: false
|
||||
}
|
||||
],
|
||||
activeStreamId: 'test-stream',
|
||||
|
||||
showStreamsBarComponent: true,
|
||||
reuseCurrentTab: false,
|
||||
debugLoggingEnabled: false,
|
||||
barStyle: 'default' as const
|
||||
debugLoggingEnabled: false
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -103,7 +103,7 @@ describe('Calendar Navigation Integration Tests', () => {
|
|||
// Test StreamProvider interface methods
|
||||
expect(typeof streamProviderService.getStreams).toBe('function');
|
||||
expect(typeof streamProviderService.getDefaultStream).toBe('function');
|
||||
expect(typeof streamProviderService.getActiveStream).toBe('function');
|
||||
|
||||
|
||||
// Test FilePathProvider interface methods
|
||||
expect(typeof filePathProviderService.getDefaultFilePath).toBe('function');
|
||||
|
|
@ -111,13 +111,13 @@ describe('Calendar Navigation Integration Tests', () => {
|
|||
// Verify method calls work
|
||||
const streams = streamProviderService.getStreams();
|
||||
const defaultStream = streamProviderService.getDefaultStream();
|
||||
const activeStream = streamProviderService.getActiveStream();
|
||||
|
||||
const filePath = filePathProviderService.getDefaultFilePath(defaultStream);
|
||||
|
||||
expect(Array.isArray(streams)).toBe(true);
|
||||
expect(defaultStream).toBeDefined();
|
||||
expect(typeof filePath).toBe('string');
|
||||
expect(activeStream).toBeDefined();
|
||||
|
||||
});
|
||||
|
||||
it('should handle service lifecycle correctly', async () => {
|
||||
|
|
@ -136,7 +136,7 @@ describe('Calendar Navigation Integration Tests', () => {
|
|||
|
||||
const newSettings = {
|
||||
...mockSettingsManager.settings,
|
||||
activeStreamId: 'new-stream'
|
||||
|
||||
};
|
||||
|
||||
// Mock component lifecycle manager methods
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { EmptyStateObserver } from './EmptyStateObserver';
|
|||
import { StreamManager } from '../../shared/interfaces';
|
||||
import { MeldDetectionService } from '../../slices/meld-integration';
|
||||
import { eventBus } from '../../shared/EventBus';
|
||||
import { resolveStreamFilePath } from './streamUtils';
|
||||
|
||||
// Interface for accessing app.plugins
|
||||
interface AppWithPlugins extends App {
|
||||
|
|
@ -109,12 +110,9 @@ export class CreateFileView extends ItemView {
|
|||
// Otherwise, calculate from current date and stream.
|
||||
if (state.filePath) {
|
||||
this.filePath = state.filePath;
|
||||
} else {
|
||||
// Recalculate file path based on current stream and date (which is now updated)
|
||||
const currentDate = this.dateStateManager.getState().currentDate;
|
||||
const fileName = `${this.formatDateToYYYYMMDD(currentDate)}.md`;
|
||||
const streamFolder = this.stream.folder.replace(/\/$/, '');
|
||||
this.filePath = `${streamFolder}/${fileName}`;
|
||||
this.filePath = resolveStreamFilePath(this.stream, currentDate);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -124,6 +122,7 @@ export class CreateFileView extends ItemView {
|
|||
|
||||
this.contentEl.empty();
|
||||
this.contentEl.addClass('streams-create-file-container');
|
||||
this.containerEl.addClass('streams-create-file-leaf');
|
||||
this.createFileViewContent(this.contentEl);
|
||||
}
|
||||
|
||||
|
|
@ -137,26 +136,19 @@ export class CreateFileView extends ItemView {
|
|||
|
||||
|
||||
private handleDateChange(state: DateState): void {
|
||||
|
||||
// Update the file path based on the new date
|
||||
const fileName = `${this.formatDateToYYYYMMDD(state.currentDate)}.md`;
|
||||
const streamFolder = this.stream.folder.replace(/\/$/, '');
|
||||
this.filePath = `${streamFolder}/${fileName}`;
|
||||
this.filePath = resolveStreamFilePath(this.stream, state.currentDate);
|
||||
|
||||
// Refresh the view content
|
||||
if (this.contentEl) {
|
||||
this.contentEl.empty();
|
||||
this.contentEl.addClass('streams-create-file-container');
|
||||
this.containerEl.addClass('streams-create-file-leaf');
|
||||
this.createFileViewContent(this.contentEl);
|
||||
}
|
||||
}
|
||||
|
||||
private formatDateToYYYYMMDD(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
// Set this as the active stream in the main plugin - REMOVED logic
|
||||
|
|
@ -173,6 +165,7 @@ export class CreateFileView extends ItemView {
|
|||
// Prepare our content element
|
||||
this.contentEl.empty();
|
||||
this.contentEl.addClass('streams-create-file-container');
|
||||
this.containerEl.addClass('streams-create-file-leaf');
|
||||
|
||||
// Content element styling is handled by CSS class
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { configurationService } from '../../shared/ConfigurationService';
|
|||
import { getPluginById, getCommands, executeCommandById } from '../../shared/obsidian-types';
|
||||
import { ServiceContainer } from '../../shared/interfaces';
|
||||
import { MeldDetectionService } from '../../slices/meld-integration';
|
||||
import { resolveStreamFilePath } from './streamUtils';
|
||||
|
||||
// Interface for the streams plugin
|
||||
interface StreamsPlugin {
|
||||
|
|
@ -105,6 +106,7 @@ export class CreateFileViewEncrypted extends ItemView {
|
|||
if (this.contentEl) {
|
||||
this.contentEl.empty();
|
||||
this.contentEl.addClass('streams-create-file-encrypted-container');
|
||||
this.containerEl.addClass('streams-create-file-encrypted-leaf');
|
||||
this.createFileViewEncryptedContent(this.contentEl);
|
||||
}
|
||||
}
|
||||
|
|
@ -116,24 +118,18 @@ export class CreateFileViewEncrypted extends ItemView {
|
|||
|
||||
private handleDateChange(state: DateState): void {
|
||||
// Update the file path based on the new date
|
||||
const fileName = `${this.formatDateToYYYYMMDD(state.currentDate)}.md`;
|
||||
const streamFolder = this.stream.folder.replace(/\/$/, '');
|
||||
this.filePath = `${streamFolder}/${fileName}`;
|
||||
this.filePath = resolveStreamFilePath(this.stream, state.currentDate);
|
||||
|
||||
// Refresh the view content
|
||||
if (this.contentEl) {
|
||||
this.contentEl.empty();
|
||||
this.contentEl.addClass('streams-create-file-encrypted-container');
|
||||
this.containerEl.addClass('streams-create-file-encrypted-leaf');
|
||||
this.createFileViewEncryptedContent(this.contentEl);
|
||||
}
|
||||
}
|
||||
|
||||
private formatDateToYYYYMMDD(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
// Set this as the active stream in the main plugin
|
||||
|
|
@ -151,6 +147,7 @@ export class CreateFileViewEncrypted extends ItemView {
|
|||
// Prepare our content element
|
||||
this.contentEl.empty();
|
||||
this.contentEl.addClass('streams-create-file-encrypted-container');
|
||||
this.containerEl.addClass('streams-create-file-encrypted-leaf');
|
||||
|
||||
// Content element styling is handled by CSS class
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { CommandService, ViewService } from '../../shared/interfaces';
|
|||
import { Stream } from '../../shared/types';
|
||||
import { OpenStreamDateCommand } from './OpenStreamDateCommand';
|
||||
import { OpenTodayStreamCommand } from './OpenTodayStreamCommand';
|
||||
import { OpenTodayCurrentStreamCommand } from './OpenTodayCurrentStreamCommand';
|
||||
import { OpenTodayPrimaryStreamCommand } from './OpenTodayPrimaryStreamCommand';
|
||||
import { FileCreationInterface, NormalFileStrategy, MeldEncryptedFileStrategy } from './file-creation-strategies';
|
||||
|
||||
export class FileOperationsService extends PluginAwareSliceService implements CommandService, ViewService {
|
||||
|
|
@ -43,13 +43,13 @@ export class FileOperationsService extends PluginAwareSliceService implements Co
|
|||
|
||||
plugin.addCommand({
|
||||
id: 'open-today-current-stream',
|
||||
name: 'Open today for current stream',
|
||||
name: 'Go to primary stream',
|
||||
callback: () => {
|
||||
const command = new OpenTodayCurrentStreamCommand(
|
||||
plugin.app,
|
||||
plugin.settings?.streams || [],
|
||||
plugin.settings?.reuseCurrentTab || false,
|
||||
plugin
|
||||
const command = new OpenTodayPrimaryStreamCommand(
|
||||
plugin.app,
|
||||
plugin.settings?.streams || [],
|
||||
plugin,
|
||||
plugin.settings?.reuseCurrentTab || false
|
||||
);
|
||||
command.execute();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,21 +3,22 @@ import { Stream } from '../../shared/types';
|
|||
import { centralizedLogger } from '../../shared/CentralizedLogger';
|
||||
import { DateStateManager, DateState } from '../../shared/DateStateManager';
|
||||
import { ViewWithEmptyStateObserver, getSetting } from '../../shared/obsidian-types';
|
||||
import { resolveStreamFilePath } from './streamUtils';
|
||||
|
||||
export const INSTALL_MELD_VIEW_TYPE = 'streams-install-meld-view';
|
||||
|
||||
export class InstallMeldView extends ItemView {
|
||||
navigation = true; // Enable navigation history integration
|
||||
|
||||
|
||||
private filePath: string;
|
||||
private stream: Stream;
|
||||
private date: Date;
|
||||
private dateStateManager: DateStateManager;
|
||||
private unsubscribeDateChanged: (() => void) | null = null;
|
||||
|
||||
|
||||
constructor(
|
||||
leaf: WorkspaceLeaf,
|
||||
app: App,
|
||||
leaf: WorkspaceLeaf,
|
||||
app: App,
|
||||
filePath: string,
|
||||
stream: Stream,
|
||||
date: Date
|
||||
|
|
@ -58,16 +59,16 @@ export class InstallMeldView extends ItemView {
|
|||
if (!this || !this.contentEl || !this.leaf || this.contentEl === null || this.leaf === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Additional safety check - ensure the view is still attached to the DOM
|
||||
if (!document.contains(this.contentEl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (state) {
|
||||
this.filePath = state.filePath || this.filePath;
|
||||
this.stream = state.stream || this.stream;
|
||||
|
||||
|
||||
// Handle date parameter
|
||||
if (state.date) {
|
||||
const newDate = typeof state.date === 'string' ? new Date(state.date) : state.date;
|
||||
|
|
@ -75,7 +76,7 @@ export class InstallMeldView extends ItemView {
|
|||
this.date = newDate;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Refresh the view with new state
|
||||
if (this.contentEl) {
|
||||
this.contentEl.empty();
|
||||
|
|
@ -93,19 +94,19 @@ export class InstallMeldView extends ItemView {
|
|||
this.unsubscribeDateChanged = this.dateStateManager.onDateChanged((state) => {
|
||||
this.handleDateChange(state);
|
||||
});
|
||||
|
||||
|
||||
// Trigger streams bar component to be added to this view
|
||||
this.triggerCalendarComponent();
|
||||
|
||||
|
||||
// Prepare our content element
|
||||
this.contentEl.empty();
|
||||
this.contentEl.addClass('streams-install-meld-container');
|
||||
|
||||
|
||||
// Content element styling is handled by CSS class
|
||||
|
||||
|
||||
// Hide any empty-state elements that might still be present
|
||||
this.hideEmptyStates();
|
||||
|
||||
|
||||
// Create our install meld view content
|
||||
this.createInstallMeldViewContent(this.contentEl);
|
||||
}
|
||||
|
|
@ -116,12 +117,12 @@ export class InstallMeldView extends ItemView {
|
|||
this.unsubscribeDateChanged();
|
||||
this.unsubscribeDateChanged = null;
|
||||
}
|
||||
|
||||
|
||||
// Clear content and mark as invalid
|
||||
if (this.contentEl) {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
|
||||
|
||||
// Mark the view as invalid to prevent setState calls
|
||||
// Note: TypeScript doesn't allow null assignment to non-nullable properties
|
||||
// This is intentional cleanup - the view is being destroyed
|
||||
|
|
@ -130,41 +131,41 @@ export class InstallMeldView extends ItemView {
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this as any).leaf = null;
|
||||
}
|
||||
|
||||
|
||||
private createInstallMeldViewContent(container: HTMLElement): void {
|
||||
// Create the content box
|
||||
const contentBox = container.createDiv('streams-install-meld-content');
|
||||
|
||||
|
||||
// Add icon
|
||||
const iconContainer = contentBox.createDiv('streams-install-meld-icon');
|
||||
setIcon(iconContainer, 'lock');
|
||||
|
||||
|
||||
// Stream info display
|
||||
const streamContainer = contentBox.createDiv('streams-install-meld-stream-container');
|
||||
const streamIcon = streamContainer.createSpan('streams-install-meld-stream-icon');
|
||||
setIcon(streamIcon, this.stream.icon || 'book');
|
||||
|
||||
|
||||
const streamName = streamContainer.createSpan('streams-install-meld-stream');
|
||||
streamName.setText(this.stream.name);
|
||||
|
||||
|
||||
// Date display
|
||||
const dateEl = contentBox.createDiv('streams-install-meld-date');
|
||||
const formattedDate = this.formatDate(this.date);
|
||||
dateEl.setText(formattedDate);
|
||||
|
||||
|
||||
// Create button
|
||||
const buttonContainer = contentBox.createDiv('streams-install-meld-button-container');
|
||||
const createButton = buttonContainer.createEl('button', {
|
||||
cls: 'mod-cta streams-install-meld-button',
|
||||
text: 'Install Meld Encrypt Plugin'
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
createButton.addEventListener('click', () => {
|
||||
this.openMeldPluginPage();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private triggerCalendarComponent(): void {
|
||||
// Trigger the streams bar component to be added to this view
|
||||
try {
|
||||
|
|
@ -175,40 +176,40 @@ export class InstallMeldView extends ItemView {
|
|||
// Calendar component trigger failed - not critical
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private hideEmptyStates(): void {
|
||||
// Check if leaf and view are still valid
|
||||
if (!this.leaf || !this.leaf.view || !this.leaf.view.containerEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const hideEmptyStates = () => {
|
||||
// Double-check that leaf and view are still valid
|
||||
if (!this.leaf || !this.leaf.view || !this.leaf.view.containerEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const emptyStates = this.leaf.view.containerEl.querySelectorAll('.empty-state, .empty-state-container');
|
||||
emptyStates.forEach(el => {
|
||||
const htmlEl = el as HTMLElement;
|
||||
htmlEl.addClass('streams-empty-state-hidden');
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// Hide them immediately
|
||||
hideEmptyStates();
|
||||
|
||||
|
||||
// Set up a MutationObserver to hide them if they get recreated
|
||||
const observer = new MutationObserver(() => {
|
||||
hideEmptyStates();
|
||||
});
|
||||
|
||||
|
||||
observer.observe(this.leaf.view.containerEl, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: false
|
||||
});
|
||||
|
||||
|
||||
// Store observer for cleanup
|
||||
// Using ViewWithEmptyStateObserver interface for proper typing
|
||||
const view = this.leaf.view as ViewWithEmptyStateObserver;
|
||||
|
|
@ -216,7 +217,7 @@ export class InstallMeldView extends ItemView {
|
|||
view.emptyStateObserver = observer;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private formatTitleDate(date: Date): string {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
|
|
@ -224,13 +225,11 @@ export class InstallMeldView extends ItemView {
|
|||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private handleDateChange(state: DateState): void {
|
||||
// Update the file path based on the new date
|
||||
const fileName = `${this.formatDateToYYYYMMDD(state.currentDate)}.mdenc`;
|
||||
const folderPath = this.filePath.substring(0, this.filePath.lastIndexOf('/'));
|
||||
this.filePath = folderPath ? `${folderPath}/${fileName}` : fileName;
|
||||
|
||||
this.filePath = resolveStreamFilePath(this.stream, state.currentDate, 'mdenc');
|
||||
|
||||
// Refresh the view content
|
||||
if (this.contentEl) {
|
||||
this.contentEl.empty();
|
||||
|
|
@ -239,27 +238,22 @@ export class InstallMeldView extends ItemView {
|
|||
}
|
||||
}
|
||||
|
||||
private formatDateToYYYYMMDD(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
|
||||
private formatDate(date: Date): string {
|
||||
try {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
} catch (error) {
|
||||
centralizedLogger.error(`Error formatting date: ${error}`);
|
||||
return "Invalid Date";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private openMeldPluginPage(): void {
|
||||
// Open the Meld plugin page in the community plugins
|
||||
try {
|
||||
|
|
@ -275,6 +269,6 @@ export class InstallMeldView extends ItemView {
|
|||
new Notice('Please manually search for "Meld Encrypt" in Community Plugins');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,59 @@ import { centralizedLogger } from '../../shared/CentralizedLogger';
|
|||
* Extracted from streamUtils.ts to eliminate code duplication and follow SRP
|
||||
*/
|
||||
export class LeafSelectionService {
|
||||
/**
|
||||
* Checks whether a workspace leaf is pinned.
|
||||
* Obsidian exposes this via the internal `pinned` property on the leaf.
|
||||
*/
|
||||
private static isLeafPinned(leaf: WorkspaceLeaf): boolean {
|
||||
return (leaf as any).pinned === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a leaf belongs to the main editor area (rootSplit),
|
||||
* as opposed to left/right side panes.
|
||||
*/
|
||||
private static isInMainEditorArea(app: App, leaf: WorkspaceLeaf): boolean {
|
||||
return leaf.getRoot() === app.workspace.rootSplit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the next unpinned leaf in the main editor area that can be reused.
|
||||
* Mimics Obsidian's native behavior of selecting an existing tab rather
|
||||
* than always creating a new one. Only considers leaves in the main
|
||||
* editor area, never side panes.
|
||||
* @param app - Obsidian app instance
|
||||
* @param viewTypeFilter - Optional filter to check if a leaf's view type is suitable
|
||||
* @returns An unpinned leaf in the main area, or a new tab if none found
|
||||
*/
|
||||
private static findNextUnpinnedLeaf(
|
||||
app: App,
|
||||
viewTypeFilter?: (viewType: string) => boolean
|
||||
): WorkspaceLeaf | null {
|
||||
// Scan only main editor leaves for an unpinned candidate
|
||||
const candidates: WorkspaceLeaf[] = [];
|
||||
app.workspace.iterateRootLeaves((leaf: WorkspaceLeaf) => {
|
||||
if (!this.isInMainEditorArea(app, leaf)) return;
|
||||
if (!this.isLeafPinned(leaf)) {
|
||||
if (!viewTypeFilter || viewTypeFilter(leaf.view.getViewType())) {
|
||||
candidates.push(leaf);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (candidates.length > 0) {
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
// No unpinned leaf found in main area — create a new tab as last resort
|
||||
try {
|
||||
return app.workspace.getLeaf('tab');
|
||||
} catch (error) {
|
||||
centralizedLogger.error('Failed to create new leaf:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the appropriate leaf based on reuseCurrentTab setting
|
||||
* @param app - Obsidian app instance
|
||||
|
|
@ -30,16 +83,12 @@ export class LeafSelectionService {
|
|||
*/
|
||||
private static reuseCurrentLeaf(app: App): WorkspaceLeaf | null {
|
||||
const activeLeaf = app.workspace.activeLeaf;
|
||||
if (activeLeaf) {
|
||||
if (activeLeaf && !this.isLeafPinned(activeLeaf) && this.isInMainEditorArea(app, activeLeaf)) {
|
||||
return activeLeaf;
|
||||
}
|
||||
|
||||
try {
|
||||
return app.workspace.getLeaf('tab');
|
||||
} catch (error) {
|
||||
centralizedLogger.error('Failed to create new leaf:', error);
|
||||
return null;
|
||||
}
|
||||
// Active leaf is pinned or in a side pane — find the next unpinned leaf instead
|
||||
return this.findNextUnpinnedLeaf(app);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -51,19 +100,15 @@ export class LeafSelectionService {
|
|||
): WorkspaceLeaf | null {
|
||||
const activeLeaf = app.workspace.activeLeaf;
|
||||
|
||||
if (activeLeaf) {
|
||||
if (activeLeaf && !this.isLeafPinned(activeLeaf) && this.isInMainEditorArea(app, activeLeaf)) {
|
||||
const viewType = activeLeaf.view.getViewType();
|
||||
if (!viewTypeFilter || viewTypeFilter(viewType)) {
|
||||
return activeLeaf;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return app.workspace.getLeaf('tab');
|
||||
} catch (error) {
|
||||
centralizedLogger.error('Failed to create new leaf:', error);
|
||||
return null;
|
||||
}
|
||||
// Active leaf is pinned, filtered out, or in a side pane — find the next unpinned leaf
|
||||
return this.findNextUnpinnedLeaf(app, viewTypeFilter);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -78,10 +123,11 @@ export class LeafSelectionService {
|
|||
file: TFile,
|
||||
reuseCurrentTab: boolean
|
||||
): WorkspaceLeaf | null {
|
||||
// First, try to find existing leaf with this file
|
||||
// First, try to find existing leaf with this file (only in main editor area)
|
||||
const existingLeaf = app.workspace.getLeavesOfType('markdown')
|
||||
.find(leaf => {
|
||||
try {
|
||||
if (!this.isInMainEditorArea(app, leaf)) return false;
|
||||
const view = leaf.view as MarkdownView;
|
||||
const viewFile = view?.file;
|
||||
if (!viewFile || !file) return false;
|
||||
|
|
@ -101,18 +147,13 @@ export class LeafSelectionService {
|
|||
// If not found and reuseCurrentTab is enabled, try to reuse current leaf
|
||||
if (reuseCurrentTab) {
|
||||
const activeLeaf = app.workspace.activeLeaf;
|
||||
if (activeLeaf) {
|
||||
if (activeLeaf && !this.isLeafPinned(activeLeaf) && this.isInMainEditorArea(app, activeLeaf)) {
|
||||
return activeLeaf;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new leaf
|
||||
try {
|
||||
return app.workspace.getLeaf('tab');
|
||||
} catch (error) {
|
||||
centralizedLogger.error('Failed to create new leaf:', error);
|
||||
return null;
|
||||
}
|
||||
// Find the next unpinned leaf in the main area, or create a new tab as last resort
|
||||
return this.findNextUnpinnedLeaf(app);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
import { App, Notice, WorkspaceLeaf, MarkdownView } from 'obsidian';
|
||||
import { Stream } from '../../shared/types';
|
||||
import { openStreamDate } from './streamUtils';
|
||||
import { Logger } from '../debug-logging/Logger';
|
||||
import { Command, StreamManager } from '../../shared/interfaces';
|
||||
import { DateStateManager } from '../../shared/DateStateManager';
|
||||
import { StreamContextService } from '../../shared/StreamContextService';
|
||||
|
||||
const log = new Logger();
|
||||
|
||||
export class OpenTodayCurrentStreamCommand implements Command {
|
||||
constructor(
|
||||
private app: App,
|
||||
private streams: Stream[],
|
||||
private reuseCurrentTab: boolean = false,
|
||||
private plugin?: StreamManager,
|
||||
private targetStream?: Stream,
|
||||
private targetLeaf?: WorkspaceLeaf,
|
||||
private dateStateManager?: DateStateManager
|
||||
) { }
|
||||
|
||||
async execute(): Promise<void> {
|
||||
log.debug('Executing OpenTodayCurrentStreamCommand');
|
||||
|
||||
// Check if there are any streams configured
|
||||
if (this.streams.length === 0) {
|
||||
log.debug('No streams configured');
|
||||
new Notice('No streams configured. Please add streams in the plugin settings first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the current stream from the targetStream or dynamic resolution
|
||||
const currentStream = this.targetStream || this.findCurrentStream();
|
||||
|
||||
if (!currentStream) {
|
||||
log.debug('No current stream found, cannot open today note');
|
||||
new Notice('No active stream context found. Please open a file belonging to a stream first.');
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug(`Opening today's note for current stream: ${currentStream.name}`);
|
||||
await openStreamDate(this.app, currentStream, new Date(), this.reuseCurrentTab, this.targetLeaf, this.dateStateManager);
|
||||
}
|
||||
|
||||
private findCurrentStream(): Stream | null {
|
||||
const streamContextService = new StreamContextService();
|
||||
|
||||
// 1. Try target leaf if provided
|
||||
if (this.targetLeaf && this.targetLeaf.view instanceof MarkdownView && this.targetLeaf.view.file) {
|
||||
const stream = streamContextService.getStreamForFile(this.targetLeaf.view.file, this.streams);
|
||||
if (stream) return stream;
|
||||
}
|
||||
|
||||
// 2. Try active file
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (activeFile) {
|
||||
const stream = streamContextService.getStreamForFile(activeFile, this.streams);
|
||||
if (stream) return stream;
|
||||
}
|
||||
|
||||
log.debug('No stream context found for current context');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
64
src/slices/file-operations/OpenTodayPrimaryStreamCommand.ts
Normal file
64
src/slices/file-operations/OpenTodayPrimaryStreamCommand.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { App, Notice, WorkspaceLeaf } from 'obsidian';
|
||||
import { Stream } from '../../shared/types';
|
||||
import { openStreamDate } from './streamUtils';
|
||||
import { Logger } from '../debug-logging/Logger';
|
||||
import { Command, SettingsManager } from '../../shared/interfaces';
|
||||
import { DateStateManager } from '../../shared/DateStateManager';
|
||||
|
||||
const log = new Logger();
|
||||
|
||||
/**
|
||||
* Opens today's note for the single configured "primary" stream.
|
||||
*
|
||||
* The primary stream is selected in settings (stored as settings.primaryStreamId).
|
||||
*/
|
||||
export class OpenTodayPrimaryStreamCommand implements Command {
|
||||
constructor(
|
||||
private app: App,
|
||||
private streams: Stream[],
|
||||
private settingsManager: SettingsManager,
|
||||
private reuseCurrentTab: boolean = false,
|
||||
private targetLeaf?: WorkspaceLeaf,
|
||||
private dateStateManager?: DateStateManager
|
||||
) { }
|
||||
|
||||
async execute(): Promise<void> {
|
||||
log.debug('Executing OpenTodayPrimaryStreamCommand');
|
||||
|
||||
if (this.streams.length === 0) {
|
||||
new Notice('No streams configured. Please add streams in the plugin settings first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const primaryStreamId = this.settingsManager.settings.primaryStreamId;
|
||||
if (!primaryStreamId) {
|
||||
new Notice('No primary stream is set. Select one in the Streams plugin settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
const primaryStream = this.streams.find(s => s.id === primaryStreamId) ?? null;
|
||||
if (!primaryStream) {
|
||||
// Settings references a stream that no longer exists.
|
||||
this.settingsManager.settings.primaryStreamId = null;
|
||||
await this.settingsManager.saveSettings();
|
||||
new Notice('Primary stream is missing. Please select a primary stream in settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (primaryStream.disabled) {
|
||||
new Notice(`Primary stream "${primaryStream.name}" is disabled. Enable it or choose a different primary stream.`);
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug(`Opening today's note for primary stream: ${primaryStream.name}`);
|
||||
await openStreamDate(
|
||||
this.app,
|
||||
primaryStream,
|
||||
new Date(),
|
||||
this.reuseCurrentTab,
|
||||
this.targetLeaf,
|
||||
this.dateStateManager
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { App } from 'obsidian';
|
||||
import { App, WorkspaceLeaf } from 'obsidian';
|
||||
import { Stream } from '../../shared/types';
|
||||
import { openStreamDate } from './streamUtils';
|
||||
import { Logger } from '../debug-logging/Logger';
|
||||
|
|
@ -11,12 +11,13 @@ export class OpenTodayStreamCommand implements Command {
|
|||
constructor(
|
||||
private app: App,
|
||||
private stream: Stream,
|
||||
private reuseCurrentTab: boolean = false
|
||||
private reuseCurrentTab: boolean = false,
|
||||
private targetLeaf?: WorkspaceLeaf
|
||||
) {}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
log.debug(`Opening today's note for stream: ${this.stream.name}`);
|
||||
log.debug(`Reuse current tab: ${this.reuseCurrentTab}`);
|
||||
await openStreamDate(this.app, this.stream, new Date(), this.reuseCurrentTab);
|
||||
await openStreamDate(this.app, this.stream, new Date(), this.reuseCurrentTab, this.targetLeaf);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,11 +27,12 @@ describe('View Selection Logic for Meld Integration', () => {
|
|||
const stream: Stream = {
|
||||
id: 'test-stream',
|
||||
name: 'Test Stream',
|
||||
folder: 'test',
|
||||
|
||||
icon: 'file-text',
|
||||
showTodayInRibbon: false,
|
||||
addCommand: false,
|
||||
encryptThisStream: false,
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
|
|
@ -43,11 +44,12 @@ describe('View Selection Logic for Meld Integration', () => {
|
|||
const stream: Stream = {
|
||||
id: 'test-stream',
|
||||
name: 'Test Stream',
|
||||
folder: 'test',
|
||||
|
||||
icon: 'file-text',
|
||||
showTodayInRibbon: false,
|
||||
addCommand: false,
|
||||
encryptThisStream: true,
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
|
|
@ -61,11 +63,12 @@ describe('View Selection Logic for Meld Integration', () => {
|
|||
const stream: Stream = {
|
||||
id: 'test-stream',
|
||||
name: 'Test Stream',
|
||||
folder: 'test',
|
||||
|
||||
icon: 'file-text',
|
||||
showTodayInRibbon: false,
|
||||
addCommand: false,
|
||||
encryptThisStream: true,
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
|
|
@ -77,11 +80,12 @@ describe('View Selection Logic for Meld Integration', () => {
|
|||
const stream: Stream = {
|
||||
id: 'test-stream',
|
||||
name: 'Test Stream',
|
||||
folder: 'test',
|
||||
|
||||
icon: 'file-text',
|
||||
showTodayInRibbon: false,
|
||||
addCommand: false,
|
||||
encryptThisStream: false,
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ export { CreateFileView, CREATE_FILE_VIEW_TYPE } from './CreateFileView';
|
|||
export { CreateFileViewEncrypted, CREATE_FILE_VIEW_ENCRYPTED_TYPE } from './CreateFileViewEncrypted';
|
||||
export { InstallMeldView, INSTALL_MELD_VIEW_TYPE } from './InstallMeldView';
|
||||
export { OpenStreamDateCommand } from './OpenStreamDateCommand';
|
||||
export { OpenTodayCurrentStreamCommand } from './OpenTodayCurrentStreamCommand';
|
||||
export { OpenTodayPrimaryStreamCommand } from './OpenTodayPrimaryStreamCommand';
|
||||
export { OpenTodayStreamCommand } from './OpenTodayStreamCommand';
|
||||
export * from './streamUtils';
|
||||
export { LeafSelectionService } from './LeafSelectionService';
|
||||
|
|
|
|||
|
|
@ -98,6 +98,29 @@ interface AppWithViewRegistry extends App {
|
|||
|
||||
|
||||
|
||||
/**
|
||||
* Recursively creates a folder path if it doesn't exist.
|
||||
*/
|
||||
export async function ensureFolderExists(app: App, folderPath: string): Promise<void> {
|
||||
if (!folderPath) return;
|
||||
|
||||
const parts = folderPath.split(/[/\\]/).filter(Boolean);
|
||||
let currentPath = '';
|
||||
|
||||
for (const part of parts) {
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
||||
const folderExists = app.vault.getAbstractFileByPath(currentPath);
|
||||
if (!folderExists) {
|
||||
try {
|
||||
await app.vault.createFolder(currentPath);
|
||||
} catch (error) {
|
||||
// Folder might have been created concurrently
|
||||
centralizedLogger.debug(`Folder might already exist ${currentPath}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date as YYYY-MM-DD for filenames
|
||||
*/
|
||||
|
|
@ -133,20 +156,20 @@ export function getFolderSuggestions(app: App): string[] {
|
|||
|
||||
export async function createDailyNote(app: App, folder: string): Promise<TFile | null> {
|
||||
const date = new Date();
|
||||
const fileName = `${formatDateToYYYYMMDD(date)}.md`;
|
||||
// Use the flexible resolver but for a generic/default stream context
|
||||
const fakeStream: Stream = {
|
||||
id: 'tmp', name: 'tmp', icon: 'book', showTodayInRibbon: false, addCommand: false,
|
||||
encryptThisStream: false, disabled: false,
|
||||
dateFormat: `${folder}/YYYY-MM-DD`
|
||||
};
|
||||
|
||||
const folderPath = folder
|
||||
.split(/[/\\]/)
|
||||
.filter(Boolean)
|
||||
.join('/');
|
||||
const filePath = folderPath ? `${folderPath}/${fileName}` : fileName;
|
||||
const filePath = resolveStreamFilePath(fakeStream, date);
|
||||
|
||||
let file = app.vault.getAbstractFileByPath(filePath);
|
||||
|
||||
if (!file) {
|
||||
if (folderPath && !app.vault.getAbstractFileByPath(folderPath)) {
|
||||
await app.vault.createFolder(folderPath);
|
||||
}
|
||||
const folderPath = filePath.substring(0, filePath.lastIndexOf('/'));
|
||||
await ensureFolderExists(app, folderPath);
|
||||
|
||||
const template = '';
|
||||
file = await app.vault.create(filePath, template);
|
||||
|
|
@ -155,6 +178,64 @@ export async function createDailyNote(app: App, folder: string): Promise<TFile |
|
|||
return file instanceof TFile ? file : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a dynamic file path for a stream using the provided date.
|
||||
* Supports `{TOKEN}` bracket syntax for mixing literal text and moment.js formats.
|
||||
* Maintains backwards compatibility for `dateFormat` strings without brackets.
|
||||
*/
|
||||
export function resolveStreamFilePath(stream: Stream, date: Date | string, extension: string = 'md'): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
const momentDate = (window as any).moment(d);
|
||||
|
||||
// Process File path template (File Name + Path)
|
||||
let fullPath = stream.dateFormat || 'YYYY-MM-DD';
|
||||
|
||||
if (fullPath.includes('{')) {
|
||||
// Evaluate {TOKENS}
|
||||
fullPath = fullPath.replace(/\{([^}]+)\}/g, (_, token) => momentDate.format(token));
|
||||
} else {
|
||||
// Legacy mode: try to intelligently wrap recognized date tokens to prevent parsing literal folder names
|
||||
// Only target standard Y/M/D/H/m/s/w components separated by non-word chars
|
||||
fullPath = fullPath.replace(/\b(Y+|M+|D+|d+|H+|h+|m+|s+|w+|W+|A|a)\b/g, (match) => {
|
||||
return momentDate.format(match);
|
||||
});
|
||||
}
|
||||
|
||||
if (!fullPath.endsWith(`.${extension}`)) {
|
||||
fullPath += `.${extension}`;
|
||||
}
|
||||
|
||||
// Normalize path for Obsidian API compatibility (remove leading slash if present)
|
||||
if (fullPath.startsWith('/')) {
|
||||
fullPath = fullPath.slice(1);
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the static top-level directory from a stream's file path template.
|
||||
* Used to identify if an arbitrary file belongs to a stream.
|
||||
*/
|
||||
export function getStreamBaseFolder(stream: Stream): string {
|
||||
const format = stream.dateFormat || '';
|
||||
|
||||
const braceIndex = format.indexOf('{');
|
||||
let prefix = format;
|
||||
|
||||
if (braceIndex !== -1) {
|
||||
prefix = format.substring(0, braceIndex);
|
||||
}
|
||||
|
||||
// Find the last slash in the remaining literal prefix
|
||||
const lastSlash = prefix.lastIndexOf('/');
|
||||
if (lastSlash !== -1) {
|
||||
return prefix.substring(0, lastSlash);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export async function openStreamDate(app: App, stream: Stream, date: Date = new Date(), reuseCurrentTab = false, targetLeaf?: WorkspaceLeaf, dateStateManager?: DateStateManager): Promise<void> {
|
||||
// Opening stream date
|
||||
|
||||
|
|
@ -163,15 +244,10 @@ export async function openStreamDate(app: App, stream: Stream, date: Date = new
|
|||
return;
|
||||
}
|
||||
|
||||
const fileName = `${formatDateToYYYYMMDD(date)}.md`;
|
||||
// Formatted date
|
||||
|
||||
const folderPath = stream.folder
|
||||
.split(/[/\\]/)
|
||||
.filter(Boolean)
|
||||
.join('/');
|
||||
// Always recalculate filePath from the stream object, which should be fresh
|
||||
const filePath = folderPath ? `${folderPath}/${fileName}` : fileName;
|
||||
const filePath = resolveStreamFilePath(stream, date);
|
||||
const folderPath = filePath.substring(0, filePath.lastIndexOf('/'));
|
||||
|
||||
// Looking for file at path
|
||||
|
||||
let file = app.vault.getAbstractFileByPath(filePath);
|
||||
|
|
@ -184,17 +260,7 @@ export async function openStreamDate(app: App, stream: Stream, date: Date = new
|
|||
|
||||
if (!file) {
|
||||
// File not found, showing create file view
|
||||
if (folderPath) {
|
||||
try {
|
||||
const folderExists = app.vault.getAbstractFileByPath(folderPath);
|
||||
if (!folderExists) {
|
||||
await app.vault.createFolder(folderPath);
|
||||
// Created folder
|
||||
}
|
||||
} catch (error) {
|
||||
// log.debug('Using existing folder:', folderPath);
|
||||
}
|
||||
}
|
||||
await ensureFolderExists(app, folderPath);
|
||||
|
||||
// Use targetLeaf if provided, otherwise select appropriate leaf
|
||||
const leaf = targetLeaf || LeafSelectionService.selectLeaf(
|
||||
|
|
|
|||
|
|
@ -3,13 +3,15 @@ import { SettingsAwareSliceService } from '../../shared/BaseSlice';
|
|||
import { Stream, StreamsSettings } from '../../shared/types';
|
||||
import { eventBus, EVENTS } from '../../shared/EventBus';
|
||||
import { OpenTodayStreamCommand } from '../file-operations/OpenTodayStreamCommand';
|
||||
import { OpenTodayCurrentStreamCommand } from '../file-operations/OpenTodayCurrentStreamCommand';
|
||||
import { OpenTodayPrimaryStreamCommand } from '../file-operations/OpenTodayPrimaryStreamCommand';
|
||||
|
||||
export class RibbonService extends SettingsAwareSliceService {
|
||||
private ribbonIconsByStream: Map<string, { today?: HTMLElement }> = new Map();
|
||||
private commandsByStreamId: Map<string, string> = new Map();
|
||||
private instanceId = Math.random().toString(36).substring(7);
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
this.log(`[RibbonService-${this.instanceId}] Initializing...`);
|
||||
if (this.initialized) return;
|
||||
|
||||
this.initializeAllRibbonIcons();
|
||||
|
|
@ -27,14 +29,30 @@ export class RibbonService extends SettingsAwareSliceService {
|
|||
}
|
||||
|
||||
private registerEventBusListeners(): void {
|
||||
this.log(`[RibbonService-${this.instanceId}] Registering event bus listeners`);
|
||||
// Listen for stream changes
|
||||
eventBus.subscribe(EVENTS.STREAM_ADDED, (event) => this.onStreamAdded(event.data));
|
||||
eventBus.subscribe(EVENTS.STREAM_UPDATED, (event) => this.onStreamUpdated(event.data));
|
||||
eventBus.subscribe(EVENTS.STREAM_REMOVED, (event) => this.onStreamRemoved(event.data.streamId));
|
||||
eventBus.subscribe(EVENTS.STREAM_ADDED, (event) => {
|
||||
if (event.data && 'stream' in event.data) {
|
||||
this.onStreamAdded((event.data as any).stream);
|
||||
}
|
||||
});
|
||||
eventBus.subscribe(EVENTS.STREAM_UPDATED, (event) => {
|
||||
if (event.data && 'stream' in event.data) {
|
||||
this.onStreamUpdated((event.data as any).stream);
|
||||
}
|
||||
});
|
||||
eventBus.subscribe(EVENTS.STREAM_REMOVED, (event) => {
|
||||
if (event.data && 'streamId' in event.data) {
|
||||
this.onStreamRemoved((event.data as any).streamId);
|
||||
}
|
||||
});
|
||||
// eventBus.subscribe(EVENTS.ACTIVE_STREAM_CHANGED, () => this.updateAllRibbonIcons()); - REMOVED
|
||||
|
||||
// Listen for settings changes
|
||||
eventBus.subscribe(EVENTS.SETTINGS_CHANGED, () => this.updateAllRibbonIcons());
|
||||
eventBus.subscribe(EVENTS.SETTINGS_CHANGED, () => {
|
||||
this.log('[RibbonService] Received SETTINGS_CHANGED event');
|
||||
this.updateAllRibbonIcons();
|
||||
});
|
||||
}
|
||||
|
||||
private onStreamAdded(stream: Stream): void {
|
||||
|
|
@ -65,22 +83,26 @@ export class RibbonService extends SettingsAwareSliceService {
|
|||
});
|
||||
}
|
||||
|
||||
public updateAllRibbonIcons(): void {
|
||||
this.removeAllRibbonIcons();
|
||||
this.initializeAllRibbonIcons();
|
||||
}
|
||||
|
||||
|
||||
private createAllStreamsIcon(): void {
|
||||
// Open today for current stream button
|
||||
// Go to primary stream button
|
||||
this.getPlugin().addRibbonIcon(
|
||||
'calendar',
|
||||
'Streams: Open today for current stream',
|
||||
() => {
|
||||
const command = new OpenTodayCurrentStreamCommand(
|
||||
'Streams: Go to primary stream',
|
||||
(evt: MouseEvent) => {
|
||||
// Ctrl/Cmd+click forces a new tab
|
||||
const forceNewTab = evt.ctrlKey || evt.metaKey;
|
||||
const targetLeaf = forceNewTab
|
||||
? this.getPlugin().app.workspace.getLeaf('tab')
|
||||
: undefined;
|
||||
|
||||
const command = new OpenTodayPrimaryStreamCommand(
|
||||
this.getPlugin().app,
|
||||
this.getStreams(),
|
||||
this.getPlugin(),
|
||||
this.getSettings().reuseCurrentTab,
|
||||
this.getPlugin()
|
||||
targetLeaf
|
||||
);
|
||||
command.execute();
|
||||
}
|
||||
|
|
@ -88,6 +110,13 @@ export class RibbonService extends SettingsAwareSliceService {
|
|||
}
|
||||
|
||||
private createStreamIcons(stream: Stream): void {
|
||||
this.log(`[RibbonService-${this.instanceId}] createStreamIcons called for ${stream?.name} (${stream?.id}). Show: ${stream?.showTodayInRibbon}, Disabled: ${stream?.disabled}`);
|
||||
|
||||
if (!stream || !stream.id) {
|
||||
this.log(`[RibbonService-${this.instanceId}] Attempted to create icons for invalid stream`, stream);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get or create entry for this stream
|
||||
let streamIcons = this.ribbonIconsByStream.get(stream.id);
|
||||
if (!streamIcons) {
|
||||
|
|
@ -95,42 +124,210 @@ export class RibbonService extends SettingsAwareSliceService {
|
|||
this.ribbonIconsByStream.set(stream.id, streamIcons);
|
||||
}
|
||||
|
||||
// Only create the icon if it should be visible
|
||||
if (stream.showTodayInRibbon && !streamIcons.today) {
|
||||
this.log(`Creating Today icon for stream ${stream.id}`);
|
||||
const shouldBeVisible = stream.showTodayInRibbon && !stream.disabled;
|
||||
|
||||
streamIcons.today = this.getPlugin().addRibbonIcon(
|
||||
stream.icon,
|
||||
`Open today for ${stream.name}`,
|
||||
() => {
|
||||
const command = new OpenTodayStreamCommand(
|
||||
this.getPlugin().app,
|
||||
stream,
|
||||
this.getSettings().reuseCurrentTab
|
||||
);
|
||||
command.execute();
|
||||
}
|
||||
);
|
||||
// Case 1: Should NOT be visible
|
||||
if (!shouldBeVisible) {
|
||||
if (streamIcons.today) {
|
||||
const label = streamIcons.today.getAttribute('aria-label') || 'unknown';
|
||||
this.log(`[RibbonService-${this.instanceId}] Toggling OFF: Removing icon for stream "${stream.name}" (ID: ${stream.id}). Button aria-label: "${label}"`);
|
||||
streamIcons.today.remove();
|
||||
streamIcons.today = undefined;
|
||||
} else {
|
||||
this.log(`[RibbonService-${this.instanceId}] Toggling OFF (No action): Icon for stream "${stream.name}" was already missing.`);
|
||||
}
|
||||
// Ensure no ghosts remain
|
||||
this.enforceRibbonConsistency();
|
||||
return;
|
||||
}
|
||||
|
||||
// Case 2: Should be visible
|
||||
// Check if existing icon is valid
|
||||
if (streamIcons.today) {
|
||||
if (streamIcons.today.isConnected) {
|
||||
this.log(`[RibbonService-${this.instanceId}] Toggling ON (No action): Icon for stream "${stream.name}" is already present and connected.`);
|
||||
// Already valid and in DOM. Do nothing.
|
||||
return;
|
||||
} else {
|
||||
this.log(`[RibbonService-${this.instanceId}] Found disconnected reference for "${stream.name}". Clearing reference.`);
|
||||
// Reference exists but not in DOM. Clear reference.
|
||||
streamIcons.today = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any ghosts before creating (but preserve tracked one, which we just checked isn't there)
|
||||
// enforceRibbonConsistency is called AFTER creation to ensure we know what the 'tracked' one is.
|
||||
|
||||
this.log(`[RibbonService-${this.instanceId}] Toggling ON: Creating new ribbon icon for stream "${stream.name}" (ID: ${stream.id})`);
|
||||
|
||||
const iconEl = this.getPlugin().addRibbonIcon(
|
||||
stream.icon,
|
||||
`Open today for ${stream.name}`,
|
||||
(evt: MouseEvent) => {
|
||||
// Ctrl/Cmd+click forces a new tab
|
||||
const forceNewTab = evt.ctrlKey || evt.metaKey;
|
||||
const targetLeaf = forceNewTab
|
||||
? this.getPlugin().app.workspace.getLeaf('tab')
|
||||
: undefined;
|
||||
|
||||
const command = new OpenTodayStreamCommand(
|
||||
this.getPlugin().app,
|
||||
stream,
|
||||
this.getSettings().reuseCurrentTab,
|
||||
targetLeaf
|
||||
);
|
||||
command.execute();
|
||||
}
|
||||
);
|
||||
|
||||
// Tag the icon for robust identification and removal
|
||||
iconEl.addClass('streams-ribbon-icon');
|
||||
iconEl.setAttribute('data-stream-id', stream.id);
|
||||
|
||||
this.log(`[RibbonService-${this.instanceId}] Created icon element: ${iconEl.outerHTML}`);
|
||||
|
||||
streamIcons.today = iconEl;
|
||||
|
||||
// Final policing pass
|
||||
this.enforceRibbonConsistency();
|
||||
}
|
||||
|
||||
private removeStreamIcons(streamId: string): void {
|
||||
this.log(`[RibbonService-${this.instanceId}] removeStreamIcons called for ${streamId}`);
|
||||
if (!streamId) return;
|
||||
|
||||
// Try to find stream name for better ghost busting
|
||||
let streamName = '';
|
||||
const stream = this.getStreams().find(s => s.id === streamId);
|
||||
if (stream) {
|
||||
streamName = stream.name;
|
||||
}
|
||||
|
||||
// 1. Remove tracked icon
|
||||
const streamIcons = this.ribbonIconsByStream.get(streamId);
|
||||
if (streamIcons) {
|
||||
if (streamIcons.today) {
|
||||
streamIcons.today.remove();
|
||||
streamIcons.today = undefined;
|
||||
}
|
||||
this.ribbonIconsByStream.delete(streamId);
|
||||
}
|
||||
|
||||
// 2. Remove any "ghost" icons from the DOM
|
||||
this.enforceRibbonConsistency();
|
||||
}
|
||||
|
||||
private enforceRibbonConsistency(): void {
|
||||
this.log(`[RibbonService-${this.instanceId}] Enforcing ribbon consistency...`);
|
||||
|
||||
const streams = this.getStreams();
|
||||
// Set of IDs that are ALLOWED to have an icon
|
||||
const allowedStreamIds = new Set(
|
||||
streams.filter(s => s.showTodayInRibbon && !s.disabled).map(s => s.id)
|
||||
);
|
||||
|
||||
// Map Name -> ID for looking up untagged icons
|
||||
const nameToId = new Map(streams.map(s => [s.name, s.id]));
|
||||
|
||||
// 1. Scan ALL icons that look like ours (tagged)
|
||||
const taggedIcons = document.querySelectorAll('.streams-ribbon-icon');
|
||||
taggedIcons.forEach(icon => {
|
||||
const streamId = icon.getAttribute('data-stream-id');
|
||||
if (streamId) {
|
||||
// Is this stream allowed to be visible?
|
||||
if (!allowedStreamIds.has(streamId)) {
|
||||
this.log(`[RibbonService-${this.instanceId}] Policing: Removing hidden stream icon (ID: ${streamId})`);
|
||||
icon.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
// It IS allowed, but is it the "Blessed" instance?
|
||||
// If we have multiple icons for the same valid stream, only the tracked one survives.
|
||||
const tracked = this.ribbonIconsByStream.get(streamId)?.today;
|
||||
if (tracked && tracked !== icon) {
|
||||
this.log(`[RibbonService-${this.instanceId}] Policing: Removing duplicate/stale icon for valid stream (ID: ${streamId})`);
|
||||
icon.remove();
|
||||
} else if (!tracked) {
|
||||
// It's allowed, but we aren't tracking it?
|
||||
// This creates a dilemma. If we are just initializing, we might not have tracked it yet.
|
||||
// But we called 'createStreamIcons' which sets tracking.
|
||||
// So if it's untracked, it's a ghost.
|
||||
this.log(`[RibbonService-${this.instanceId}] Policing: Removing untracked ghost icon for valid stream (ID: ${streamId})`);
|
||||
icon.remove();
|
||||
}
|
||||
} else {
|
||||
// Has class but no ID? Suspicious. Remove.
|
||||
this.log(`[RibbonService-${this.instanceId}] Policing: Removing malformed icon`);
|
||||
icon.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Scan ALL ribbon actions for untagged "Islanders"
|
||||
const allActions = document.querySelectorAll('.side-dock-ribbon-action');
|
||||
allActions.forEach(icon => {
|
||||
// Skip if it's already processed as tagged (optimization)
|
||||
if (icon.classList.contains('streams-ribbon-icon')) return;
|
||||
|
||||
const label = icon.getAttribute('aria-label');
|
||||
if (label && label.startsWith('Open today for ')) {
|
||||
const streamName = label.substring('Open today for '.length);
|
||||
const streamId = nameToId.get(streamName);
|
||||
|
||||
if (streamId) {
|
||||
// We found a match in our streams!
|
||||
// Since it is NOT tagged (we checked class), it is definitely a Ghost/Legacy icon.
|
||||
// We REMOVE it always, because any valid icon MUST be tagged by now.
|
||||
this.log(`[RibbonService-${this.instanceId}] Policing: Removing legacy untagged icon for "${streamName}"`);
|
||||
icon.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private removeAllRibbonIcons(): void {
|
||||
for (const streamIcons of this.ribbonIconsByStream.values()) {
|
||||
this.log(`[RibbonService-${this.instanceId}] removeAllRibbonIcons calling. Count: ${this.ribbonIconsByStream.size}`);
|
||||
|
||||
// 1. Clear tracked icons
|
||||
for (const [id, streamIcons] of this.ribbonIconsByStream.entries()) {
|
||||
if (streamIcons.today) {
|
||||
streamIcons.today.remove();
|
||||
}
|
||||
}
|
||||
this.ribbonIconsByStream.clear();
|
||||
|
||||
// 2. Sweep the DOM for any remaining icons with our class
|
||||
const remainingIcons = document.querySelectorAll('.streams-ribbon-icon');
|
||||
remainingIcons.forEach(icon => icon.remove());
|
||||
}
|
||||
|
||||
public updateAllRibbonIcons(): void {
|
||||
this.log(`[RibbonService-${this.instanceId}] updateAllRibbonIcons called (Diff Strategy)`);
|
||||
// Log stack trace to identify caller
|
||||
try {
|
||||
throw new Error('Ribbon Update Trace');
|
||||
} catch (e: any) {
|
||||
this.log(`[RibbonService-${this.instanceId}] Triggered by: ${e.stack}`);
|
||||
}
|
||||
|
||||
const streams = this.getStreams();
|
||||
const activeStreamIds = new Set<string>();
|
||||
|
||||
// 1. Process all streams: Ensure they are in correct state
|
||||
streams.forEach(stream => {
|
||||
activeStreamIds.add(stream.id);
|
||||
this.createStreamIcons(stream); // createStreamIcons now handles both create and remove-if-hidden
|
||||
});
|
||||
|
||||
// 2. Remove icons for streams that no longer exist in settings (deleted)
|
||||
for (const trackedId of this.ribbonIconsByStream.keys()) {
|
||||
if (!activeStreamIds.has(trackedId)) {
|
||||
this.log(`[RibbonService-${this.instanceId}] Removing orphan icons for stream ${trackedId}`);
|
||||
this.removeStreamIcons(trackedId);
|
||||
}
|
||||
}
|
||||
|
||||
// Final Policing to catch any discrepancies
|
||||
this.enforceRibbonConsistency();
|
||||
}
|
||||
|
||||
private initializeStreamCommands(): void {
|
||||
|
|
@ -140,7 +337,9 @@ export class RibbonService extends SettingsAwareSliceService {
|
|||
}
|
||||
|
||||
private updateStreamCommands(stream: Stream): void {
|
||||
if (stream.addCommand) {
|
||||
if (!stream || !stream.id) return;
|
||||
|
||||
if (stream.addCommand && !stream.disabled) {
|
||||
this.addStreamCommand(stream);
|
||||
} else {
|
||||
this.removeStreamCommand(stream.id);
|
||||
|
|
|
|||
237
src/slices/ribbon-integration/__tests__/RibbonService.test.ts
Normal file
237
src/slices/ribbon-integration/__tests__/RibbonService.test.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import { RibbonService } from '../RibbonService';
|
||||
import { Stream, StreamsSettings } from '../../../shared/types';
|
||||
import { Plugin } from 'obsidian';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../../shared/EventBus', () => ({
|
||||
eventBus: {
|
||||
subscribe: jest.fn(),
|
||||
emit: jest.fn()
|
||||
},
|
||||
EVENTS: {
|
||||
STREAM_ADDED: 'STREAM_ADDED',
|
||||
STREAM_UPDATED: 'STREAM_UPDATED',
|
||||
STREAM_REMOVED: 'STREAM_REMOVED',
|
||||
SETTINGS_CHANGED: 'SETTINGS_CHANGED'
|
||||
}
|
||||
}));
|
||||
|
||||
describe('RibbonService', () => {
|
||||
let ribbonService: RibbonService;
|
||||
let mockPlugin: any;
|
||||
let mockApp: any;
|
||||
let mockSettings: StreamsSettings;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset DOM
|
||||
document.body.innerHTML = '';
|
||||
|
||||
// Mock App
|
||||
mockApp = {};
|
||||
|
||||
// Mock Settings
|
||||
mockSettings = {
|
||||
streams: [],
|
||||
primaryStreamId: null,
|
||||
reuseCurrentTab: false,
|
||||
// ... other settings as needed ...
|
||||
} as any;
|
||||
|
||||
// Mock Plugin
|
||||
mockPlugin = {
|
||||
app: mockApp,
|
||||
settings: mockSettings,
|
||||
addRibbonIcon: jest.fn().mockImplementation((icon, title, callback) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'side-dock-ribbon-action';
|
||||
el.setAttribute('aria-label', title);
|
||||
|
||||
// Mock Obsidian DOM extension methods
|
||||
(el as any).addClass = (cls: string) => el.classList.add(cls);
|
||||
(el as any).removeClass = (cls: string) => el.classList.remove(cls);
|
||||
(el as any).toggleClass = (cls: string, toggle: boolean) => el.classList.toggle(cls, toggle);
|
||||
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}),
|
||||
addCommand: jest.fn(),
|
||||
saveSettings: jest.fn(),
|
||||
};
|
||||
|
||||
ribbonService = new RibbonService();
|
||||
ribbonService.setPlugin(mockPlugin as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ribbonService.cleanup();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('enforceRibbonConsistency (Policing)', () => {
|
||||
it('should remove icons for disabled streams', async () => {
|
||||
// Setup: Create a ghost icon for a disabled stream
|
||||
const ghostIcon = document.createElement('div');
|
||||
ghostIcon.className = 'streams-ribbon-icon';
|
||||
ghostIcon.setAttribute('data-stream-id', 'disabled-stream');
|
||||
document.body.appendChild(ghostIcon);
|
||||
|
||||
// Configure settings: Stream exists but is disabled
|
||||
const disabledStream: Stream = {
|
||||
id: 'disabled-stream',
|
||||
name: 'Disabled Stream',
|
||||
icon: 'calendar',
|
||||
|
||||
|
||||
|
||||
showTodayInRibbon: true,
|
||||
disabled: true, // DISABLED
|
||||
addCommand: false,
|
||||
encryptThisStream: false,
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
};
|
||||
mockSettings.streams = [disabledStream];
|
||||
|
||||
// Act: Run update (triggers policing)
|
||||
ribbonService.updateAllRibbonIcons();
|
||||
|
||||
// Assert: Icon should be gone
|
||||
expect(document.body.contains(ghostIcon)).toBe(false);
|
||||
});
|
||||
|
||||
it('should remove icons for hidden streams (showTodayInRibbon: false)', async () => {
|
||||
// Setup: Create a ghost icon
|
||||
const ghostIcon = document.createElement('div');
|
||||
ghostIcon.className = 'streams-ribbon-icon';
|
||||
ghostIcon.setAttribute('data-stream-id', 'hidden-stream');
|
||||
document.body.appendChild(ghostIcon);
|
||||
|
||||
// Configure settings: Stream exists but hidden
|
||||
const hiddenStream: Stream = {
|
||||
id: 'hidden-stream',
|
||||
name: 'Hidden Stream',
|
||||
icon: 'calendar',
|
||||
|
||||
|
||||
|
||||
showTodayInRibbon: false, // HIDDEN
|
||||
disabled: false,
|
||||
addCommand: false,
|
||||
encryptThisStream: false,
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
};
|
||||
mockSettings.streams = [hiddenStream];
|
||||
|
||||
// Act
|
||||
ribbonService.updateAllRibbonIcons();
|
||||
|
||||
// Assert
|
||||
expect(document.body.contains(ghostIcon)).toBe(false);
|
||||
});
|
||||
|
||||
it('should remove icons for non-existent streams (pure ghosts)', async () => {
|
||||
const ghostIcon = document.createElement('div');
|
||||
ghostIcon.className = 'streams-ribbon-icon';
|
||||
ghostIcon.setAttribute('data-stream-id', 'unknown-stream');
|
||||
document.body.appendChild(ghostIcon);
|
||||
|
||||
mockSettings.streams = [];
|
||||
|
||||
ribbonService.updateAllRibbonIcons();
|
||||
|
||||
expect(document.body.contains(ghostIcon)).toBe(false);
|
||||
});
|
||||
|
||||
it('should remove legacy untagged icons by name', async () => {
|
||||
// Setup: Create an untagged icon with aria-label
|
||||
const ancientIcon = document.createElement('div');
|
||||
ancientIcon.className = 'side-dock-ribbon-action'; // Classic obsidian class
|
||||
ancientIcon.setAttribute('aria-label', 'Open today for Legacy Stream');
|
||||
document.body.appendChild(ancientIcon);
|
||||
|
||||
// Settings: Stream exists but is hidden (so it should strip the icon)
|
||||
const legacyStream: Stream = {
|
||||
id: 'legacy-stream',
|
||||
name: 'Legacy Stream',
|
||||
icon: 'calendar',
|
||||
|
||||
|
||||
|
||||
showTodayInRibbon: false, // Should NOT be there
|
||||
disabled: false,
|
||||
addCommand: false,
|
||||
encryptThisStream: false,
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
};
|
||||
mockSettings.streams = [legacyStream];
|
||||
|
||||
ribbonService.updateAllRibbonIcons();
|
||||
|
||||
expect(document.body.contains(ancientIcon)).toBe(false);
|
||||
});
|
||||
|
||||
it('should KEEP valid icons', async () => {
|
||||
const validStream: Stream = {
|
||||
id: 'valid-stream',
|
||||
name: 'Valid Stream',
|
||||
icon: 'calendar',
|
||||
|
||||
|
||||
|
||||
showTodayInRibbon: true,
|
||||
disabled: false,
|
||||
addCommand: false,
|
||||
encryptThisStream: false,
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
};
|
||||
mockSettings.streams = [validStream];
|
||||
|
||||
// Act: Create icons normally
|
||||
ribbonService.updateAllRibbonIcons();
|
||||
|
||||
// Assert: Document should have 1 icon
|
||||
const icons = document.querySelectorAll('.streams-ribbon-icon');
|
||||
expect(icons.length).toBe(1);
|
||||
expect(icons[0].getAttribute('data-stream-id')).toBe('valid-stream');
|
||||
});
|
||||
|
||||
it('should remove duplicate/stale icons for valid streams', async () => {
|
||||
const validStream: Stream = {
|
||||
id: 'valid-stream',
|
||||
name: 'Valid Stream',
|
||||
icon: 'calendar',
|
||||
|
||||
|
||||
|
||||
showTodayInRibbon: true,
|
||||
disabled: false,
|
||||
addCommand: false,
|
||||
encryptThisStream: false,
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
};
|
||||
mockSettings.streams = [validStream];
|
||||
|
||||
// Helper to spy on creation
|
||||
// We want to simulate a situation where an extra icon exists.
|
||||
|
||||
// 1. Manually inject a "stale" icon for this stream
|
||||
const staleIcon = document.createElement('div');
|
||||
staleIcon.className = 'streams-ribbon-icon';
|
||||
staleIcon.setAttribute('data-stream-id', 'valid-stream');
|
||||
staleIcon.innerHTML = 'old';
|
||||
document.body.appendChild(staleIcon);
|
||||
|
||||
// 2. Run update. This will create a NEW icon (the "Blessed" one)
|
||||
// and the Policeman should delete the OLD one.
|
||||
ribbonService.updateAllRibbonIcons();
|
||||
|
||||
const icons = document.querySelectorAll('.streams-ribbon-icon');
|
||||
|
||||
// Should have 1 icon (the new one)
|
||||
expect(icons.length).toBe(1);
|
||||
|
||||
// The surviving icon should NOT be the stale one
|
||||
expect(icons[0]).not.toBe(staleIcon);
|
||||
expect(icons[0].innerHTML).not.toBe('old');
|
||||
});
|
||||
});
|
||||
});
|
||||
74
src/slices/settings-management/IconPickerModal.ts
Normal file
74
src/slices/settings-management/IconPickerModal.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { App, Modal, Setting, TextComponent, getIconIds, setIcon } from 'obsidian';
|
||||
import { LucideIcon } from '../../shared/types';
|
||||
|
||||
export class IconPickerModal extends Modal {
|
||||
private onChoose: (icon: LucideIcon) => void;
|
||||
private currentIcon: string;
|
||||
private searchQuery: string = '';
|
||||
private iconGrid: HTMLElement;
|
||||
private allIcons: string[];
|
||||
|
||||
constructor(app: App, currentIcon: string, onChoose: (icon: LucideIcon) => void) {
|
||||
super(app);
|
||||
this.currentIcon = currentIcon;
|
||||
this.onChoose = onChoose;
|
||||
this.allIcons = getIconIds();
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.addClass('streams-icon-picker-modal');
|
||||
|
||||
// Header and Search
|
||||
const header = contentEl.createDiv('streams-icon-picker-header');
|
||||
header.createEl('h2', { text: 'Select Icon' });
|
||||
|
||||
const searchContainer = header.createDiv('streams-search-container');
|
||||
new TextComponent(searchContainer)
|
||||
.setPlaceholder('Search icons...')
|
||||
.setValue(this.searchQuery)
|
||||
.onChange((value) => {
|
||||
this.searchQuery = value.toLowerCase();
|
||||
this.renderIcons();
|
||||
});
|
||||
|
||||
// Icon Grid
|
||||
this.iconGrid = contentEl.createDiv('streams-icon-picker-grid');
|
||||
this.renderIcons();
|
||||
}
|
||||
|
||||
private renderIcons() {
|
||||
this.iconGrid.empty();
|
||||
|
||||
const filteredIcons = this.allIcons.filter(icon =>
|
||||
icon.toLowerCase().includes(this.searchQuery)
|
||||
);
|
||||
|
||||
filteredIcons.forEach(icon => {
|
||||
const iconItem = this.iconGrid.createDiv('streams-icon-item');
|
||||
if (icon === this.currentIcon) {
|
||||
iconItem.addClass('selected');
|
||||
}
|
||||
|
||||
const iconContainer = iconItem.createDiv('streams-icon-preview');
|
||||
setIcon(iconContainer, icon);
|
||||
|
||||
iconItem.setAttribute('aria-label', icon);
|
||||
|
||||
iconItem.addEventListener('click', () => {
|
||||
this.onChoose(icon as LucideIcon);
|
||||
this.close();
|
||||
});
|
||||
});
|
||||
|
||||
if (filteredIcons.length === 0) {
|
||||
this.iconGrid.createDiv('streams-no-icons').setText('No icons found');
|
||||
}
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { App, PluginSettingTab, Setting, Notice } from 'obsidian';
|
||||
import { App, PluginSettingTab, Setting, Notice, ButtonComponent } from 'obsidian';
|
||||
import { SettingsAwareSliceService } from '../../shared/BaseSlice';
|
||||
import { Stream, StreamsSettings, LucideIcon } from '../../shared/types';
|
||||
import { StreamsPluginInterface, SettingsManager, UIController, LogProvider } from '../../shared/interfaces';
|
||||
|
|
@ -6,6 +6,8 @@ import { eventBus, EVENTS } from '../../shared/EventBus';
|
|||
import { centralizedLogger } from '../../shared/CentralizedLogger';
|
||||
import { configurationService } from '../../shared/ConfigurationService';
|
||||
import { MeldDetectionService } from '../../slices/meld-integration';
|
||||
import { IconPickerModal } from './IconPickerModal';
|
||||
import { resolveStreamFilePath } from '../file-operations/streamUtils';
|
||||
|
||||
export class SettingsService extends SettingsAwareSliceService {
|
||||
private settingsTab: StreamsSettingTab | null = null;
|
||||
|
|
@ -75,7 +77,7 @@ export class StreamsSettingTab extends PluginSettingTab {
|
|||
|
||||
new Notice(`Streams bar component ${value ? 'shown' : 'hidden'}`);
|
||||
}));
|
||||
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Reuse current tab for calendar navigation')
|
||||
.setDesc('When enabled, calendar navigation will reuse the current tab instead of opening new tabs')
|
||||
|
|
@ -89,7 +91,7 @@ export class StreamsSettingTab extends PluginSettingTab {
|
|||
|
||||
new Notice(`Calendar navigation will ${value ? 'reuse' : 'open new'} tabs`);
|
||||
}));
|
||||
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Enable debug logging')
|
||||
.setDesc('Enable debug logging for the Streams plugin (can also be toggled via command palette)')
|
||||
|
|
@ -98,6 +100,7 @@ export class StreamsSettingTab extends PluginSettingTab {
|
|||
.onChange(async (value) => {
|
||||
this.settingsManager.settings.debugLoggingEnabled = value;
|
||||
|
||||
// Toggle Plugin Logger
|
||||
if (this.logProvider.log) {
|
||||
if (value) {
|
||||
this.logProvider.log.on();
|
||||
|
|
@ -106,29 +109,42 @@ export class StreamsSettingTab extends PluginSettingTab {
|
|||
}
|
||||
}
|
||||
|
||||
// Toggle Centralized Logger (used by slices)
|
||||
if (value) {
|
||||
centralizedLogger.enable();
|
||||
} else {
|
||||
centralizedLogger.disable();
|
||||
}
|
||||
|
||||
await this.settingsManager.saveSettings();
|
||||
|
||||
new Notice(`Debug logging ${value ? 'enabled' : 'disabled'}`);
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Bar style')
|
||||
.setDesc('Choose the visual style for the streams bar component')
|
||||
.addDropdown(dropdown => dropdown
|
||||
.addOption('default', 'Default')
|
||||
.addOption('modern', 'Modern')
|
||||
.setValue(this.settingsManager.settings.barStyle)
|
||||
.onChange(async (value: 'default' | 'modern') => {
|
||||
this.settingsManager.settings.barStyle = value;
|
||||
await this.settingsManager.saveSettings();
|
||||
|
||||
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
|
||||
|
||||
new Notice(`Bar style changed to ${value === 'default' ? 'Default' : 'Modern'}`);
|
||||
}));
|
||||
|
||||
new Setting(containerEl).setName('Streams').setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Primary stream')
|
||||
.setDesc('Used by the "Go to primary stream" command')
|
||||
.addDropdown(dropdown => {
|
||||
const current = this.settingsManager.settings.primaryStreamId ?? '';
|
||||
|
||||
dropdown.addOption('', 'Not set');
|
||||
|
||||
// Only offer enabled streams; primary stream must be a single usable stream.
|
||||
this.settingsManager.settings.streams
|
||||
.filter(s => !s.disabled)
|
||||
.forEach(stream => dropdown.addOption(stream.id, stream.name));
|
||||
|
||||
dropdown.setValue(current);
|
||||
|
||||
dropdown.onChange(async (value) => {
|
||||
this.settingsManager.settings.primaryStreamId = value ? value : null;
|
||||
await this.settingsManager.saveSettings();
|
||||
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Add stream')
|
||||
.setDesc('Create a new note stream')
|
||||
|
|
@ -144,7 +160,8 @@ export class StreamsSettingTab extends PluginSettingTab {
|
|||
showTodayInRibbon: true,
|
||||
addCommand: false,
|
||||
encryptThisStream: false,
|
||||
disabled: false
|
||||
disabled: false,
|
||||
dateFormat: 'YYYY-MM-DD'
|
||||
};
|
||||
this.settingsManager.settings.streams.push(newStream);
|
||||
await this.settingsManager.saveSettings();
|
||||
|
|
@ -162,29 +179,32 @@ export class StreamsSettingTab extends PluginSettingTab {
|
|||
|
||||
private createStreamCard(container: HTMLElement, stream: Stream, index: number): HTMLElement {
|
||||
const card = container.createDiv('streams-plugin-card');
|
||||
|
||||
|
||||
// Add disabled class if stream is disabled
|
||||
if (stream.disabled) {
|
||||
card.addClass('streams-plugin-card-disabled');
|
||||
}
|
||||
|
||||
|
||||
// Create header with title and reorder controls
|
||||
const header = card.createDiv('streams-card-header');
|
||||
const title = header.createEl('h3', { text: stream.name });
|
||||
|
||||
|
||||
// Add reorder controls to the header
|
||||
const reorderContainer = header.createDiv('streams-reorder-container');
|
||||
|
||||
|
||||
// Move up button - create simple HTML button
|
||||
const upButton = reorderContainer.createEl('button', {
|
||||
cls: 'streams-reorder-btn streams-caret-up',
|
||||
attr: {
|
||||
'data-action': 'move-up',
|
||||
'title': 'Move stream up'
|
||||
'title': 'Move stream up',
|
||||
'type': 'button'
|
||||
}
|
||||
});
|
||||
if (index === 0) upButton.disabled = true;
|
||||
upButton.addEventListener('click', async () => {
|
||||
upButton.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
await this.moveStreamUp(index);
|
||||
});
|
||||
|
||||
|
|
@ -193,53 +213,160 @@ export class StreamsSettingTab extends PluginSettingTab {
|
|||
cls: 'streams-reorder-btn streams-caret-down',
|
||||
attr: {
|
||||
'data-action': 'move-down',
|
||||
'title': 'Move stream down'
|
||||
'title': 'Move stream down',
|
||||
'type': 'button'
|
||||
}
|
||||
});
|
||||
if (index === this.settingsManager.settings.streams.length - 1) downButton.disabled = true;
|
||||
downButton.addEventListener('click', async () => {
|
||||
downButton.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
await this.moveStreamDown(index);
|
||||
});
|
||||
|
||||
|
||||
// Add status toggle
|
||||
const statusToggle = reorderContainer.createEl('button', {
|
||||
cls: `streams-status-toggle ${stream.disabled ? 'is-disabled' : 'is-enabled'}`,
|
||||
text: stream.disabled ? 'DISABLED' : 'ENABLED',
|
||||
attr: {
|
||||
'title': stream.disabled ? 'Click to enable stream' : 'Click to disable stream',
|
||||
'type': 'button'
|
||||
}
|
||||
});
|
||||
|
||||
statusToggle.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const newValue = !stream.disabled;
|
||||
stream.disabled = newValue;
|
||||
|
||||
if (newValue && this.settingsManager.settings.primaryStreamId === stream.id) {
|
||||
this.settingsManager.settings.primaryStreamId = null;
|
||||
new Notice('Primary stream was disabled and has been cleared.');
|
||||
}
|
||||
|
||||
await this.settingsManager.saveSettings();
|
||||
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
|
||||
|
||||
// Update DOM directly instead of calling this.display() to prevent losing focus
|
||||
statusToggle.className = `streams-status-toggle ${stream.disabled ? 'is-disabled' : 'is-enabled'}`;
|
||||
statusToggle.textContent = stream.disabled ? 'DISABLED' : 'ENABLED';
|
||||
statusToggle.title = stream.disabled ? 'Click to enable stream' : 'Click to disable stream';
|
||||
|
||||
if (stream.disabled) {
|
||||
card.addClass('streams-plugin-card-disabled');
|
||||
} else {
|
||||
card.removeClass('streams-plugin-card-disabled');
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
eventBus.emit(EVENTS.STREAM_UPDATED, { stream: stream }, 'settings-management');
|
||||
});
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
private addStreamSettings(container: HTMLElement, stream: Stream, index: number): void {
|
||||
// Stream name
|
||||
new Setting(container)
|
||||
.setClass('streams-setting-stacked')
|
||||
.setName('Stream name')
|
||||
.setDesc('Name of the stream')
|
||||
.addText(text => text
|
||||
.setValue(stream.name)
|
||||
.onChange(async (value) => {
|
||||
stream.name = value;
|
||||
await this.settingsManager.saveSettings();
|
||||
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
|
||||
eventBus.emit(EVENTS.STREAM_UPDATED, { stream: stream }, 'settings-management');
|
||||
}));
|
||||
|
||||
// Stream folder
|
||||
new Setting(container)
|
||||
.setName('Folder')
|
||||
.setDesc('Folder where daily notes will be created')
|
||||
.addText(text => text
|
||||
.setValue(stream.folder)
|
||||
.onChange(async (value) => {
|
||||
stream.folder = value;
|
||||
await this.settingsManager.saveSettings();
|
||||
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
|
||||
}));
|
||||
// File path template
|
||||
const dateFormatDesc = document.createDocumentFragment();
|
||||
dateFormatDesc.append(
|
||||
'Advanced path support, i.e.:',
|
||||
document.createElement('br'),
|
||||
document.createElement('br'),
|
||||
'/path/{YYYY-MM-DD}',
|
||||
document.createElement('br'),
|
||||
'/path/{YYYY}/{MM}/{DD}',
|
||||
document.createElement('br'),
|
||||
'/path/{YYYY}/deeper/{MM}/{DD}',
|
||||
document.createElement('br'),
|
||||
document.createElement('br')
|
||||
);
|
||||
|
||||
const previewSpan = document.createElement('strong');
|
||||
previewSpan.textContent = resolveStreamFilePath(stream, new Date());
|
||||
dateFormatDesc.append(
|
||||
'Preview: ',
|
||||
document.createElement('br'),
|
||||
previewSpan,
|
||||
document.createElement('br'),
|
||||
document.createElement('br'),
|
||||
document.createElement('i').appendChild(document.createTextNode('*YYYY, MM, and DD must all be present'))
|
||||
);
|
||||
|
||||
// Show today in ribbon
|
||||
new Setting(container)
|
||||
.setName('Show today button in ribbon')
|
||||
.setDesc('Show a today button for this stream in the ribbon')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(stream.showTodayInRibbon)
|
||||
.onChange(async (value) => {
|
||||
stream.showTodayInRibbon = value;
|
||||
await this.settingsManager.saveSettings();
|
||||
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
|
||||
}));
|
||||
.setClass('streams-setting-stacked')
|
||||
.setName('File path template')
|
||||
.setDesc(dateFormatDesc)
|
||||
.addText(text => {
|
||||
const settingInput = text.inputEl;
|
||||
text.setValue(stream.dateFormat || 'YYYY-MM-DD')
|
||||
.setPlaceholder('my/folder/{YYYY-MM-DD}')
|
||||
.onChange(async (value) => {
|
||||
const missingTokens = [];
|
||||
if (!value.includes('YYYY')) missingTokens.push('YYYY');
|
||||
if (!value.includes('MM')) missingTokens.push('MM');
|
||||
if (!value.includes('DD')) missingTokens.push('DD');
|
||||
|
||||
if (missingTokens.length > 0) {
|
||||
// Invalid format, visually indicate error
|
||||
settingInput.style.borderColor = 'var(--text-error)';
|
||||
const tokenString = missingTokens.join(', ');
|
||||
previewSpan.textContent = `Error: Missing required tokens (${tokenString})!`;
|
||||
previewSpan.style.color = 'var(--text-error)';
|
||||
return; // PREVENT SAVE
|
||||
}
|
||||
|
||||
// Valid format, clear errors
|
||||
settingInput.style.borderColor = '';
|
||||
previewSpan.style.color = '';
|
||||
|
||||
stream.dateFormat = value;
|
||||
previewSpan.textContent = resolveStreamFilePath(stream, new Date());
|
||||
|
||||
await this.settingsManager.saveSettings();
|
||||
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
|
||||
eventBus.emit(EVENTS.STREAM_UPDATED, { stream: stream }, 'settings-management');
|
||||
});
|
||||
});
|
||||
|
||||
// Icon
|
||||
const iconSetting = new Setting(container)
|
||||
.setName('Icon')
|
||||
.setDesc('Icon for the stream (used in ribbon and menus)')
|
||||
.addButton(button => {
|
||||
button
|
||||
.setIcon(stream.icon)
|
||||
.setTooltip('Select icon')
|
||||
.onClick(() => {
|
||||
const modal = new IconPickerModal(
|
||||
this.app,
|
||||
stream.icon,
|
||||
async (newIcon) => {
|
||||
stream.icon = newIcon;
|
||||
button.setIcon(newIcon);
|
||||
await this.settingsManager.saveSettings();
|
||||
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
|
||||
eventBus.emit(EVENTS.STREAM_UPDATED, { stream: stream }, 'settings-management');
|
||||
}
|
||||
);
|
||||
modal.open();
|
||||
});
|
||||
});
|
||||
|
||||
// Add command
|
||||
new Setting(container)
|
||||
|
|
@ -251,49 +378,45 @@ export class StreamsSettingTab extends PluginSettingTab {
|
|||
stream.addCommand = value;
|
||||
await this.settingsManager.saveSettings();
|
||||
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
|
||||
eventBus.emit(EVENTS.STREAM_UPDATED, { stream: stream }, 'settings-management');
|
||||
}));
|
||||
|
||||
// Show today in ribbon
|
||||
new Setting(container)
|
||||
.setName('Show in ribbon')
|
||||
.setDesc('Show a today button for this stream in the ribbon')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(stream.showTodayInRibbon)
|
||||
.onChange(async (value) => {
|
||||
stream.showTodayInRibbon = value;
|
||||
await this.settingsManager.saveSettings();
|
||||
// Use STREAM_UPDATED for granular updates (avoiding full ribbon rebuilds)
|
||||
eventBus.emit(EVENTS.STREAM_UPDATED, { stream: stream }, 'settings-management');
|
||||
}));
|
||||
|
||||
// Encrypt this stream
|
||||
this.addEncryptionToggle(container, stream);
|
||||
|
||||
// Disable stream
|
||||
const disableSetting = new Setting(container)
|
||||
.setName('Disable stream')
|
||||
.setDesc('When disabled, this stream will be hidden from selection lists and grayed out in settings')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(stream.disabled || false)
|
||||
.onChange(async (value) => {
|
||||
stream.disabled = value;
|
||||
await this.settingsManager.saveSettings();
|
||||
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
|
||||
|
||||
// Refresh the display to update visual styling
|
||||
this.display();
|
||||
|
||||
// Force immediate UI refresh for mobile devices
|
||||
// Use requestAnimationFrame to ensure DOM updates are processed
|
||||
requestAnimationFrame(() => {
|
||||
// Emit a specific event for stream dropdown refresh
|
||||
eventBus.emit(EVENTS.STREAM_UPDATED, { streamId: stream.id, disabled: value }, 'settings-management');
|
||||
});
|
||||
}));
|
||||
|
||||
// Add a class to identify the disable toggle for styling
|
||||
if (stream.disabled) {
|
||||
disableSetting.settingEl.addClass('streams-disable-toggle');
|
||||
}
|
||||
|
||||
// Remove stream
|
||||
new Setting(container)
|
||||
.addButton(button => button
|
||||
.setButtonText('Remove stream')
|
||||
.setWarning()
|
||||
.onClick(async () => {
|
||||
this.settingsManager.settings.streams.splice(index, 1);
|
||||
await this.settingsManager.saveSettings();
|
||||
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
|
||||
this.display();
|
||||
}));
|
||||
const removeContainer = container.createDiv('streams-remove-button-container');
|
||||
removeContainer.style.marginTop = '1em';
|
||||
removeContainer.style.marginBottom = '1em';
|
||||
|
||||
new ButtonComponent(removeContainer)
|
||||
.setButtonText('Remove stream')
|
||||
.setWarning()
|
||||
.onClick(async () => {
|
||||
// If the removed stream was the primary stream, clear it.
|
||||
if (this.settingsManager.settings.primaryStreamId === stream.id) {
|
||||
this.settingsManager.settings.primaryStreamId = null;
|
||||
new Notice('Primary stream was removed and has been cleared.');
|
||||
}
|
||||
|
||||
this.settingsManager.settings.streams.splice(index, 1);
|
||||
await this.settingsManager.saveSettings();
|
||||
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
|
||||
this.display();
|
||||
});
|
||||
}
|
||||
|
||||
private async moveStreamUp(index: number): Promise<void> {
|
||||
|
|
@ -335,10 +458,10 @@ export class StreamsSettingTab extends PluginSettingTab {
|
|||
const meldDetectionService = new MeldDetectionService();
|
||||
meldDetectionService.setPlugin(this.settingsManager as any);
|
||||
const isMeldAvailable = meldDetectionService.isMeldPluginAvailable();
|
||||
|
||||
|
||||
const encryptionSetting = new Setting(container)
|
||||
.setName('Encrypt this stream')
|
||||
.setDesc(isMeldAvailable
|
||||
.setDesc(isMeldAvailable
|
||||
? 'When enabled, files created in this stream will be encrypted using the Meld plugin'
|
||||
: 'Meld plugin is not available. Please install and enable the Meld plugin to use encryption features.'
|
||||
)
|
||||
|
|
@ -351,11 +474,11 @@ export class StreamsSettingTab extends PluginSettingTab {
|
|||
new Notice('Meld plugin is not available. Please install and enable the Meld plugin first.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
stream.encryptThisStream = value;
|
||||
await this.settingsManager.saveSettings();
|
||||
eventBus.emit(EVENTS.SETTINGS_CHANGED, this.settingsManager.settings, 'settings-management');
|
||||
|
||||
|
||||
new Notice(`Encryption ${value ? 'enabled' : 'disabled'} for stream "${stream.name}"`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export class StreamSelectionModal extends Modal {
|
|||
enabledStreams.forEach(stream => {
|
||||
new Setting(contentEl)
|
||||
.setName(stream.name)
|
||||
.setDesc(stream.folder)
|
||||
.setDesc(stream.dateFormat)
|
||||
.addButton(button => button
|
||||
.setButtonText('Select')
|
||||
.onClick(() => {
|
||||
|
|
|
|||
437
styles.css
437
styles.css
|
|
@ -1,3 +1,10 @@
|
|||
/*********************************************************
|
||||
* CSS VARIABLES - STREAMS BAR DIMENSIONS
|
||||
*********************************************************/
|
||||
:root {
|
||||
--streams-bar-widget-height: 28px;
|
||||
}
|
||||
|
||||
/*********************************************************
|
||||
* ANIMATIONS
|
||||
*********************************************************/
|
||||
|
|
@ -192,19 +199,38 @@
|
|||
position: relative;
|
||||
}
|
||||
|
||||
.streams-plugin-card-disabled::before {
|
||||
content: "DISABLED";
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-color: var(--text-error);
|
||||
color: var(--text-on-accent);
|
||||
.streams-status-toggle {
|
||||
margin-left: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
height: auto;
|
||||
line-height: 1.5;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.streams-status-toggle.is-disabled {
|
||||
background-color: var(--text-error);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.streams-status-toggle.is-disabled:hover {
|
||||
background-color: var(--text-error-hover);
|
||||
}
|
||||
|
||||
.streams-status-toggle.is-enabled {
|
||||
background-color: var(--interactive-success);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.streams-status-toggle.is-enabled:hover {
|
||||
background-color: var(--interactive-success-hover);
|
||||
}
|
||||
|
||||
.streams-plugin-card-disabled h3 {
|
||||
|
|
@ -219,10 +245,24 @@
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Allow the disable toggle itself to be clickable */
|
||||
.streams-plugin-card-disabled .streams-disable-toggle .setting-item-control {
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
.setting-item.streams-setting-stacked {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.setting-item.streams-setting-stacked .setting-item-info {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.setting-item.streams-setting-stacked .setting-item-control {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.setting-item.streams-setting-stacked .setting-item-control input[type="text"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/*********************************************************
|
||||
|
|
@ -231,7 +271,7 @@
|
|||
.streams-bar-component {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background-color: var(--background-primary);
|
||||
background-color: transparent;
|
||||
padding: 4px 6px 4px 6px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
|
|
@ -239,33 +279,120 @@
|
|||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: none;
|
||||
backdrop-filter: none;
|
||||
z-index: 1000;
|
||||
order: 99;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.streams-markdown-view-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.streams-markdown-view-content .streams-bar-component {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
}
|
||||
|
||||
.workspace-leaf-content:has(.streams-create-file-container) {
|
||||
.streams-create-file-leaf {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Prevent scrollbars on CreateFileView */
|
||||
.workspace-leaf-content:has(.streams-create-file-container) .view-content {
|
||||
.streams-create-file-leaf .view-content {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.streams-leaf-active .view-header {
|
||||
z-index: 10000;
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
min-height: var(--header-height);
|
||||
}
|
||||
|
||||
/* Ensure the native title container doesn't wrap to a new line, but shrinks and truncates as normal */
|
||||
.streams-leaf-active .view-header>.view-header-title-container {
|
||||
min-width: 0;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* HOLISTIC SCROLLING FIX (Desktop):
|
||||
* Instead of making the header relative (which breaks immersive scrolling) or zeroing out padding,
|
||||
* we let the header be native (absolute) so content scrolls under it.
|
||||
* Since our Streams bar makes the header taller, we explicitly increase the padding-top
|
||||
* on the markdown containers using an offset (40px).
|
||||
*/
|
||||
.mod-root .workspace-leaf-content.streams-leaf-active .view-content .markdown-reading-view>.markdown-preview-view,
|
||||
.mod-root .workspace-leaf-content.streams-leaf-active .view-content .markdown-source-view>.cm-editor>.cm-scroller {
|
||||
padding-top: calc(.5vh + var(--view-top-spacing-markdown, 0px)) !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* HOLISTIC SCROLLING FIX (Mobile):
|
||||
* Uses a slightly larger offset for touch targets (6vh).
|
||||
*/
|
||||
.is-phone .mod-root .workspace-leaf-content.streams-leaf-active .view-content .markdown-reading-view>.markdown-preview-view,
|
||||
.is-phone .mod-root .workspace-leaf-content.streams-leaf-active .view-content .markdown-source-view>.cm-editor>.cm-scroller {
|
||||
padding-top: calc(6vh + var(--view-top-spacing-markdown, 0px)) !important;
|
||||
}
|
||||
|
||||
.is-phone .streams-bar-component {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.streams-bar-component {
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.streams-bar-collapsed {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/*
|
||||
* PILL STYLE MENU
|
||||
* Wrap the two groups in pill-shaped containers to match Obsidian's native mobile menu buttons
|
||||
*/
|
||||
.streams-bar-nav-controls,
|
||||
.streams-bar-change-stream {
|
||||
background-color: var(--background-primary);
|
||||
border-radius: var(--radius-xl, 20px);
|
||||
padding: 4px 8px;
|
||||
box-shadow: var(--shadow-s, 0 2px 8px rgba(0, 0, 0, 0.05));
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Strip individual button borders/backgrounds to make them flow smoothly inside the pill */
|
||||
.streams-bar-day-nav,
|
||||
.streams-bar-today-button,
|
||||
.streams-bar-home-button,
|
||||
.streams-bar-settings-button {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
height: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.streams-bar-day-nav:hover,
|
||||
.streams-bar-today-button:hover,
|
||||
.streams-bar-home-button:hover,
|
||||
.streams-bar-settings-button:hover {
|
||||
background-color: transparent !important;
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
.streams-bar-change-stream:hover {
|
||||
background-color: var(--background-secondary) !important;
|
||||
color: var(--text-accent);
|
||||
border-color: var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.streams-bar-nav-controls {
|
||||
flex: 0 0 auto;
|
||||
/* Prevent stretching */
|
||||
}
|
||||
|
||||
.streams-bar-change-stream {
|
||||
margin-right: 0;
|
||||
/* Push all the way to the right */
|
||||
}
|
||||
|
||||
.streams-bar-component .streams-dropdown {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -274,12 +401,14 @@
|
|||
display: block;
|
||||
}
|
||||
|
||||
.streams-bar-dropdown-hidden {
|
||||
display: none !important;
|
||||
body .streams-bar-dropdown-hidden,
|
||||
.streams-bar-component .streams-bar-dropdown-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.streams-bar-dropdown-visible {
|
||||
display: block !important;
|
||||
body .streams-bar-dropdown-visible,
|
||||
.streams-bar-component .streams-bar-dropdown-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.streams-bar-component--visible {
|
||||
|
|
@ -327,12 +456,15 @@
|
|||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
margin: 0;
|
||||
background-color: transparent;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
background-color: var(--background-primary);
|
||||
border-radius: var(--radius-xl, 20px);
|
||||
padding: 4px 8px;
|
||||
box-shadow: var(--shadow-s, 0 2px 8px rgba(0, 0, 0, 0.05));
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
gap: 4px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.streams-bar-nav {
|
||||
|
|
@ -370,25 +502,25 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.2s ease;
|
||||
background-color: var(--interactive-normal);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.streams-bar-day-nav:hover {
|
||||
background-color: var(--interactive-hover);
|
||||
color: var(--text-normal);
|
||||
border-color: var(--text-accent);
|
||||
background-color: transparent;
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
.streams-bar-day-nav:active {
|
||||
background-color: var(--interactive-accent-hover);
|
||||
background-color: transparent;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
|
|
@ -401,17 +533,18 @@
|
|||
/* margin: 0 8px; */
|
||||
font-size: 15px;
|
||||
min-width: 60px;
|
||||
padding: 2px 2px;
|
||||
height: auto;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--interactive-normal);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
/* transition: all 0.2s ease; */
|
||||
}
|
||||
|
||||
.streams-bar-today-button:hover {
|
||||
background-color: var(--interactive-hover);
|
||||
border-color: var(--text-accent);
|
||||
background-color: transparent;
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
|
|
@ -422,7 +555,7 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
height: 28px;
|
||||
height: var(--streams-bar-widget-height);
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
|
@ -432,22 +565,25 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 4px;
|
||||
padding: 4px 16px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-xl, 20px);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
background-color: var(--interactive-normal);
|
||||
background-color: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
box-shadow: var(--shadow-s, 0 2px 8px rgba(0, 0, 0, 0.05));
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.streams-bar-change-stream:hover {
|
||||
background-color: var(--interactive-hover);
|
||||
color: var(--text-normal);
|
||||
border-color: var(--text-accent);
|
||||
background-color: var(--background-secondary);
|
||||
color: var(--text-accent);
|
||||
border-color: var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.streams-bar-change-stream-text {
|
||||
|
|
@ -463,17 +599,18 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
height: auto;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--interactive-normal);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.streams-bar-home-button:hover {
|
||||
background-color: var(--interactive-hover);
|
||||
color: var(--text-normal);
|
||||
border-color: var(--text-accent);
|
||||
background-color: transparent;
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
.streams-bar-settings-button {
|
||||
|
|
@ -483,17 +620,18 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
height: auto;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--interactive-normal);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.streams-bar-settings-button:hover {
|
||||
background-color: var(--interactive-hover);
|
||||
color: var(--text-normal);
|
||||
border-color: var(--text-accent);
|
||||
background-color: transparent;
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
/*********************************************************
|
||||
|
|
@ -514,9 +652,9 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
.streams-bar-streams-dropdown.streams-bar-dropdown-visible,
|
||||
.streams-bar-streams-dropdown.streams-dropdown--visible {
|
||||
display: block !important;
|
||||
.streams-bar-component .streams-bar-streams-dropdown.streams-bar-dropdown-visible,
|
||||
.streams-bar-component .streams-bar-streams-dropdown.streams-dropdown--visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.streams-bar-stream-item {
|
||||
|
|
@ -586,6 +724,33 @@
|
|||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
/* Primary stream indicator (subtle) */
|
||||
.streams-bar-stream-item-primary-indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
margin-left: 8px;
|
||||
background: var(--text-muted);
|
||||
opacity: 0.45;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.streams-bar-stream-item:hover .streams-bar-stream-item-primary-indicator {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.streams-bar-stream-item-selected .streams-bar-stream-item-primary-indicator {
|
||||
background: var(--text-on-accent);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Slightly larger in modern style so it still reads as a dot */
|
||||
.streams-bar-component.modern-style .streams-bar-stream-item-primary-indicator {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Encryption icon for main stream display */
|
||||
.streams-bar-encryption-icon {
|
||||
display: flex;
|
||||
|
|
@ -1012,10 +1177,21 @@
|
|||
|
||||
|
||||
.is-phone .streams-bar-component.modern-style {
|
||||
backdrop-filter: none;
|
||||
background: transparent;
|
||||
border-bottom: none;
|
||||
font-size: 11px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.is-phone .streams-bar-component.modern-style .streams-bar-collapsed {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.is-phone .streams-bar-component.modern-style .streams-bar-change-stream {
|
||||
margin-right: 25px;
|
||||
}
|
||||
|
||||
/*********************************************************
|
||||
* STREAMS BAR - NAVIGATION & HEADER (MODERN STYLE)
|
||||
*********************************************************/
|
||||
|
|
@ -1576,10 +1752,16 @@
|
|||
}
|
||||
|
||||
.is-phone .streams-bar-component.modern-style {
|
||||
position: absolute;
|
||||
top: 45px;
|
||||
backdrop-filter: none;
|
||||
background: transparent;
|
||||
border-bottom: none;
|
||||
font-size: 11px;
|
||||
padding: 4px 6px;
|
||||
margin: 0 2px;
|
||||
width: calc(100% - 4px);
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1631,16 +1813,18 @@
|
|||
position: relative;
|
||||
}
|
||||
|
||||
.streams-empty-state-hidden {
|
||||
display: none !important;
|
||||
body .streams-empty-state-hidden,
|
||||
div.streams-empty-state-hidden {
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.streams-date-setting-hidden {
|
||||
display: none !important;
|
||||
body .streams-date-setting-hidden,
|
||||
div.streams-date-setting-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.streams-install-meld-content {
|
||||
|
|
@ -1811,12 +1995,12 @@
|
|||
/*********************************************************
|
||||
* ENCRYPTED CREATE FILE VIEW STYLES
|
||||
*********************************************************/
|
||||
.workspace-leaf-content:has(.streams-create-file-encrypted-container) {
|
||||
.streams-create-file-encrypted-leaf {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Prevent scrollbars on EncryptedCreateFileView */
|
||||
.workspace-leaf-content:has(.streams-create-file-encrypted-container) .view-content {
|
||||
.streams-create-file-encrypted-leaf .view-content {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
@ -1979,4 +2163,109 @@
|
|||
.is-phone .streams-create-file-encrypted-button {
|
||||
padding: 10px 24px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
/*********************************************************
|
||||
* ICON PICKER MODAL
|
||||
*********************************************************/
|
||||
.streams-icon-picker-modal {
|
||||
max-width: 500px;
|
||||
width: 70vw;
|
||||
max-height: 70vh;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.streams-icon-picker-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.streams-icon-picker-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.streams-search-container input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.streams-icon-picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(32px, 1fr));
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
max-height: 50vh;
|
||||
background-color: var(--background-secondary);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.streams-icon-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background-color: var(--background-primary);
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.streams-icon-item:hover {
|
||||
background-color: var(--interactive-hover);
|
||||
border-color: var(--text-accent);
|
||||
}
|
||||
|
||||
.streams-icon-item.selected {
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.streams-icon-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.streams-icon-preview svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Tooltip on hover */
|
||||
.streams-icon-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.streams-icon-item:hover::after {
|
||||
content: attr(aria-label);
|
||||
position: absolute;
|
||||
bottom: -30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--background-modifier-message);
|
||||
color: var(--text-normal);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.streams-no-icons {
|
||||
grid-column: 1 / -1;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"1.0.0": "0.15.0"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue