mirror of
https://github.com/pdriggett/practice-planner.git
synced 2026-07-22 07:47:14 +00:00
Initial scaffold of Weekly Music Practice Planner
Obsidian community plugin that creates one markdown note per week under <Folder>/<YYYY>/<YYYY>-Www.md. Week start day is configurable; weeks that cross a year boundary stay under the year they started. Includes settings tab (folder, week-start day, default skills) and commands to open this/next/previous week's plan.
This commit is contained in:
commit
b920116cb6
12 changed files with 2960 additions and 0 deletions
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
node_modules/
|
||||
|
||||
main.js
|
||||
*.js.map
|
||||
|
||||
data.json
|
||||
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Patrick Driggett
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
45
README.md
Normal file
45
README.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Weekly Music Practice Planner
|
||||
|
||||
An [Obsidian](https://obsidian.md) community plugin for planning and tracking weekly music practice.
|
||||
|
||||
Each week is a single markdown note in your vault containing:
|
||||
|
||||
- **Goals** for the week
|
||||
- A **skills × days** tracking table
|
||||
- **Daily notes** sections, one per day
|
||||
|
||||
## Settings
|
||||
|
||||
- **Folder** — where weekly notes are stored (default: `Practice`).
|
||||
- **Week starts on** — first day of the practice week. Weeks that cross into a new year are stored under the year they started.
|
||||
- **Default skills** — rows used in each new week's skill table.
|
||||
|
||||
## Commands
|
||||
|
||||
Available via the Command Palette (`⌘P` / `Ctrl+P`):
|
||||
|
||||
- **Open this week's practice plan**
|
||||
- **Open next week's practice plan**
|
||||
- **Open previous week's practice plan**
|
||||
|
||||
Each command opens the matching week's note, creating it from the template if it doesn't yet exist.
|
||||
|
||||
## File layout
|
||||
|
||||
Notes are written as:
|
||||
|
||||
```
|
||||
<Folder>/<YYYY>/<YYYY>-Www.md
|
||||
```
|
||||
|
||||
Where `YYYY` is the year of the week's **start** date and `ww` is the week number counted from the first start-day of that year.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # watch + rebuild main.js
|
||||
npm run build # type-check + production build
|
||||
```
|
||||
|
||||
To test in Obsidian, copy or symlink this folder into `<vault>/.obsidian/plugins/practice-planner/`. Obsidian needs `main.js`, `manifest.json`, and `styles.css` at runtime.
|
||||
46
esbuild.config.mjs
Normal file
46
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
|
||||
const banner = `/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
`;
|
||||
|
||||
const prod = process.argv[2] === "production";
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: { js: banner },
|
||||
entryPoints: ["main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins,
|
||||
],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
271
main.ts
Normal file
271
main.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import {
|
||||
App,
|
||||
Notice,
|
||||
Plugin,
|
||||
PluginSettingTab,
|
||||
Setting,
|
||||
TFile,
|
||||
normalizePath,
|
||||
} from "obsidian";
|
||||
|
||||
type DayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
interface PracticePlannerSettings {
|
||||
folderPath: string;
|
||||
weekStartDay: DayIndex;
|
||||
skills: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: PracticePlannerSettings = {
|
||||
folderPath: "Practice",
|
||||
weekStartDay: 1,
|
||||
skills: ["Scales", "Technique", "Repertoire", "Sight Reading"],
|
||||
};
|
||||
|
||||
const DAY_NAMES = [
|
||||
"Sunday",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
];
|
||||
|
||||
const DAY_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
export default class PracticePlannerPlugin extends Plugin {
|
||||
settings: PracticePlannerSettings = DEFAULT_SETTINGS;
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
|
||||
this.addCommand({
|
||||
id: "open-this-week",
|
||||
name: "Open this week's practice plan",
|
||||
callback: () => this.openWeek(0),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "open-next-week",
|
||||
name: "Open next week's practice plan",
|
||||
callback: () => this.openWeek(1),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "open-previous-week",
|
||||
name: "Open previous week's practice plan",
|
||||
callback: () => this.openWeek(-1),
|
||||
});
|
||||
|
||||
this.addSettingTab(new PracticePlannerSettingTab(this.app, this));
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
async openWeek(weekOffset: number) {
|
||||
const today = new Date();
|
||||
const target = new Date(today);
|
||||
target.setDate(target.getDate() + weekOffset * 7);
|
||||
|
||||
const start = startOfWeek(target, this.settings.weekStartDay);
|
||||
const { year, week } = weekKey(start, this.settings.weekStartDay);
|
||||
const path = this.notePathFor(year, week);
|
||||
|
||||
let file = this.app.vault.getAbstractFileByPath(path);
|
||||
if (!file) {
|
||||
await this.ensureFolder(path);
|
||||
const body = renderTemplate(
|
||||
start,
|
||||
year,
|
||||
week,
|
||||
this.settings.weekStartDay,
|
||||
this.settings.skills,
|
||||
);
|
||||
file = await this.app.vault.create(path, body);
|
||||
}
|
||||
|
||||
if (file instanceof TFile) {
|
||||
await this.app.workspace.getLeaf(false).openFile(file);
|
||||
} else {
|
||||
new Notice(`Could not open ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
notePathFor(year: number, week: number): string {
|
||||
const weekStr = String(week).padStart(2, "0");
|
||||
const folder = this.settings.folderPath.replace(/^\/+|\/+$/g, "");
|
||||
return normalizePath(`${folder}/${year}/${year}-W${weekStr}.md`);
|
||||
}
|
||||
|
||||
async ensureFolder(filePath: string) {
|
||||
const parts = filePath.split("/");
|
||||
parts.pop();
|
||||
let current = "";
|
||||
for (const part of parts) {
|
||||
current = current ? `${current}/${part}` : part;
|
||||
if (!this.app.vault.getAbstractFileByPath(current)) {
|
||||
await this.app.vault.createFolder(current);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startOfWeek(date: Date, weekStartDay: DayIndex): Date {
|
||||
const d = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const offset = (d.getDay() - weekStartDay + 7) % 7;
|
||||
d.setDate(d.getDate() - offset);
|
||||
return d;
|
||||
}
|
||||
|
||||
function firstWeekStartOfYear(year: number, weekStartDay: DayIndex): Date {
|
||||
const jan1 = new Date(year, 0, 1);
|
||||
const offset = (weekStartDay - jan1.getDay() + 7) % 7;
|
||||
return new Date(year, 0, 1 + offset);
|
||||
}
|
||||
|
||||
function weekKey(
|
||||
weekStart: Date,
|
||||
weekStartDay: DayIndex,
|
||||
): { year: number; week: number } {
|
||||
const year = weekStart.getFullYear();
|
||||
const firstStart = firstWeekStartOfYear(year, weekStartDay);
|
||||
const msPerWeek = 7 * 24 * 60 * 60 * 1000;
|
||||
const diff = weekStart.getTime() - firstStart.getTime();
|
||||
const week = Math.floor(diff / msPerWeek) + 1;
|
||||
return { year, week };
|
||||
}
|
||||
|
||||
function orderedDays(weekStartDay: DayIndex): DayIndex[] {
|
||||
const out: DayIndex[] = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
out.push(((weekStartDay + i) % 7) as DayIndex);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const d = new Date(date);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d;
|
||||
}
|
||||
|
||||
function formatISO(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function renderTemplate(
|
||||
weekStart: Date,
|
||||
year: number,
|
||||
week: number,
|
||||
weekStartDay: DayIndex,
|
||||
skills: string[],
|
||||
): string {
|
||||
const days = orderedDays(weekStartDay);
|
||||
const weekEnd = addDays(weekStart, 6);
|
||||
const weekStr = String(week).padStart(2, "0");
|
||||
|
||||
const headerCols = days.map((d) => DAY_SHORT[d]).join(" | ");
|
||||
const sepCols = days.map(() => "---").join(" | ");
|
||||
const skillRows = (skills.length ? skills : ["(skill)"])
|
||||
.map((s) => `| ${s} | ${days.map(() => " ").join(" | ")} |`)
|
||||
.join("\n");
|
||||
|
||||
const dailySections = days
|
||||
.map((d, i) => {
|
||||
const date = addDays(weekStart, i);
|
||||
return `### ${DAY_NAMES[d]} — ${formatISO(date)}\n- \n`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `---
|
||||
type: practice-week
|
||||
year: ${year}
|
||||
week: ${week}
|
||||
start: ${formatISO(weekStart)}
|
||||
end: ${formatISO(weekEnd)}
|
||||
week_start_day: ${DAY_NAMES[weekStartDay]}
|
||||
---
|
||||
|
||||
# Week ${weekStr} of ${year} (${formatISO(weekStart)} – ${formatISO(weekEnd)})
|
||||
|
||||
## Goals
|
||||
|
||||
|
||||
## Skills
|
||||
|
||||
| Skill | ${headerCols} |
|
||||
| --- | ${sepCols} |
|
||||
${skillRows}
|
||||
|
||||
## Daily Notes
|
||||
|
||||
${dailySections}`;
|
||||
}
|
||||
|
||||
class PracticePlannerSettingTab extends PluginSettingTab {
|
||||
plugin: PracticePlannerPlugin;
|
||||
|
||||
constructor(app: App, plugin: PracticePlannerPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Folder")
|
||||
.setDesc("Vault folder where weekly practice notes are stored.")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("Practice")
|
||||
.setValue(this.plugin.settings.folderPath)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.folderPath = value || "Practice";
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Week starts on")
|
||||
.setDesc(
|
||||
"First day of the practice week. Weeks that cross into a new year are stored under the year they started.",
|
||||
)
|
||||
.addDropdown((dd) => {
|
||||
DAY_NAMES.forEach((name, i) => dd.addOption(String(i), name));
|
||||
dd.setValue(String(this.plugin.settings.weekStartDay));
|
||||
dd.onChange(async (value) => {
|
||||
this.plugin.settings.weekStartDay = Number(value) as DayIndex;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Default skills")
|
||||
.setDesc("One skill per line. Used as rows in each new week's skill table.")
|
||||
.addTextArea((ta) => {
|
||||
ta.setPlaceholder("Scales\nTechnique\nRepertoire\nSight Reading");
|
||||
ta.setValue(this.plugin.settings.skills.join("\n"));
|
||||
ta.onChange(async (value) => {
|
||||
this.plugin.settings.skills = value
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
ta.inputEl.rows = 6;
|
||||
ta.inputEl.cols = 30;
|
||||
});
|
||||
}
|
||||
}
|
||||
10
manifest.json
Normal file
10
manifest.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "practice-planner",
|
||||
"name": "Weekly Music Practice Planner",
|
||||
"version": "0.1.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Plan and track weekly music practice: per-skill progress by day, plus per-day notes.",
|
||||
"author": "Patrick Driggett",
|
||||
"authorUrl": "",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
2488
package-lock.json
generated
Normal file
2488
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
package.json
Normal file
29
package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "practice-planner",
|
||||
"version": "0.1.0",
|
||||
"description": "Weekly Music Practice Planner for Obsidian.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
},
|
||||
"keywords": [
|
||||
"obsidian",
|
||||
"music",
|
||||
"practice",
|
||||
"planner"
|
||||
],
|
||||
"author": "Patrick Driggett",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@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.28.1",
|
||||
"obsidian": "latest",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "4.7.4"
|
||||
}
|
||||
}
|
||||
1
styles.css
Normal file
1
styles.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
/* Weekly Music Practice Planner styles */
|
||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
12
version-bump.mjs
Normal file
12
version-bump.mjs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const targetVersion = process.env.npm_package_version;
|
||||
|
||||
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||
const { minAppVersion } = manifest;
|
||||
manifest.version = targetVersion;
|
||||
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||
|
||||
const versions = JSON.parse(readFileSync("versions.json", "utf8"));
|
||||
versions[targetVersion] = minAppVersion;
|
||||
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));
|
||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"0.1.0": "0.15.0"
|
||||
}
|
||||
Loading…
Reference in a new issue