diff --git a/.eslintrc.js b/.eslintrc.js index fc30c7b..430d0e5 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -10,6 +10,6 @@ module.exports = { ], "@typescript-eslint/ban-ts-comment": 0, "@typescript-eslint/no-explicit-any": 0, - "eol-last": ["error", "always"] + "eol-last": ["error", "always"], }, }; diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..88da7d6 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +*.md +main.js +test-longform-vault diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..39fb190 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "plugins": [], + "printWidth": 80, + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/manifest-beta.json b/manifest-beta.json index 2949b6e..7ebeadf 100644 --- a/manifest-beta.json +++ b/manifest-beta.json @@ -1,10 +1,10 @@ -{ - "id": "longform", - "name": "Longform", - "version": "2.0.0", - "minAppVersion": "1.0", - "description": "Write novels, screenplays, and other long projects in Obsidian.", - "author": "Kevin Barrett", - "authorUrl": "https://kevinbarrett.org", - "isDesktopOnly": false -} \ No newline at end of file +{ + "id": "longform", + "name": "Longform", + "version": "2.0.0", + "minAppVersion": "1.0", + "description": "Write novels, screenplays, and other long projects in Obsidian.", + "author": "Kevin Barrett", + "authorUrl": "https://kevinbarrett.org", + "isDesktopOnly": false +} diff --git a/manifest.json b/manifest.json index b242acb..6c56e27 100644 --- a/manifest.json +++ b/manifest.json @@ -1,11 +1,11 @@ -{ - "id": "longform", - "name": "Longform", - "version": "2.0.7", - "minAppVersion": "1.0", - "description": "Write novels, screenplays, and other long projects in Obsidian.", - "author": "Kevin Barrett", - "authorUrl": "https://kevinbarrett.org", - "fundingUrl": "https://github.com/sponsors/kevboh", - "isDesktopOnly": false -} +{ + "id": "longform", + "name": "Longform", + "version": "2.0.7", + "minAppVersion": "1.0", + "description": "Write novels, screenplays, and other long projects in Obsidian.", + "author": "Kevin Barrett", + "authorUrl": "https://kevinbarrett.org", + "fundingUrl": "https://github.com/sponsors/kevboh", + "isDesktopOnly": false +} diff --git a/rollup.config.js b/rollup.config.js index b71bd1b..9fb1f38 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,80 +1,80 @@ -import svelte from "rollup-plugin-svelte"; -import typescript from "@rollup/plugin-typescript"; -import { nodeResolve } from "@rollup/plugin-node-resolve"; -import commonjs from "@rollup/plugin-commonjs"; -import sveltePreprocess from "svelte-preprocess"; -import copy from "rollup-plugin-copy"; -import { env } from "process"; - -const isProd = env.BUILD === "production"; - -const banner = `/* -THIS IS A GENERATED/BUNDLED FILE BY ROLLUP -if you want to view the source visit the plugins github repository -*/ -`; - -const getPlugins = (...plugins) => - [ - svelte({ - emitCss: false, - preprocess: sveltePreprocess(), - }), - typescript({ - sourceMap: !isProd, - inlineSources: !isProd, - rootDir: "./src", - }), - nodeResolve({ browser: true, dedupe: ["svelte"] }), - commonjs({ - include: "node_modules/**", - }), - ].concat(plugins); - -const BASE_CONFIG = { - input: "src/main.ts", - external: ["obsidian"], -}; - -const DEV_PLUGIN_CONFIG = { - ...BASE_CONFIG, - output: { - dir: "test-longform-vault/.obsidian/plugins/longform", - sourcemap: "inline", - format: "cjs", - exports: "default", - }, - plugins: getPlugins( - copy({ - targets: [ - { - src: "manifest.json", - dest: "test-longform-vault/.obsidian/plugins/longform/", - }, - { - src: "styles.css", - dest: "test-longform-vault/.obsidian/plugins/longform/", - }, - ], - hook: "writeBundle", - verbose: true, - overwrite: true, - }) - ), -}; - -const PROD_PLUGIN_CONFIG = { - ...BASE_CONFIG, - output: { - dir: ".", - sourcemap: !isProd, - format: "cjs", - exports: "default", - banner, - }, - plugins: getPlugins(), -}; - -const config = isProd ? PROD_PLUGIN_CONFIG : DEV_PLUGIN_CONFIG; - -export default config; +import svelte from "rollup-plugin-svelte"; +import typescript from "@rollup/plugin-typescript"; +import { nodeResolve } from "@rollup/plugin-node-resolve"; +import commonjs from "@rollup/plugin-commonjs"; +import sveltePreprocess from "svelte-preprocess"; +import copy from "rollup-plugin-copy"; +import { env } from "process"; + +const isProd = env.BUILD === "production"; + +const banner = `/* +THIS IS A GENERATED/BUNDLED FILE BY ROLLUP +if you want to view the source visit the plugins github repository +*/ +`; + +const getPlugins = (...plugins) => + [ + svelte({ + emitCss: false, + preprocess: sveltePreprocess(), + }), + typescript({ + sourceMap: !isProd, + inlineSources: !isProd, + rootDir: "./src", + }), + nodeResolve({ browser: true, dedupe: ["svelte"] }), + commonjs({ + include: "node_modules/**", + }), + ].concat(plugins); + +const BASE_CONFIG = { + input: "src/main.ts", + external: ["obsidian"], +}; + +const DEV_PLUGIN_CONFIG = { + ...BASE_CONFIG, + output: { + dir: "test-longform-vault/.obsidian/plugins/longform", + sourcemap: "inline", + format: "cjs", + exports: "default", + }, + plugins: getPlugins( + copy({ + targets: [ + { + src: "manifest.json", + dest: "test-longform-vault/.obsidian/plugins/longform/", + }, + { + src: "styles.css", + dest: "test-longform-vault/.obsidian/plugins/longform/", + }, + ], + hook: "writeBundle", + verbose: true, + overwrite: true, + }) + ), +}; + +const PROD_PLUGIN_CONFIG = { + ...BASE_CONFIG, + output: { + dir: ".", + sourcemap: !isProd, + format: "cjs", + exports: "default", + banner, + }, + plugins: getPlugins(), +}; + +const config = isProd ? PROD_PLUGIN_CONFIG : DEV_PLUGIN_CONFIG; + +export default config; diff --git a/src/main.ts b/src/main.ts index 289b698..6845426 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,523 +1,523 @@ -import { - Plugin, - WorkspaceLeaf, - FileView, - addIcon, - Notice, - TAbstractFile, - TFolder, - normalizePath, -} from "obsidian"; -import debounce from "lodash/debounce"; -import once from "lodash/once"; -import pick from "lodash/pick"; -import { derived, type Unsubscriber } from "svelte/store"; -import { get } from "svelte/store"; - -import { - VIEW_TYPE_LONGFORM_EXPLORER, - ExplorerPane, -} from "./view/explorer/ExplorerPane"; -import { - PASSTHROUGH_SAVE_SETTINGS_PATHS, - type Draft, - type LongformPluginSettings, - type SerializedWorkflow, - type WordCountSession, -} from "./model/types"; -import { DEFAULT_SETTINGS, TRACKED_SETTINGS_PATHS } from "./model/types"; -import { activeFile, goalProgress, selectedTab } from "./view/stores"; -import { ICON_NAME, ICON_SVG } from "./view/icon"; -import { LongformSettingsTab } from "./view/settings/LongformSettings"; -import { - deserializeWorkflow, - serializeWorkflow, -} from "./compile/serialization"; -import type { Workflow } from "./compile"; -import { DEFAULT_WORKFLOWS } from "./compile"; -import { UserScriptObserver } from "./model/user-script-observer"; -import { StoreVaultSync } from "./model/store-vault-sync"; -import { - selectedDraft, - selectedDraftVaultPath, - workflows, - initialized, - pluginSettings, - drafts, - sessions, -} from "./model/stores"; -import { addCommands } from "./commands"; -import { determineMigrationStatus } from "./model/migration"; -import { draftForPath } from "./model/scene-navigation"; -import { WritingSessionTracker } from "./model/writing-session-tracker"; -import NewProjectModal from "./view/project-lifecycle/new-project-modal"; -import { LongformAPI } from "./api/LongformAPI"; - -const LONGFORM_LEAF_CLASS = "longform-leaf"; - -// TODO: Try and abstract away more logic from actual plugin hooks here - -export default class LongformPlugin extends Plugin { - // Local mirror of the pluginSettings store - // since this class does a lot of ad-hoc settings fetching. - // More efficient than a lot of get() calls. - cachedSettings: LongformPluginSettings | null = null; - private unsubscribeSettings: Unsubscriber; - private unsubscribeWorkflows: Unsubscriber; - private unsubscribeDrafts: Unsubscriber; - private unsubscribeSelectedDraft: Unsubscriber; - private unsubscribeSessions: Unsubscriber; - private unsubscribeGoalNotification: Unsubscriber; - private userScriptObserver: UserScriptObserver; - writingSessionTracker: WritingSessionTracker; - public api: LongformAPI; - - private storeVaultSync: StoreVaultSync; - - async onload(): Promise { - console.log(`[Longform] Starting Longform ${this.manifest.version}…`); - addIcon(ICON_NAME, ICON_SVG); - - this.registerView( - VIEW_TYPE_LONGFORM_EXPLORER, - (leaf: WorkspaceLeaf) => new ExplorerPane(leaf) - ); - - this.registerEvent( - this.app.workspace.on("file-menu", (menu, file: TAbstractFile) => { - if (!(file instanceof TFolder)) { - return; - } - menu.addItem((item) => { - item - .setTitle("Create Longform Project") - .setIcon(ICON_NAME) - .onClick(() => { - new NewProjectModal(this.app, file).open(); - }); - }); - }) - ); - - // Settings - this.unsubscribeSettings = pluginSettings.subscribe(async (value) => { - let shouldSave = false; - - const changeInKeys = ( - obj1: Record, - obj2: Record, - keys: string[] - ): boolean => { - return !!keys.find((k) => obj1[k] !== obj2[k]); - }; - - if ( - this.cachedSettings && - changeInKeys( - this.cachedSettings, - value, - PASSTHROUGH_SAVE_SETTINGS_PATHS - ) - ) { - shouldSave = true; - } - - this.cachedSettings = value; - - if (shouldSave) { - await this.saveSettings(); - } - }); - - await this.loadSettings(); - this.addSettingTab(new LongformSettingsTab(this.app, this)); - - this.storeVaultSync = new StoreVaultSync(this.app); - - this.app.workspace.onLayoutReady(this.postLayoutInit.bind(this)); - - // Track active file - activeFile.set(this.app.workspace.getActiveFile()); - this.registerEvent( - this.app.workspace.on("active-leaf-change", (leaf) => { - if (leaf.view instanceof FileView) { - activeFile.set(leaf.view.file); - } - // NOTE: This may break, as it's undocumented. - // Need some way to determine the empty state. - else if ( - (leaf.view as any).emptyTitleEl && - (leaf.view as any).emptyStateEl - ) { - activeFile.set(null); - } - }) - ); - - addCommands(this); - - // Dynamically style longform scenes - this.registerEvent( - this.app.workspace.on("layout-change", () => { - this.styleLongformLeaves(); - }) - ); - this.unsubscribeDrafts = drafts.subscribe((allDrafts) => { - this.styleLongformLeaves(allDrafts); - }); - - this.api = new LongformAPI(); - } - - onunload(): void { - this.userScriptObserver.destroy(); - this.storeVaultSync.destroy(); - this.unsubscribeSettings(); - this.unsubscribeWorkflows(); - this.unsubscribeSelectedDraft(); - this.unsubscribeDrafts(); - this.unsubscribeSessions(); - this.unsubscribeGoalNotification(); - this.writingSessionTracker.destroy(); - this.app.workspace - .getLeavesOfType(VIEW_TYPE_LONGFORM_EXPLORER) - .forEach((leaf) => leaf.detach()); - } - - async loadSettings(): Promise { - const settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); - - // deserialize iso8601 strings as dates - - const _pluginSettings: LongformPluginSettings = pick( - settings, - TRACKED_SETTINGS_PATHS - ) as LongformPluginSettings; - pluginSettings.set(_pluginSettings); - selectedDraftVaultPath.set(_pluginSettings.selectedDraftVaultPath); - determineMigrationStatus(_pluginSettings); - - // We load user scripts imperatively first to cover cases where we need to deserialize - // workflows that may contain them. - const userScriptFolder = settings["userScriptFolder"]; - this.userScriptObserver = new UserScriptObserver( - this.app.vault, - userScriptFolder - ); - await this.userScriptObserver.loadUserSteps(); - - let _workflows = settings["workflows"]; - - if (!_workflows) { - console.log("[Longform] No workflows found; adding default workflow."); - _workflows = DEFAULT_WORKFLOWS; - } - - const deserializedWorkflows: Record = {}; - Object.entries(_workflows).forEach(([key, value]) => { - deserializedWorkflows[key as string] = deserializeWorkflow( - value as SerializedWorkflow - ); - }); - workflows.set(deserializedWorkflows); - - const onStatusClick = () => { - const file = get(activeFile); - if (!file) { - return false; - } - const draft = draftForPath(file.path, get(drafts)); - if (draft) { - selectedDraftVaultPath.set(draft.vaultPath); - this.initLeaf(); - const leaf = this.app.workspace - .getLeavesOfType(VIEW_TYPE_LONGFORM_EXPLORER) - .first(); - if (leaf) { - this.app.workspace.revealLeaf(leaf); - } - - selectedTab.set("Project"); - } - }; - - this.writingSessionTracker = new WritingSessionTracker( - settings["sessions"], - this.addStatusBarItem(), - onStatusClick, - this.app.vault - ); - } - - async saveSettings(): Promise { - if (!this.cachedSettings) { - return; - } - - const _workflows = get(workflows); - const serializedWorkflows: Record = {}; - Object.entries(_workflows).forEach(([key, value]) => { - serializedWorkflows[key as string] = serializeWorkflow(value); - }); - - await this.saveData({ - ...this.cachedSettings, - workflows: serializedWorkflows, - }); - } - - private postLayoutInit(): void { - this.userScriptObserver.beginObserving(); - this.watchProjects(); - - const defaultToScenes = once(function (d: Draft) { - if (d && d.format === "scenes") { - selectedTab.set("Scenes"); - } - }); - - this.unsubscribeSelectedDraft = selectedDraft.subscribe(async (d) => { - if (!get(initialized) || !d) { - return; - } - - // On initial load, default to Scenes tab for multi-scene projects. - defaultToScenes(d); - - pluginSettings.update((s) => ({ - ...s, - selectedDraftVaultPath: d.vaultPath, - })); - this.cachedSettings = get(pluginSettings); - await this.saveSettings(); - }); - - // Workflows - const saveWorkflows = debounce(() => { - this.saveSettings(); - }, 3000); - this.unsubscribeWorkflows = workflows.subscribe(() => { - if (!get(initialized)) { - return; - } - - saveWorkflows(); - }); - - // Sessions - const saveSessions = debounce(async (toSave: WordCountSession[]) => { - if (this.cachedSettings.sessionStorage === "data") { - pluginSettings.update((s) => { - const toReturn = { - ...s, - sessions: toSave, - }; - this.cachedSettings = toReturn; - return toReturn; - }); - await this.saveSettings(); - } else { - // Save to either plugin or vault - let file: string | null = null; - if (this.cachedSettings.sessionStorage === "plugin-folder") { - if (!this.manifest.dir) { - console.error(`[Longform] No manifest.dir for saving sessions.`); - return; - } - file = normalizePath(`${this.manifest.dir}/sessions.json`); - } else { - file = this.cachedSettings.sessionFile; - } - if (!file) { - return; - } - const data = JSON.stringify(toSave); - await this.app.vault.adapter.write(file, data); - - // If we have lingering session data in settings, clear it - if (this.cachedSettings.sessions.length !== 0) { - const emptySessions: WordCountSession[] = []; - pluginSettings.update((s) => { - const toReturn = { - ...s, - sessions: emptySessions, - }; - this.cachedSettings = toReturn; - return toReturn; - }); - await this.saveSettings(); - } - } - }, 3000); - this.unsubscribeSessions = sessions.subscribe((s) => { - if (!get(initialized)) { - return; - } - - saveSessions(s); - }); - - this.unsubscribeGoalNotification = derived( - [goalProgress, pluginSettings, selectedDraft, activeFile], - (stores) => stores - ).subscribe( - ([$goalProgress, $pluginSettings, $selectedDraft, $activeFile]) => { - if ($goalProgress >= 1 && $pluginSettings.notifyOnGoal) { - let target: string; - if ($pluginSettings.applyGoalTo === "all") { - target = "all"; - } else if ($pluginSettings.applyGoalTo === "project") { - target = `draft::${$selectedDraft.vaultPath}`; - } else if ($pluginSettings.applyGoalTo === "note") { - if ($selectedDraft && $selectedDraft.format === "single") { - target = `note::${$selectedDraft.vaultPath}`; - } else if ( - $selectedDraft && - $selectedDraft.format === "scenes" && - $activeFile - ) { - target = `note::${$activeFile.path}`; - } - } - if ( - target && - !this.writingSessionTracker.goalsNotifiedFor.has(target) - ) { - this.writingSessionTracker.goalsNotifiedFor.add(target); - new Notice("Writing goal met!"); - } - } - } - ); - - this.initLeaf(); - initialized.set(true); - } - - initLeaf(): void { - if ( - this.app.workspace.getLeavesOfType(VIEW_TYPE_LONGFORM_EXPLORER).length - ) { - return; - } - this.app.workspace.getLeftLeaf(false).setViewState({ - type: VIEW_TYPE_LONGFORM_EXPLORER, - }); - } - - private watchProjects(): void { - // USER SCRIPTS - this.registerEvent( - this.app.vault.on( - "modify", - this.userScriptObserver.fileEventCallback.bind(this.userScriptObserver) - ) - ); - - this.registerEvent( - this.app.vault.on("create", (file) => { - this.userScriptObserver.fileEventCallback.bind(this.userScriptObserver)( - file - ); - }) - ); - - this.registerEvent( - this.app.vault.on("delete", (file) => { - this.userScriptObserver.fileEventCallback.bind(this.userScriptObserver)( - file - ); - }) - ); - - this.registerEvent( - this.app.vault.on("rename", (file, _oldPath) => { - this.userScriptObserver.fileEventCallback.bind(this.userScriptObserver)( - file - ); - }) - ); - - // STORE-VAULT SYNC - this.storeVaultSync.discoverDrafts(); - - this.registerEvent( - this.app.metadataCache.on( - "changed", - this.storeVaultSync.fileMetadataChanged.bind(this.storeVaultSync) - ) - ); - - this.registerEvent( - this.app.vault.on( - "create", - this.storeVaultSync.fileCreated.bind(this.storeVaultSync) - ) - ); - - this.registerEvent( - this.app.vault.on( - "delete", - this.storeVaultSync.fileDeleted.bind(this.storeVaultSync) - ) - ); - - this.registerEvent( - this.app.vault.on( - "rename", - this.storeVaultSync.fileRenamed.bind(this.storeVaultSync) - ) - ); - - // WORD COUNTS - this.registerEvent( - this.app.vault.on( - "modify", - this.writingSessionTracker.fileModified.bind(this.writingSessionTracker) - ) - ); - - this.registerEvent( - this.app.vault.on("create", (file) => { - this.writingSessionTracker.debouncedCountDraftContaining.bind( - this.writingSessionTracker - )(file); - }) - ); - - this.registerEvent( - this.app.vault.on("delete", (file) => { - this.writingSessionTracker.debouncedCountDraftContaining.bind( - this.writingSessionTracker - )(file); - }) - ); - - this.registerEvent( - this.app.vault.on("rename", (file, _oldPath) => { - this.writingSessionTracker.debouncedCountDraftContaining.bind( - this.writingSessionTracker - )(file); - }) - ); - } - - private styleLongformLeaves(allDrafts: Draft[] = get(drafts)) { - this.app.workspace.getLeavesOfType("markdown").forEach((leaf) => { - if (leaf.view instanceof FileView) { - const draft = draftForPath(leaf.view.file.path, allDrafts); - if (draft) { - leaf.view.containerEl.classList.add(LONGFORM_LEAF_CLASS); - } else { - leaf.view.containerEl.classList.remove(LONGFORM_LEAF_CLASS); - } - } - - // @ts-ignore - const leafId = leaf.id; - if (leafId) { - leaf.view.containerEl.dataset.leafId = leafId; - } - }); - } -} +import { + Plugin, + WorkspaceLeaf, + FileView, + addIcon, + Notice, + TAbstractFile, + TFolder, + normalizePath, +} from "obsidian"; +import debounce from "lodash/debounce"; +import once from "lodash/once"; +import pick from "lodash/pick"; +import { derived, type Unsubscriber } from "svelte/store"; +import { get } from "svelte/store"; + +import { + VIEW_TYPE_LONGFORM_EXPLORER, + ExplorerPane, +} from "./view/explorer/ExplorerPane"; +import { + PASSTHROUGH_SAVE_SETTINGS_PATHS, + type Draft, + type LongformPluginSettings, + type SerializedWorkflow, + type WordCountSession, +} from "./model/types"; +import { DEFAULT_SETTINGS, TRACKED_SETTINGS_PATHS } from "./model/types"; +import { activeFile, goalProgress, selectedTab } from "./view/stores"; +import { ICON_NAME, ICON_SVG } from "./view/icon"; +import { LongformSettingsTab } from "./view/settings/LongformSettings"; +import { + deserializeWorkflow, + serializeWorkflow, +} from "./compile/serialization"; +import type { Workflow } from "./compile"; +import { DEFAULT_WORKFLOWS } from "./compile"; +import { UserScriptObserver } from "./model/user-script-observer"; +import { StoreVaultSync } from "./model/store-vault-sync"; +import { + selectedDraft, + selectedDraftVaultPath, + workflows, + initialized, + pluginSettings, + drafts, + sessions, +} from "./model/stores"; +import { addCommands } from "./commands"; +import { determineMigrationStatus } from "./model/migration"; +import { draftForPath } from "./model/scene-navigation"; +import { WritingSessionTracker } from "./model/writing-session-tracker"; +import NewProjectModal from "./view/project-lifecycle/new-project-modal"; +import { LongformAPI } from "./api/LongformAPI"; + +const LONGFORM_LEAF_CLASS = "longform-leaf"; + +// TODO: Try and abstract away more logic from actual plugin hooks here + +export default class LongformPlugin extends Plugin { + // Local mirror of the pluginSettings store + // since this class does a lot of ad-hoc settings fetching. + // More efficient than a lot of get() calls. + cachedSettings: LongformPluginSettings | null = null; + private unsubscribeSettings: Unsubscriber; + private unsubscribeWorkflows: Unsubscriber; + private unsubscribeDrafts: Unsubscriber; + private unsubscribeSelectedDraft: Unsubscriber; + private unsubscribeSessions: Unsubscriber; + private unsubscribeGoalNotification: Unsubscriber; + private userScriptObserver: UserScriptObserver; + writingSessionTracker: WritingSessionTracker; + public api: LongformAPI; + + private storeVaultSync: StoreVaultSync; + + async onload(): Promise { + console.log(`[Longform] Starting Longform ${this.manifest.version}…`); + addIcon(ICON_NAME, ICON_SVG); + + this.registerView( + VIEW_TYPE_LONGFORM_EXPLORER, + (leaf: WorkspaceLeaf) => new ExplorerPane(leaf) + ); + + this.registerEvent( + this.app.workspace.on("file-menu", (menu, file: TAbstractFile) => { + if (!(file instanceof TFolder)) { + return; + } + menu.addItem((item) => { + item + .setTitle("Create Longform Project") + .setIcon(ICON_NAME) + .onClick(() => { + new NewProjectModal(this.app, file).open(); + }); + }); + }) + ); + + // Settings + this.unsubscribeSettings = pluginSettings.subscribe(async (value) => { + let shouldSave = false; + + const changeInKeys = ( + obj1: Record, + obj2: Record, + keys: string[] + ): boolean => { + return !!keys.find((k) => obj1[k] !== obj2[k]); + }; + + if ( + this.cachedSettings && + changeInKeys( + this.cachedSettings, + value, + PASSTHROUGH_SAVE_SETTINGS_PATHS + ) + ) { + shouldSave = true; + } + + this.cachedSettings = value; + + if (shouldSave) { + await this.saveSettings(); + } + }); + + await this.loadSettings(); + this.addSettingTab(new LongformSettingsTab(this.app, this)); + + this.storeVaultSync = new StoreVaultSync(this.app); + + this.app.workspace.onLayoutReady(this.postLayoutInit.bind(this)); + + // Track active file + activeFile.set(this.app.workspace.getActiveFile()); + this.registerEvent( + this.app.workspace.on("active-leaf-change", (leaf) => { + if (leaf.view instanceof FileView) { + activeFile.set(leaf.view.file); + } + // NOTE: This may break, as it's undocumented. + // Need some way to determine the empty state. + else if ( + (leaf.view as any).emptyTitleEl && + (leaf.view as any).emptyStateEl + ) { + activeFile.set(null); + } + }) + ); + + addCommands(this); + + // Dynamically style longform scenes + this.registerEvent( + this.app.workspace.on("layout-change", () => { + this.styleLongformLeaves(); + }) + ); + this.unsubscribeDrafts = drafts.subscribe((allDrafts) => { + this.styleLongformLeaves(allDrafts); + }); + + this.api = new LongformAPI(); + } + + onunload(): void { + this.userScriptObserver.destroy(); + this.storeVaultSync.destroy(); + this.unsubscribeSettings(); + this.unsubscribeWorkflows(); + this.unsubscribeSelectedDraft(); + this.unsubscribeDrafts(); + this.unsubscribeSessions(); + this.unsubscribeGoalNotification(); + this.writingSessionTracker.destroy(); + this.app.workspace + .getLeavesOfType(VIEW_TYPE_LONGFORM_EXPLORER) + .forEach((leaf) => leaf.detach()); + } + + async loadSettings(): Promise { + const settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + + // deserialize iso8601 strings as dates + + const _pluginSettings: LongformPluginSettings = pick( + settings, + TRACKED_SETTINGS_PATHS + ) as LongformPluginSettings; + pluginSettings.set(_pluginSettings); + selectedDraftVaultPath.set(_pluginSettings.selectedDraftVaultPath); + determineMigrationStatus(_pluginSettings); + + // We load user scripts imperatively first to cover cases where we need to deserialize + // workflows that may contain them. + const userScriptFolder = settings["userScriptFolder"]; + this.userScriptObserver = new UserScriptObserver( + this.app.vault, + userScriptFolder + ); + await this.userScriptObserver.loadUserSteps(); + + let _workflows = settings["workflows"]; + + if (!_workflows) { + console.log("[Longform] No workflows found; adding default workflow."); + _workflows = DEFAULT_WORKFLOWS; + } + + const deserializedWorkflows: Record = {}; + Object.entries(_workflows).forEach(([key, value]) => { + deserializedWorkflows[key as string] = deserializeWorkflow( + value as SerializedWorkflow + ); + }); + workflows.set(deserializedWorkflows); + + const onStatusClick = () => { + const file = get(activeFile); + if (!file) { + return false; + } + const draft = draftForPath(file.path, get(drafts)); + if (draft) { + selectedDraftVaultPath.set(draft.vaultPath); + this.initLeaf(); + const leaf = this.app.workspace + .getLeavesOfType(VIEW_TYPE_LONGFORM_EXPLORER) + .first(); + if (leaf) { + this.app.workspace.revealLeaf(leaf); + } + + selectedTab.set("Project"); + } + }; + + this.writingSessionTracker = new WritingSessionTracker( + settings["sessions"], + this.addStatusBarItem(), + onStatusClick, + this.app.vault + ); + } + + async saveSettings(): Promise { + if (!this.cachedSettings) { + return; + } + + const _workflows = get(workflows); + const serializedWorkflows: Record = {}; + Object.entries(_workflows).forEach(([key, value]) => { + serializedWorkflows[key as string] = serializeWorkflow(value); + }); + + await this.saveData({ + ...this.cachedSettings, + workflows: serializedWorkflows, + }); + } + + private postLayoutInit(): void { + this.userScriptObserver.beginObserving(); + this.watchProjects(); + + const defaultToScenes = once(function (d: Draft) { + if (d && d.format === "scenes") { + selectedTab.set("Scenes"); + } + }); + + this.unsubscribeSelectedDraft = selectedDraft.subscribe(async (d) => { + if (!get(initialized) || !d) { + return; + } + + // On initial load, default to Scenes tab for multi-scene projects. + defaultToScenes(d); + + pluginSettings.update((s) => ({ + ...s, + selectedDraftVaultPath: d.vaultPath, + })); + this.cachedSettings = get(pluginSettings); + await this.saveSettings(); + }); + + // Workflows + const saveWorkflows = debounce(() => { + this.saveSettings(); + }, 3000); + this.unsubscribeWorkflows = workflows.subscribe(() => { + if (!get(initialized)) { + return; + } + + saveWorkflows(); + }); + + // Sessions + const saveSessions = debounce(async (toSave: WordCountSession[]) => { + if (this.cachedSettings.sessionStorage === "data") { + pluginSettings.update((s) => { + const toReturn = { + ...s, + sessions: toSave, + }; + this.cachedSettings = toReturn; + return toReturn; + }); + await this.saveSettings(); + } else { + // Save to either plugin or vault + let file: string | null = null; + if (this.cachedSettings.sessionStorage === "plugin-folder") { + if (!this.manifest.dir) { + console.error(`[Longform] No manifest.dir for saving sessions.`); + return; + } + file = normalizePath(`${this.manifest.dir}/sessions.json`); + } else { + file = this.cachedSettings.sessionFile; + } + if (!file) { + return; + } + const data = JSON.stringify(toSave); + await this.app.vault.adapter.write(file, data); + + // If we have lingering session data in settings, clear it + if (this.cachedSettings.sessions.length !== 0) { + const emptySessions: WordCountSession[] = []; + pluginSettings.update((s) => { + const toReturn = { + ...s, + sessions: emptySessions, + }; + this.cachedSettings = toReturn; + return toReturn; + }); + await this.saveSettings(); + } + } + }, 3000); + this.unsubscribeSessions = sessions.subscribe((s) => { + if (!get(initialized)) { + return; + } + + saveSessions(s); + }); + + this.unsubscribeGoalNotification = derived( + [goalProgress, pluginSettings, selectedDraft, activeFile], + (stores) => stores + ).subscribe( + ([$goalProgress, $pluginSettings, $selectedDraft, $activeFile]) => { + if ($goalProgress >= 1 && $pluginSettings.notifyOnGoal) { + let target: string; + if ($pluginSettings.applyGoalTo === "all") { + target = "all"; + } else if ($pluginSettings.applyGoalTo === "project") { + target = `draft::${$selectedDraft.vaultPath}`; + } else if ($pluginSettings.applyGoalTo === "note") { + if ($selectedDraft && $selectedDraft.format === "single") { + target = `note::${$selectedDraft.vaultPath}`; + } else if ( + $selectedDraft && + $selectedDraft.format === "scenes" && + $activeFile + ) { + target = `note::${$activeFile.path}`; + } + } + if ( + target && + !this.writingSessionTracker.goalsNotifiedFor.has(target) + ) { + this.writingSessionTracker.goalsNotifiedFor.add(target); + new Notice("Writing goal met!"); + } + } + } + ); + + this.initLeaf(); + initialized.set(true); + } + + initLeaf(): void { + if ( + this.app.workspace.getLeavesOfType(VIEW_TYPE_LONGFORM_EXPLORER).length + ) { + return; + } + this.app.workspace.getLeftLeaf(false).setViewState({ + type: VIEW_TYPE_LONGFORM_EXPLORER, + }); + } + + private watchProjects(): void { + // USER SCRIPTS + this.registerEvent( + this.app.vault.on( + "modify", + this.userScriptObserver.fileEventCallback.bind(this.userScriptObserver) + ) + ); + + this.registerEvent( + this.app.vault.on("create", (file) => { + this.userScriptObserver.fileEventCallback.bind(this.userScriptObserver)( + file + ); + }) + ); + + this.registerEvent( + this.app.vault.on("delete", (file) => { + this.userScriptObserver.fileEventCallback.bind(this.userScriptObserver)( + file + ); + }) + ); + + this.registerEvent( + this.app.vault.on("rename", (file, _oldPath) => { + this.userScriptObserver.fileEventCallback.bind(this.userScriptObserver)( + file + ); + }) + ); + + // STORE-VAULT SYNC + this.storeVaultSync.discoverDrafts(); + + this.registerEvent( + this.app.metadataCache.on( + "changed", + this.storeVaultSync.fileMetadataChanged.bind(this.storeVaultSync) + ) + ); + + this.registerEvent( + this.app.vault.on( + "create", + this.storeVaultSync.fileCreated.bind(this.storeVaultSync) + ) + ); + + this.registerEvent( + this.app.vault.on( + "delete", + this.storeVaultSync.fileDeleted.bind(this.storeVaultSync) + ) + ); + + this.registerEvent( + this.app.vault.on( + "rename", + this.storeVaultSync.fileRenamed.bind(this.storeVaultSync) + ) + ); + + // WORD COUNTS + this.registerEvent( + this.app.vault.on( + "modify", + this.writingSessionTracker.fileModified.bind(this.writingSessionTracker) + ) + ); + + this.registerEvent( + this.app.vault.on("create", (file) => { + this.writingSessionTracker.debouncedCountDraftContaining.bind( + this.writingSessionTracker + )(file); + }) + ); + + this.registerEvent( + this.app.vault.on("delete", (file) => { + this.writingSessionTracker.debouncedCountDraftContaining.bind( + this.writingSessionTracker + )(file); + }) + ); + + this.registerEvent( + this.app.vault.on("rename", (file, _oldPath) => { + this.writingSessionTracker.debouncedCountDraftContaining.bind( + this.writingSessionTracker + )(file); + }) + ); + } + + private styleLongformLeaves(allDrafts: Draft[] = get(drafts)) { + this.app.workspace.getLeavesOfType("markdown").forEach((leaf) => { + if (leaf.view instanceof FileView) { + const draft = draftForPath(leaf.view.file.path, allDrafts); + if (draft) { + leaf.view.containerEl.classList.add(LONGFORM_LEAF_CLASS); + } else { + leaf.view.containerEl.classList.remove(LONGFORM_LEAF_CLASS); + } + } + + // @ts-ignore + const leafId = leaf.id; + if (leafId) { + leaf.view.containerEl.dataset.leafId = leafId; + } + }); + } +} diff --git a/src/view/explorer/scene-menu-items.ts b/src/view/explorer/scene-menu-items.ts index d22f302..ab695fd 100644 --- a/src/view/explorer/scene-menu-items.ts +++ b/src/view/explorer/scene-menu-items.ts @@ -2,89 +2,88 @@ import { drafts, selectedDraft } from "src/model/stores"; import type { MultipleSceneDraft } from "src/model/types"; import { get } from "svelte/store"; - const getSelectedDraftWithIndex = () => { - const draft = get(selectedDraft) as MultipleSceneDraft; - if (!draft) { - return { index: -1, draft } - } - const index = get(drafts).findIndex( - (d) => d.vaultPath === draft.vaultPath - ); - return { index, draft } -} + const draft = get(selectedDraft) as MultipleSceneDraft; + if (!draft) { + return { index: -1, draft }; + } + const index = get(drafts).findIndex((d) => d.vaultPath === draft.vaultPath); + return { index, draft }; +}; export const addScene = (fileName: string) => { - const { index, draft } = getSelectedDraftWithIndex() - if (!draft) { - return; - } - if (index >= 0 && draft.format === "scenes") { - drafts.update((d) => { - const targetDraft = d[index] as MultipleSceneDraft; - (d[index] as MultipleSceneDraft).scenes = [ - ...targetDraft.scenes, - { title: fileName, indent: 0 }, - ]; - (d[index] as MultipleSceneDraft).unknownFiles = - targetDraft.unknownFiles.filter((f) => f !== fileName); - return d; - }); - } -} + const { index, draft } = getSelectedDraftWithIndex(); + if (!draft) { + return; + } + if (index >= 0 && draft.format === "scenes") { + drafts.update((d) => { + const targetDraft = d[index] as MultipleSceneDraft; + (d[index] as MultipleSceneDraft).scenes = [ + ...targetDraft.scenes, + { title: fileName, indent: 0 }, + ]; + (d[index] as MultipleSceneDraft).unknownFiles = + targetDraft.unknownFiles.filter((f) => f !== fileName); + return d; + }); + } +}; export const ignoreScene = (fileName: string) => { - const { index, draft } = getSelectedDraftWithIndex() - if (!draft) { - return; - } - if (index >= 0 && draft.format === "scenes") { - drafts.update((d) => { - const targetDraft = d[index] as MultipleSceneDraft; - (d[index] as MultipleSceneDraft).scenes = targetDraft.scenes.filter(it => it.title != fileName); - (d[index] as MultipleSceneDraft).ignoredFiles = [ - ...targetDraft.ignoredFiles, - fileName, - ]; - (d[index] as MultipleSceneDraft).unknownFiles = - targetDraft.unknownFiles.filter((f) => f !== fileName); - return d; - }); - } -} + const { index, draft } = getSelectedDraftWithIndex(); + if (!draft) { + return; + } + if (index >= 0 && draft.format === "scenes") { + drafts.update((d) => { + const targetDraft = d[index] as MultipleSceneDraft; + (d[index] as MultipleSceneDraft).scenes = targetDraft.scenes.filter( + (it) => it.title != fileName + ); + (d[index] as MultipleSceneDraft).ignoredFiles = [ + ...targetDraft.ignoredFiles, + fileName, + ]; + (d[index] as MultipleSceneDraft).unknownFiles = + targetDraft.unknownFiles.filter((f) => f !== fileName); + return d; + }); + } +}; export const addAll = () => { - const { index, draft } = getSelectedDraftWithIndex() - if (!draft) { - return; - } - if (index >= 0 && draft.format === "scenes") { - drafts.update((d) => { - const targetDraft = d[index] as MultipleSceneDraft; - (d[index] as MultipleSceneDraft).scenes = [ - ...targetDraft.scenes, - ...targetDraft.unknownFiles.map((f) => ({ title: f, indent: 0 })), - ]; - (d[index] as MultipleSceneDraft).unknownFiles = []; - return d; - }); - } -} + const { index, draft } = getSelectedDraftWithIndex(); + if (!draft) { + return; + } + if (index >= 0 && draft.format === "scenes") { + drafts.update((d) => { + const targetDraft = d[index] as MultipleSceneDraft; + (d[index] as MultipleSceneDraft).scenes = [ + ...targetDraft.scenes, + ...targetDraft.unknownFiles.map((f) => ({ title: f, indent: 0 })), + ]; + (d[index] as MultipleSceneDraft).unknownFiles = []; + return d; + }); + } +}; export const ignoreAll = () => { - const { index, draft } = getSelectedDraftWithIndex() - if (!draft) { - return; - } - if (index >= 0 && draft.format === "scenes") { - drafts.update((d) => { - const targetDraft = d[index] as MultipleSceneDraft; - (d[index] as MultipleSceneDraft).ignoredFiles = [ - ...targetDraft.ignoredFiles, - ...targetDraft.unknownFiles, - ]; - (d[index] as MultipleSceneDraft).unknownFiles = []; - return d; - }); - } -} + const { index, draft } = getSelectedDraftWithIndex(); + if (!draft) { + return; + } + if (index >= 0 && draft.format === "scenes") { + drafts.update((d) => { + const targetDraft = d[index] as MultipleSceneDraft; + (d[index] as MultipleSceneDraft).ignoredFiles = [ + ...targetDraft.ignoredFiles, + ...targetDraft.unknownFiles, + ]; + (d[index] as MultipleSceneDraft).unknownFiles = []; + return d; + }); + } +}; diff --git a/styles.css b/styles.css index c2fd54e..25d27b7 100644 --- a/styles.css +++ b/styles.css @@ -1,19 +1,19 @@ -:root { - --longform-explorer-font-size: var(--font-ui-medium); - --longform-explorer-indent-size: 2em; -} - -.longform-settings-user-steps { - padding-inline-start: 1em; - margin-block-start: 0; - margin-block-end: 0; -} - -.longform-settings-user-step-name { - color: var(--text-normal); -} - -.longform-settings-user-step-id { - margin-left: var(--size-4-2); - color: var(--text-muted); -} +:root { + --longform-explorer-font-size: var(--font-ui-medium); + --longform-explorer-indent-size: 2em; +} + +.longform-settings-user-steps { + padding-inline-start: 1em; + margin-block-start: 0; + margin-block-end: 0; +} + +.longform-settings-user-step-name { + color: var(--text-normal); +} + +.longform-settings-user-step-id { + margin-left: var(--size-4-2); + color: var(--text-muted); +} diff --git a/tsconfig.json b/tsconfig.json index 47a1959..9e43be2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,22 +1,17 @@ -{ - "extends": "@tsconfig/svelte/tsconfig.json", - "include": ["src/**/*", "test/**/*"], - "exclude": ["node_modules/*"], - "compilerOptions": { - "baseUrl": ".", - "types": ["node", "svelte"], - "sourceMap": true, - "module": "ESNext", - "target": "es6", - "allowJs": true, - "noImplicitAny": true, - "moduleResolution": "node", - "importHelpers": true, - "lib": [ - "dom", - "es5", - "scripthost", - "es2015" - ] - } -} +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules/*"], + "compilerOptions": { + "baseUrl": ".", + "types": ["node", "svelte"], + "sourceMap": true, + "module": "ESNext", + "target": "es6", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "lib": ["dom", "es5", "scripthost", "es2015"] + } +} diff --git a/versions.json b/versions.json index 64a6350..a1d8b3c 100644 --- a/versions.json +++ b/versions.json @@ -1,15 +1,15 @@ -{ - "2.0.7": "1.3.5", - "2.0.6": "1.3.5", - "2.0.5": "1.1.9", - "2.0.4": "1.1.9", - "2.0.3": "1.1.9", - "2.0.2": "1.0", - "2.0.1": "1.0", - "2.0.0": "1.0", - "1.1.0": "0.12.11", - "1.0.3": "0.12.11", - "1.0.2": "0.12.11", - "1.0.1": "0.12.0", - "1.0.0": "0.12.0" -} +{ + "2.0.7": "1.3.5", + "2.0.6": "1.3.5", + "2.0.5": "1.1.9", + "2.0.4": "1.1.9", + "2.0.3": "1.1.9", + "2.0.2": "1.0", + "2.0.1": "1.0", + "2.0.0": "1.0", + "1.1.0": "0.12.11", + "1.0.3": "0.12.11", + "1.0.2": "0.12.11", + "1.0.1": "0.12.0", + "1.0.0": "0.12.0" +}