diff --git a/README.md b/README.md
index f80291e..b7a65ff 100644
--- a/README.md
+++ b/README.md
@@ -1,92 +1,77 @@
# Obsidian Repeat Plugin
-**Review notes using spaced repetition from right within Obsidian.** Mark a note for repetition with one of the built-in commands, or by adding the `repeat` property to the note. Notes become "due" according to the schedule recorded in the `repeat` property, and show up in a dedicated Repeat view inside Obsidian.
+Review notes using spaced repetition from right within Obsidian.
-For example, a note marked with `repeat: every week` will be due every week on the day of its first review. A note marked with `repeat: spaced every month` will first be due in a month, then some time between half a month and two months in the future, depending on what you picked during its first review, and so on. The plugin automatically computes the due date and stores the result in a `due_at` property. That way, all the repetition information is easy to understand and edit.
+Mark any note for repetition with the `repeat` property. Notes become due according to their schedule and appear in a dedicated Repeat view. All repetition state is stored in the note's frontmatter, making it easy to understand and edit.
-A few other goodies are available:
+
-* You can choose to make the note's body obscured during reviews (`hidden: true`) to make one-sided flashcards.
-* Notes due in more than a week become due in the morning (you can configure the time in the plugin's settings), so that you can have a predictable daily review time.
-* Notes are non-repeating by default, but you can also explicitly mark them with `repeat: never` to disable reviews (useful for programmatic or bulk marking).
+## Quick Start
-## Mark a note for repetition
+1. Install [Dataview](https://github.com/blacksmithgu/obsidian-dataview) and Repeat Plugin from Community Plugins
+2. Add a `repeat` property to any note (e.g., `repeat: every week`)
+3. Open the Repeat view via the clock icon or `Repeat: Review due notes` command
-You mark a note for repetition by specifying the `repeat` property. The `due_at` property is automatically added after your first review. The `hidden` property is optional, and will obscure the body of your note during reviews (clicking on the note's body will reveal its contents).
+## Marking Notes for Repetition
-
+Add a `repeat` property to any note. You can also use the **`Repeat: Repeat this note...`** command:
-A note can have a periodic or spaced repetition schedule. Notes using **periodic repetition** become due after a fixed amount of time has passed since the last time they were due. Some valid `repeat` values for periodic repetition are of the form:
+
-* `daily`, `weekly`, `monthly`, `yearly`
-* `every day`, `every week`, `every month`, `every year`
-* `every K days`, `every K weeks`, `every K months`, `every K years` (where `K` is a number)
-* `every tuesday`, `every monday, friday`, `every tue, wed, thu`, `every sat and sun` (specific days of the week)
+### Periodic Repetition
-Notes using **spaced repetition** become due after a period that you choose during each review. To use spaced repetition, just add `spaced ` to the start of a note's `repeat` value. So:
+Notes become due after a fixed interval:
-* `spaced daily`, `spaced weekly`, `spaced monthly`, `spaced yearly`
-* `spaced every day`, `spaced every week`, `spaced every month`, `spaced every year`
-* `spaced every K days`, `spaced every K weeks`, `spaced every K months`, `spaced every K years` (where `K` is a number)
+- `daily`, `weekly`, `monthly`, `yearly`
+- `every day`, `every week`, `every month`, `every year`
+- `every 3 days`, `every 2 weeks`, etc.
+- `every monday`, `every tue, thu`, `every sat and sun`
-You don't have to edit these values directly: you can also use the **`Repeat: Repeat this note...` command** to mark a note for repetition using a modal.
+### Spaced Repetition
-
+Add `spaced` to choose when to review next during each review:
-Notes become due in the morning. You can add ` in the evening` to the end of the repeat value if you want a note to be due in the evening instead, e.g., `repeat: every day in the evening`. By default "morning" means 6 AM and "evening" means 6 PM, but you can change these times and the default repetition interval in the plugin's settings.
+- `spaced daily`, `spaced weekly`, `spaced monthly`
+- `spaced every 3 days`, `spaced every 2 weeks`, etc.
-Notes *without* the `repeat` property simply don't repeat. You can also explicitly mark a note as non-repeating with `repeat: never` (or `off`, `no`, or `false`), which can be useful if you periodically mark recent notes for repetition.
+### Other Options
-## Review notes as they become due
+- **Hidden content**: Add `hidden: true` to obscure the note body during reviews (click to reveal)
+- **Evening reviews**: Add `in the evening` to the repeat value (e.g., `every day in the evening`)
+- **Disable repetition**: Use `repeat: never` (or `off`, `no`, `false`)
-To review notes, open the Repeat view in Obsidian by clicking the "clock" icon, or using the `Repeat: Review due notes` command. In this example, the `repeat` value is `spaced every 2 days`.
+## Reviewing Notes
-
+Open the Repeat view to see due notes. Click a button to schedule the next review:
-Clicking one of the buttons above the embedded note will update the note's `due_at` and (possibly) `repeat` property values.
+
-### Extreme detail about how due dates are calculated that you don't really need to know
+### Filtering
-Notes using periodic repetition will be due one period *in the future* relative to their previous due date. For example, if it's July 2024 and a note has `repeat: every year` and `due_at: 1999-01-01`, then after review the note will have `due_at: 2025-01-01T06:00:00`. That way, your periodic due dates remain equally spaced even if you stop reviewing your notes for a while.
+Click the note count to expand filter options. Filter by tags or any Dataview source expression:
-Notes using spaced repetition become due in whatever time you click relative to the current time. So, if you clicked `3 days (x1.5)` in the example above, that note would be due in 3 days. At the same time, the `repeat` value would be updated from `spaced every 2 days` to `spaced every 3 days`.
+
-Clicking the `5 minutes (skip)` button always just bumps the `due_at` value by 5 minutes (without changing the `repeat` value) so that ignoring a note for the moment doesn't change it's repetition dynamics.
+- Click tag shortcuts to filter (e.g., `#math`)
+- Use expressions like `#math OR #physics`
+- Save frequently-used filters for quick access
-The spaced period multiples `(x0.5), (x1), (x1.5), (x2)` never change. Some other spaced repetition apps like in Anki or SuperMemo 2 calculate the repetition multiples dynamically. This gives you more fine-tuned repetition intervals, in some sense, at the expense of a lot more state per note. One of the design goals of Obsidian Repeat Plugin is to store all repetition state in the note's frontmatter properties, and make all of it easily understandable and editable. I think the tradeoff is good: using a fixed exponential-ish scheduling strategy still allows for a lot of flexibility for when you review, while also meeting this design goal.
+## How Scheduling Works
-All you really need to make a note repeating is add a valid `repeat` value. If there is no `due_at` value, the plugin will still pick up the note for review and calculate the `due_at` value after the review. So you can quickly mark notes for review using this property without worrying about technical details
+- **Periodic**: Due dates stay evenly spaced. If you miss reviews, the note catches up to the next occurrence.
+- **Spaced**: You pick a multiplier (x0.5, x1, x1.5, x2) to adjust the interval each review.
+- **Skip**: The `5 minutes (skip)` button bumps the due time without changing the schedule.
+- The `due_at` property is calculated automatically after each review.
-The `hidden` property enables you to use one-sided flash cards. A note marked as `hidden: yes` (or `on`, or `true`) will have its contents obscured until you click in in a review.
+## Installation
-
+Install from **Settings > Community plugins > Browse**:
-`repeat` values have to be in a supported form, but still use relatively natural language. This way, it's easy to mark a note for repetition using relatively natural language (`repeat: every 3 days`) instead of dealing with some computer-friendly syntax. With that said, the plugin will always save `due_at` values using `hours` as the repetition unit. For this reason, shortest repetition interval is one hour.
-
-Finally, the plugin changes the frontmatter properties it uses in-place, so your other properties are never changed.
-
-### Custom Repeat view styling
-
-The plugin uses Obsidian's native embedding styles to display notes during reviews. So, you can customize those styles and the customizations will be reflected in the plugin. The plugin wraps the embeds in a `
` element, so that you can customize *only* the plugin's styles.
-
-One useful customization (shown in the screenshots throughout) is making embedded note's titles **bolder**. To do that, create a new CSS snippet (via Settings > Appearance > CSS snippets) containing
-
-```css
-.repeat-embedded_note .embed-title {
- font-weight: 600 !important;
-}
-```
-
-## How to install Obsidian Repeat Plugin
-
-Go to **Obsidian's community plugins list** (via Settings > Options > Community plugins > Browse) and install:
-
-1. Dataview Plugin (used for querying notes)
+1. Dataview Plugin (required)
2. Repeat Plugin
-## Additional resources
+## Resources
-* **Development**:
- - Pull requests are welcome. If you have a significant change in mind, create an issue to discuss it with the community first. You may also find some issues with the "help wanted" tag in the Issues tab of [the plugin's GitHub repo](https://github.com/prncc/obsidian-repeat-plugin).
- - To start a local dev build, run `yarn install` and then `yarn dev`.
-* **More Tools**: The `add_repeat_to_notes.py` script from [https://github.com/prncc/obsidian-scripts](https://github.com/prncc/obsidian-scripts) allows you to interactively add the `repeat` field to notes in your existing vault. The Repeat Plugin will be able to pick up those notes from there.
+- [GitHub Issues](https://github.com/prncc/obsidian-repeat-plugin) — Report bugs or request features
+- [obsidian-scripts](https://github.com/prncc/obsidian-scripts) — Bulk add `repeat` to existing notes
+- Development: `yarn install && yarn dev`
diff --git a/images/edit-modal.png b/images/edit-modal.png
deleted file mode 100644
index 8eb040b..0000000
Binary files a/images/edit-modal.png and /dev/null differ
diff --git a/images/edit-text.png b/images/edit-text.png
deleted file mode 100644
index 983c034..0000000
Binary files a/images/edit-text.png and /dev/null differ
diff --git a/images/export-note-for-repetition.webp b/images/export-note-for-repetition.webp
new file mode 100644
index 0000000..5a2f8ed
Binary files /dev/null and b/images/export-note-for-repetition.webp differ
diff --git a/images/repeating-note.webp b/images/repeating-note.webp
new file mode 100644
index 0000000..f646a18
Binary files /dev/null and b/images/repeating-note.webp differ
diff --git a/images/review-hidden.png b/images/review-hidden.png
deleted file mode 100644
index 45d6e59..0000000
Binary files a/images/review-hidden.png and /dev/null differ
diff --git a/images/review-revealed.png b/images/review-revealed.png
deleted file mode 100644
index b1bc183..0000000
Binary files a/images/review-revealed.png and /dev/null differ
diff --git a/images/review-ui-closed-filter.webp b/images/review-ui-closed-filter.webp
new file mode 100644
index 0000000..97ab7b5
Binary files /dev/null and b/images/review-ui-closed-filter.webp differ
diff --git a/images/review-ui-open-filter.webp b/images/review-ui-open-filter.webp
new file mode 100644
index 0000000..ae42e54
Binary files /dev/null and b/images/review-ui-open-filter.webp differ
diff --git a/package.json b/package.json
index 868e22b..917a53a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "obsidian-repeat-plugin",
- "version": "1.10.0",
+ "version": "2.0.0",
"description": "An Obsidian plugin to review notes using periodic or spaced repetition.",
"main": "./src/main.ts",
"scripts": {
diff --git a/src/main.ts b/src/main.ts
index f5383cb..d15b284 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -60,6 +60,7 @@ export default class RepeatPlugin extends Plugin {
this.statusBarItem = undefined;
}
if (this.settings.showDueCountInStatusBar) {
+ this.makeStatusBarItem();
this.updateNotesDueCount();
}
if (!this.settings.showRibbonIcon && this.ribbonIcon) {
@@ -71,18 +72,26 @@ export default class RepeatPlugin extends Plugin {
}
}
+ makeStatusBarItem() {
+ if (this.settings.showDueCountInStatusBar && !this.statusBarItem) {
+ this.statusBarItem = this.addStatusBarItem();
+ this.statusBarItem.addClass('mod-clickable');
+ this.statusBarItem.setText('Repeat');
+ this.statusBarItem.addEventListener('click', () => {
+ this.activateRepeatNotesDueView();
+ });
+ }
+ }
+
updateNotesDueCount() {
- if (this.settings.showDueCountInStatusBar) {
- if (!this.statusBarItem) {
- this.statusBarItem = this.addStatusBarItem();
- }
+ if (this.settings.showDueCountInStatusBar && this.statusBarItem) {
const dueNoteCount = getNotesDue(
getAPI(this.app),
this.settings.ignoreFolderPath,
undefined,
this.settings.enqueueNonRepeatingNotes,
this.settings.defaultRepeat)?.length;
- if (dueNoteCount != undefined && this.statusBarItem) {
+ if (dueNoteCount != undefined) {
this.statusBarItem.setText(
`${dueNoteCount} repeat notes due`);
}
@@ -90,25 +99,37 @@ export default class RepeatPlugin extends Plugin {
}
manageStatusBarItem() {
- // Update due note count when the DataView index populates.
- this.registerEvent(
- this.app.metadataCache.on(
- // @ts-ignore: event is added by DataView.
- 'dataview:index-ready',
- () => {
- this.updateNotesDueCount();
- // Update due note count whenever metadata changes.
- setTimeout(() => {
- this.registerEvent(
- this.app.metadataCache.on(
- // @ts-ignore: event is added by DataView.
- 'dataview:metadata-change',
- this.updateNotesDueCount
- )
- );
- }, COUNT_DEBOUNCE_MS);
- })
- );
+ // Create status bar item immediately so it's visible right away.
+ this.makeStatusBarItem();
+
+ const dv = getAPI(this.app);
+ const onIndexReady = () => {
+ this.updateNotesDueCount();
+ // Update due note count whenever metadata changes.
+ setTimeout(() => {
+ this.registerEvent(
+ this.app.metadataCache.on(
+ // @ts-ignore: event is added by DataView.
+ 'dataview:metadata-change',
+ this.updateNotesDueCount
+ )
+ );
+ }, COUNT_DEBOUNCE_MS);
+ };
+
+ // If Dataview index is already ready, update immediately.
+ // Otherwise, wait for the index-ready event.
+ if (dv?.index.initialized) {
+ onIndexReady();
+ } else {
+ this.registerEvent(
+ this.app.metadataCache.on(
+ // @ts-ignore: event is added by DataView.
+ 'dataview:index-ready',
+ onIndexReady)
+ );
+ }
+
// Periodically update due note count as notes become due.
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
this.registerInterval(
@@ -258,7 +279,7 @@ export default class RepeatPlugin extends Plugin {
this.registerCommands();
this.registerView(
REPEATING_NOTES_DUE_VIEW,
- (leaf) => new RepeatView(leaf, this.settings),
+ (leaf) => new RepeatView(leaf, this.settings, this.saveSettings.bind(this)),
);
this.addSettingTab(new RepeatPluginSettingTab(this.app, this));
}
diff --git a/src/repeat/obsidian/RepeatView.tsx b/src/repeat/obsidian/RepeatView.tsx
index 220a4ac..a5d38b0 100644
--- a/src/repeat/obsidian/RepeatView.tsx
+++ b/src/repeat/obsidian/RepeatView.tsx
@@ -2,6 +2,7 @@ import {
Component,
debounce,
ItemView,
+ setIcon,
WorkspaceLeaf,
TFile,
} from 'obsidian';
@@ -10,12 +11,14 @@ import { getAPI, DataviewApi } from 'obsidian-dataview';
import { determineFrontmatterBounds, updateRepetitionMetadata } from '../../frontmatter';
import { getRepeatChoices } from '../choices';
import { RepeatChoice } from '../repeatTypes';
-import { getNextDueNote } from '../queries';
+import { getNextDueNote, getNotesDue, getTagsFromDueNotes, TagStats } from '../queries';
import { serializeRepetition } from '../serializers';
import { renderMarkdown, renderTitleElement } from '../../markdown';
import { RepeatPluginSettings } from '../../settings';
+import TextInputModal from './TextInputModal';
const MODIFY_DEBOUNCE_MS = 1 * 1000;
+const QUERY_DEBOUNCE_MS = 500;
export const REPEATING_NOTES_DUE_VIEW = 'repeating-notes-due-view';
class RepeatView extends ItemView {
@@ -30,7 +33,23 @@ class RepeatView extends ItemView {
root: Element;
settings: RepeatPluginSettings;
- constructor(leaf: WorkspaceLeaf, settings: RepeatPluginSettings) {
+ // Filter UI elements
+ filterContainer: HTMLElement;
+ filterHeader: HTMLElement;
+ filterContent: HTMLElement;
+ filterToggleIcon: HTMLElement;
+ queryInput: HTMLInputElement;
+ tagShortcutsContainer: HTMLElement;
+ savedFilterDropdown: HTMLSelectElement;
+ filterCountEl: HTMLElement;
+ filterErrorEl: HTMLElement;
+ availableTags: TagStats[];
+ displayedTagCount: number;
+ saveSettings: () => Promise;
+ handleQueryChange: ReturnType;
+ filterExpanded: boolean;
+
+ constructor(leaf: WorkspaceLeaf, settings: RepeatPluginSettings, saveSettings: () => Promise) {
super(leaf);
this.addRepeatButton = this.addRepeatButton.bind(this);
this.disableExternalHandlers = this.disableExternalHandlers.bind(this);
@@ -47,10 +66,27 @@ class RepeatView extends ItemView {
this.setPage = this.setPage.bind(this);
this.resetView = this.resetView.bind(this);
+ // Filter-related bindings
+ this.createFilterUI = this.createFilterUI.bind(this);
+ this.refreshFilterUI = this.refreshFilterUI.bind(this);
+ this.doHandleQueryChange = this.doHandleQueryChange.bind(this);
+ this.handleQueryChange = debounce(this.doHandleQueryChange, QUERY_DEBOUNCE_MS);
+ this.handleTagClick = this.handleTagClick.bind(this);
+ this.handleClearQuery = this.handleClearQuery.bind(this);
+ this.handleSaveFilter = this.handleSaveFilter.bind(this);
+ this.handleLoadSavedFilter = this.handleLoadSavedFilter.bind(this);
+ this.handleDeleteSavedFilter = this.handleDeleteSavedFilter.bind(this);
+ this.handleShowMoreTags = this.handleShowMoreTags.bind(this);
+ this.toggleFilterDrawer = this.toggleFilterDrawer.bind(this);
+ this.filterExpanded = false;
+
this.component = new Component();
this.dv = getAPI(this.app);
this.settings = settings;
+ this.saveSettings = saveSettings;
+ this.availableTags = [];
+ this.displayedTagCount = 6;
this.root = this.containerEl.children[1];
this.indexPromise = new Promise((resolve, reject) => {
@@ -154,14 +190,32 @@ class RepeatView extends ItemView {
// Reset the message container so that loading message is hidden.
this.setMessage('');
this.messageContainer.style.display = 'none';
+
+ // Refresh the filter UI with current tags
+ this.refreshFilterUI();
+
const page = getNextDueNote(
this.dv,
this.settings.ignoreFolderPath,
ignoreFilePath,
this.settings.enqueueNonRepeatingNotes,
- this.settings.defaultRepeat);
+ this.settings.defaultRepeat,
+ this.settings.filterQuery || undefined);
if (!page) {
- this.setMessage('All done for now!');
+ // Check if there are notes due but filtered out
+ const totalDue = getNotesDue(
+ this.dv,
+ this.settings.ignoreFolderPath,
+ ignoreFilePath,
+ this.settings.enqueueNonRepeatingNotes,
+ this.settings.defaultRepeat
+ )?.length || 0;
+
+ if (totalDue > 0 && this.settings.filterQuery) {
+ this.setMessage(`No notes matching filter. ${totalDue} other notes are due.`);
+ } else {
+ this.setMessage('All done for now!');
+ }
this.buttonsContainer.createEl('button', {
text: 'Refresh',
},
@@ -230,16 +284,344 @@ class RepeatView extends ItemView {
resetView() {
this.messageContainer && this.messageContainer.remove();
+ this.filterContainer && this.filterContainer.remove();
this.buttonsContainer && this.buttonsContainer.remove();
this.previewContainer && this.previewContainer.remove();
this.messageContainer = this.root.createEl('div', { cls: 'repeat-message' });
// Hide until there's a message to manage spacing.
this.messageContainer.style.display = 'none';
+ this.createFilterUI();
this.buttonsContainer = this.root.createEl('div', { cls: 'repeat-buttons' });
this.previewContainer = this.root.createEl('div', { cls: 'repeat-embedded_note' });
this.currentDueFilePath = undefined;
}
+ createFilterUI() {
+ this.filterContainer = this.root.createEl('div', { cls: 'repeat-filter' });
+
+ // Drawer header (always visible)
+ this.filterHeader = this.filterContainer.createEl('div', { cls: 'repeat-filter-header' });
+ this.filterHeader.addEventListener('click', this.toggleFilterDrawer);
+
+ this.filterToggleIcon = this.filterHeader.createEl('span', {
+ cls: 'repeat-filter-toggle-icon',
+ });
+ setIcon(this.filterToggleIcon, 'chevron-right');
+
+ // Filter count display (in header, always visible)
+ this.filterCountEl = this.filterHeader.createEl('span', {
+ cls: 'repeat-filter-count'
+ });
+
+ // Collapsible content
+ this.filterContent = this.filterContainer.createEl('div', {
+ cls: 'repeat-filter-content'
+ });
+ this.filterContent.style.display = 'none';
+
+ // Row 1: Query input + Clear button
+ const queryRow = this.filterContent.createEl('div', { cls: 'repeat-filter-row' });
+
+ this.queryInput = queryRow.createEl('input', {
+ cls: 'repeat-filter-query-input',
+ attr: {
+ type: 'text',
+ placeholder: 'Filter: #tag or Dataview expression...',
+ value: this.settings.filterQuery || '',
+ }
+ });
+ this.queryInput.value = this.settings.filterQuery || '';
+ this.queryInput.addEventListener('input', () => this.handleQueryChange());
+ this.queryInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ this.handleQueryChange();
+ this.handleQueryChange.cancel?.();
+ }
+ });
+
+ const clearBtn = queryRow.createEl('button', {
+ cls: 'repeat-filter-btn',
+ text: 'Clear',
+ });
+ clearBtn.addEventListener('click', this.handleClearQuery);
+
+ // Row 2: Tag shortcuts
+ this.tagShortcutsContainer = this.filterContent.createEl('div', {
+ cls: 'repeat-filter-tags'
+ });
+
+ // Row 3: Saved filters dropdown + Save/Delete buttons
+ const savedFilterRow = this.filterContent.createEl('div', { cls: 'repeat-filter-row' });
+
+ this.savedFilterDropdown = savedFilterRow.createEl('select', {
+ cls: 'repeat-filter-dropdown'
+ });
+ this.savedFilterDropdown.addEventListener('change', this.handleLoadSavedFilter);
+
+ const saveBtn = savedFilterRow.createEl('button', {
+ cls: 'repeat-filter-btn',
+ text: 'Save',
+ });
+ saveBtn.addEventListener('click', this.handleSaveFilter);
+
+ const deleteBtn = savedFilterRow.createEl('button', {
+ cls: 'repeat-filter-btn repeat-filter-btn-danger',
+ text: 'Delete',
+ });
+ deleteBtn.addEventListener('click', this.handleDeleteSavedFilter);
+
+ // Error display (hidden by default)
+ this.filterErrorEl = this.filterContent.createEl('div', {
+ cls: 'repeat-filter-error'
+ });
+ this.filterErrorEl.style.display = 'none';
+ }
+
+ toggleFilterDrawer() {
+ this.filterExpanded = !this.filterExpanded;
+ this.filterContent.style.display = this.filterExpanded ? 'block' : 'none';
+ setIcon(this.filterToggleIcon, this.filterExpanded ? 'chevron-down' : 'chevron-right');
+ this.filterContainer.toggleClass('repeat-filter-expanded', this.filterExpanded);
+ }
+
+ renderTagShortcuts() {
+ this.tagShortcutsContainer.empty();
+ const tagsToShow = this.availableTags.slice(0, this.displayedTagCount);
+ const hiddenCount = this.availableTags.length - this.displayedTagCount;
+
+ tagsToShow.forEach(({ tag, count }) => {
+ const tagBtn = this.tagShortcutsContainer.createEl('button', {
+ cls: 'repeat-filter-tag',
+ text: `${tag} (${count})`,
+ });
+ tagBtn.addEventListener('click', () => this.handleTagClick(tag));
+ });
+
+ if (hiddenCount > 0) {
+ const moreLink = this.tagShortcutsContainer.createEl('button', {
+ cls: 'repeat-filter-tag-more',
+ text: `+${hiddenCount} more`,
+ });
+ moreLink.addEventListener('click', this.handleShowMoreTags);
+ }
+ }
+
+ handleShowMoreTags() {
+ this.displayedTagCount += 6;
+ this.renderTagShortcuts();
+ }
+
+ refreshFilterUI() {
+ // Get all tags from due notes (without filtering)
+ this.availableTags = getTagsFromDueNotes(
+ this.dv,
+ this.settings.ignoreFolderPath,
+ undefined,
+ this.settings.enqueueNonRepeatingNotes,
+ this.settings.defaultRepeat
+ ) || [];
+
+ // Reset displayed count when refreshing (e.g., after reviewing a note)
+ this.displayedTagCount = 6;
+
+ this.renderTagShortcuts();
+
+ // Update saved filters dropdown
+ this.savedFilterDropdown.empty();
+ const defaultOption = this.savedFilterDropdown.createEl('option', {
+ text: 'Load saved filter...',
+ attr: { value: '' }
+ });
+ defaultOption.disabled = true;
+
+ // Find if current query matches a saved filter
+ const matchingFilterIndex = this.settings.savedFilters.findIndex(
+ f => f.query === this.settings.filterQuery
+ );
+
+ // Select the placeholder only if no filter matches
+ if (matchingFilterIndex === -1) {
+ defaultOption.selected = true;
+ }
+
+ this.settings.savedFilters.forEach((filter, index) => {
+ const option = this.savedFilterDropdown.createEl('option', {
+ text: filter.name,
+ attr: { value: index.toString() }
+ });
+ if (index === matchingFilterIndex) {
+ option.selected = true;
+ }
+ });
+
+ // Update filter count
+ this.updateFilterCount();
+ }
+
+ updateFilterCount() {
+ const filterQuery = this.settings.filterQuery;
+
+ // Get total due notes (unfiltered)
+ const totalCount = getNotesDue(
+ this.dv,
+ this.settings.ignoreFolderPath,
+ undefined,
+ this.settings.enqueueNonRepeatingNotes,
+ this.settings.defaultRepeat
+ )?.length || 0;
+
+ if (filterQuery) {
+ // Get filtered count
+ try {
+ const filteredCount = getNotesDue(
+ this.dv,
+ this.settings.ignoreFolderPath,
+ undefined,
+ this.settings.enqueueNonRepeatingNotes,
+ this.settings.defaultRepeat,
+ filterQuery
+ )?.length || 0;
+
+ // Check if this matches a named filter
+ const matchingFilter = this.settings.savedFilters.find(
+ f => f.query === filterQuery
+ );
+
+ if (matchingFilter) {
+ this.filterCountEl.textContent = `${matchingFilter.name}: ${filteredCount} matching, ${totalCount} total`;
+ } else {
+ this.filterCountEl.textContent = `${filteredCount} matching, ${totalCount} total`;
+ }
+ this.filterErrorEl.style.display = 'none';
+ } catch (e) {
+ this.filterCountEl.textContent = `${totalCount} notes due`;
+ this.filterErrorEl.textContent = `Invalid filter: ${e.message || 'Check your query syntax'}`;
+ this.filterErrorEl.style.display = 'block';
+ }
+ } else {
+ this.filterCountEl.textContent = `${totalCount} notes due`;
+ this.filterErrorEl.style.display = 'none';
+ }
+ }
+
+ doHandleQueryChange() {
+ const newQuery = this.queryInput.value.trim();
+ if (newQuery !== this.settings.filterQuery) {
+ this.settings.filterQuery = newQuery;
+ this.saveSettings();
+ this.updateFilterCount();
+ // Re-render the current page with new filter
+ this.buttonsContainer.empty();
+ this.previewContainer.empty();
+ this.previewContainer.removeClass('markdown-embed');
+ this.setPage();
+ }
+ }
+
+ handleTagClick(tag: string) {
+ const currentQuery = this.queryInput.value.trim();
+ if (currentQuery) {
+ // Append with OR
+ this.queryInput.value = `${currentQuery} OR ${tag}`;
+ } else {
+ this.queryInput.value = tag;
+ }
+ this.handleQueryChange();
+ }
+
+ async handleClearQuery() {
+ this.queryInput.value = '';
+ this.settings.filterQuery = '';
+ await this.saveSettings();
+ this.updateFilterCount();
+ // Re-render
+ this.buttonsContainer.empty();
+ this.previewContainer.empty();
+ this.previewContainer.removeClass('markdown-embed');
+ this.setPage();
+ }
+
+ async handleSaveFilter() {
+ const currentQuery = this.settings.filterQuery;
+ if (!currentQuery) {
+ return; // Nothing to save
+ }
+
+ const modal = new TextInputModal(
+ this.app,
+ 'Save Filter',
+ 'Filter name',
+ '',
+ async (name) => {
+ if (!name) return;
+
+ // Check for duplicate names and update or add
+ const existingIndex = this.settings.savedFilters.findIndex(f => f.name === name);
+ if (existingIndex >= 0) {
+ this.settings.savedFilters[existingIndex].query = currentQuery;
+ } else {
+ this.settings.savedFilters.push({ name, query: currentQuery });
+ }
+
+ await this.saveSettings();
+ this.refreshFilterUI();
+ }
+ );
+ modal.open();
+ }
+
+ async handleLoadSavedFilter(event: Event) {
+ const select = event.target as HTMLSelectElement;
+ const filterIndex = parseInt(select.value);
+
+ if (isNaN(filterIndex)) return;
+
+ const filter = this.settings.savedFilters[filterIndex];
+ if (filter) {
+ this.queryInput.value = filter.query;
+ this.settings.filterQuery = filter.query;
+ await this.saveSettings();
+ this.updateFilterCount();
+ // Re-render
+ this.buttonsContainer.empty();
+ this.previewContainer.empty();
+ this.previewContainer.removeClass('markdown-embed');
+ this.setPage();
+ }
+ }
+
+ async handleDeleteSavedFilter() {
+ const select = this.savedFilterDropdown;
+ const filterIndex = parseInt(select.value);
+
+ if (isNaN(filterIndex)) return;
+
+ const filter = this.settings.savedFilters[filterIndex];
+ if (filter) {
+ // Clear query if it matches the deleted filter
+ const shouldClearQuery = this.settings.filterQuery === filter.query;
+
+ this.settings.savedFilters.splice(filterIndex, 1);
+
+ if (shouldClearQuery) {
+ this.settings.filterQuery = '';
+ this.queryInput.value = '';
+ }
+
+ await this.saveSettings();
+
+ if (shouldClearQuery) {
+ // Re-render with cleared filter
+ this.buttonsContainer.empty();
+ this.previewContainer.empty();
+ this.previewContainer.removeClass('markdown-embed');
+ this.setPage();
+ } else {
+ this.refreshFilterUI();
+ }
+ }
+ }
+
setMessage(message: string) {
this.messageContainer.style.display = 'block';
this.messageContainer.setText(message);
diff --git a/src/repeat/obsidian/TextInputModal.ts b/src/repeat/obsidian/TextInputModal.ts
new file mode 100644
index 0000000..294dc1c
--- /dev/null
+++ b/src/repeat/obsidian/TextInputModal.ts
@@ -0,0 +1,68 @@
+import { App, Modal, Setting } from 'obsidian';
+
+class TextInputModal extends Modal {
+ title: string;
+ prompt: string;
+ defaultValue: string;
+ onSubmit: (result: string | null) => void;
+ inputEl: HTMLInputElement | undefined;
+
+ constructor(
+ app: App,
+ title: string,
+ prompt: string,
+ defaultValue: string,
+ onSubmit: (result: string | null) => void
+ ) {
+ super(app);
+ this.title = title;
+ this.prompt = prompt;
+ this.defaultValue = defaultValue;
+ this.onSubmit = onSubmit;
+ }
+
+ onOpen() {
+ const { contentEl } = this;
+ contentEl.empty();
+ contentEl.createEl('h2', { text: this.title });
+
+ new Setting(contentEl)
+ .setName(this.prompt)
+ .addText((text) => {
+ this.inputEl = text.inputEl;
+ text.setValue(this.defaultValue);
+ text.inputEl.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ this.submit();
+ }
+ });
+ });
+
+ new Setting(contentEl)
+ .addButton((btn) =>
+ btn
+ .setButtonText('Cancel')
+ .onClick(() => {
+ this.close();
+ this.onSubmit(null);
+ }))
+ .addButton((btn) =>
+ btn
+ .setButtonText('Save')
+ .setCta()
+ .onClick(() => this.submit()));
+ }
+
+ submit() {
+ const value = this.inputEl?.value.trim();
+ this.close();
+ this.onSubmit(value || null);
+ }
+
+ onClose() {
+ const { contentEl } = this;
+ contentEl.empty();
+ }
+}
+
+export default TextInputModal;
diff --git a/src/repeat/queries.ts b/src/repeat/queries.ts
index 964494f..9527631 100644
--- a/src/repeat/queries.ts
+++ b/src/repeat/queries.ts
@@ -3,15 +3,22 @@ import { Literal, DataviewApi, DataArray } from 'obsidian-dataview';
import { isRepeatDisabled, formRepetition, parseRepetitionFields } from './parsers';
+export interface TagStats {
+ tag: string;
+ count: number;
+}
+
export function getNotesDue(
dv: DataviewApi | undefined,
ignoreFolderPath: string,
ignoreFilePath?: string | undefined,
enqueueNonRepeatingNotes?: boolean,
defaultRepeat?: any,
+ filterQuery?: string,
): DataArray> | undefined {
const now = DateTime.now();
- return dv?.pages()
+ // If filterQuery provided, pass it to dv.pages() as a FROM expression
+ return dv?.pages(filterQuery || undefined)
.mutate((page: any) => {
const { repeat, due_at, hidden } = page.file.frontmatter || {};
if (isRepeatDisabled(repeat)) {
@@ -67,8 +74,47 @@ export function getNextDueNote(
ignoreFilePath?: string | undefined,
enqueueNonRepeatingNotes?: boolean,
defaultRepeat?: any,
+ filterQuery?: string,
): Record | undefined {
- const page = getNotesDue(dv, ignoreFolderPath, ignoreFilePath, enqueueNonRepeatingNotes, defaultRepeat)?.first();
+ const page = getNotesDue(dv, ignoreFolderPath, ignoreFilePath, enqueueNonRepeatingNotes, defaultRepeat, filterQuery)?.first();
if (!page) { return; }
return page;
}
+
+/**
+ * Get all unique tags from due notes with their counts.
+ * This queries all due notes (without filter) to populate the tag shortcuts UI.
+ */
+export function getTagsFromDueNotes(
+ dv: DataviewApi | undefined,
+ ignoreFolderPath: string,
+ ignoreFilePath?: string | undefined,
+ enqueueNonRepeatingNotes?: boolean,
+ defaultRepeat?: any,
+): TagStats[] | undefined {
+ const dueNotes = getNotesDue(
+ dv, ignoreFolderPath, ignoreFilePath,
+ enqueueNonRepeatingNotes, defaultRepeat
+ // Note: no filterQuery here - we want all due notes to get all available tags
+ );
+
+ if (!dueNotes) return undefined;
+
+ const tagCounts = new Map();
+
+ dueNotes.forEach((page: any) => {
+ // page.file.etags contains explicit tags only (not expanded subtags)
+ const tags = page.file.etags?.values || [];
+ tags.forEach((tag: string) => {
+ tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
+ });
+ });
+
+ // Convert to sorted array (descending by count, then alphabetically)
+ return Array.from(tagCounts.entries())
+ .map(([tag, count]) => ({ tag, count }))
+ .sort((a, b) => {
+ if (b.count !== a.count) return b.count - a.count;
+ return a.tag.localeCompare(b.tag);
+ });
+}
diff --git a/src/settings.ts b/src/settings.ts
index 8ca449f..ee9e4ed 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -1,5 +1,10 @@
import { Repeat } from "./repeat/repeatTypes";
+export interface SavedFilter {
+ name: string;
+ query: string; // Dataview FROM expression, e.g. "#math" or "#math AND \"Courses\""
+}
+
export interface RepeatPluginSettings {
showDueCountInStatusBar: boolean;
showRibbonIcon: boolean;
@@ -8,6 +13,8 @@ export interface RepeatPluginSettings {
eveningReviewTime: string;
defaultRepeat: Repeat;
enqueueNonRepeatingNotes: boolean;
+ filterQuery: string; // Current Dataview FROM expression
+ savedFilters: SavedFilter[]; // Named filter presets
}
export const DEFAULT_SETTINGS: RepeatPluginSettings = {
@@ -23,4 +30,6 @@ export const DEFAULT_SETTINGS: RepeatPluginSettings = {
repeatTimeOfDay: 'AM',
},
enqueueNonRepeatingNotes: false,
+ filterQuery: '',
+ savedFilters: [],
};
diff --git a/styles.css b/styles.css
index ddc9f52..4101d53 100644
--- a/styles.css
+++ b/styles.css
@@ -66,3 +66,199 @@
.repeat-date_picker:::placeholder {
color: var(--text-faint);
}
+
+/* Filter UI */
+.repeat-filter {
+ padding: 8px 30px;
+ max-width: var(--file-line-width);
+ margin-left: auto;
+ margin-right: auto;
+ border-bottom: 1px solid var(--background-modifier-border);
+}
+
+.repeat-filter-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ padding: 4px 0;
+ user-select: none;
+}
+
+.repeat-filter-header:hover {
+ color: var(--text-accent);
+}
+
+.repeat-filter-toggle-icon {
+ display: flex;
+ align-items: center;
+ color: var(--text-muted);
+ transition: color 0.15s ease;
+}
+
+.repeat-filter-toggle-icon svg {
+ width: 16px;
+ height: 16px;
+}
+
+.repeat-filter-expanded .repeat-filter-toggle-icon {
+ color: var(--text-normal);
+}
+
+.repeat-filter-content {
+ padding-top: 12px;
+}
+
+.repeat-filter-row {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+.repeat-filter-row:last-child {
+ margin-bottom: 0;
+}
+
+/* Query Input */
+.repeat-filter-query-input {
+ flex: 1;
+ background: var(--background-modifier-form-field);
+ border: 1px solid var(--background-modifier-border);
+ color: var(--text-normal);
+ font-family: inherit;
+ padding: 5px 10px;
+ font-size: 14px;
+ border-radius: 4px;
+ outline: none;
+}
+
+.repeat-filter-query-input:hover {
+ background-color: var(--background-modifier-form-field-highlighted);
+}
+
+.repeat-filter-query-input:focus {
+ border-color: var(--interactive-accent);
+}
+
+.repeat-filter-query-input::placeholder {
+ color: var(--text-faint);
+}
+
+/* Tag Shortcuts */
+.repeat-filter-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin: 8px 0;
+ min-height: 28px;
+}
+
+.repeat-filter-tag {
+ display: inline-flex;
+ align-items: center;
+ background: var(--background-secondary);
+ color: var(--text-normal);
+ padding: 4px 10px;
+ border-radius: 12px;
+ font-size: 12px;
+ border: 1px solid var(--background-modifier-border);
+ cursor: pointer;
+}
+
+.repeat-filter-tag:hover {
+ background: var(--interactive-accent);
+ color: var(--text-on-accent);
+ border-color: var(--interactive-accent);
+}
+
+.repeat-filter-tag-more {
+ display: inline-flex;
+ align-items: center;
+ background: transparent;
+ border: 1px dashed var(--background-modifier-border);
+ color: var(--text-muted);
+ padding: 4px 10px;
+ border-radius: 12px;
+ font-size: 12px;
+ font-style: italic;
+ cursor: pointer;
+}
+
+.repeat-filter-tag-more:hover {
+ background: var(--background-secondary);
+ color: var(--text-normal);
+ border-color: var(--text-muted);
+}
+
+/* Dropdowns */
+.repeat-filter-dropdown {
+ flex: 1;
+ background: var(--background-modifier-form-field);
+ border: 1px solid var(--background-modifier-border);
+ color: var(--text-normal);
+ font-family: inherit;
+ padding: 5px 10px;
+ font-size: 14px;
+ border-radius: 4px;
+ outline: none;
+}
+
+.repeat-filter-dropdown:hover {
+ background-color: var(--background-modifier-form-field-highlighted);
+}
+
+.repeat-filter-dropdown:focus {
+ border-color: var(--interactive-accent);
+}
+
+/* Buttons */
+.repeat-filter-btn {
+ padding: 5px 12px;
+ font-size: 13px;
+ background: var(--background-modifier-border);
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ color: var(--text-normal);
+}
+
+.repeat-filter-btn:hover {
+ background: var(--background-modifier-hover);
+}
+
+.repeat-filter-btn-danger:hover {
+ background: var(--background-modifier-error);
+ color: var(--text-on-accent);
+}
+
+/* Filter Count */
+.repeat-filter-count {
+ font-size: 13px;
+ color: var(--text-muted);
+}
+
+/* Filter Error */
+.repeat-filter-error {
+ text-align: center;
+ font-size: 12px;
+ color: var(--text-error);
+ margin-top: 4px;
+}
+
+/* Mobile responsiveness */
+@media (max-width: 450px) {
+ .repeat-filter-row {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .repeat-filter-query-input,
+ .repeat-filter-dropdown {
+ width: 100%;
+ }
+
+ .repeat-filter-btn {
+ width: 100%;
+ }
+}