From cd185ee6e4ac9d00505b3d75c17a7bdf08cbda1d Mon Sep 17 00:00:00 2001 From: revett <2796074+revett@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:47:11 +0100 Subject: [PATCH 1/2] Structure files --- .agents/skills/typescript-as-go/SKILL.md | 28 ++- skills-lock.json | 2 +- src/log/adapter.ts | 10 +- src/log/log.ts | 140 ++++++------ src/log/view.ts | 16 +- src/main.ts | 12 +- src/settings/settings.test.ts | 30 +-- src/settings/settings.ts | 102 ++++----- src/settings/tab.ts | 258 +++++++++++------------ src/storage/storage.ts | 148 ++++++------- src/storage/xml.ts | 76 +++---- src/sync/execute.ts | 50 ++--- src/sync/fake.ts | 60 +++--- src/sync/plan.ts | 32 +-- src/vault/fs.ts | 48 ++--- src/vault/obsidian.ts | 70 +++--- src/vault/vault.ts | 146 ++++++------- 17 files changed, 626 insertions(+), 602 deletions(-) diff --git a/.agents/skills/typescript-as-go/SKILL.md b/.agents/skills/typescript-as-go/SKILL.md index 93d4963..64d1430 100644 --- a/.agents/skills/typescript-as-go/SKILL.md +++ b/.agents/skills/typescript-as-go/SKILL.md @@ -6,7 +6,7 @@ compatibility: Designed for Claude Code (or similar products) metadata: author: revett repo: https://github.com/revett/typescript-as-go - version: 0.4.0 + version: 0.5.0 --- # TypeScript As Go @@ -20,7 +20,7 @@ to read and review, not clever to write. No magic. ## Rules As an agent working in this project, you must follow the following allowed and banned rules when -writing Typescript. +writing Typescript, as well as the strict file layout rules. ### Allowed @@ -99,3 +99,27 @@ writing Typescript. 15. No chained array pipelines (`.filter().map()`, `.map().filter()`) and no `.reduce`; Go has no map or filter, so write an explicit `for...of` that guards with `continue` and pushes into a result, one readable loop over a pipeline (a single `.map` on an already clean list is fine) + +### File Layout + +Every file declares its symbols in the same order, so a reader always knows where to look and a diff +never argues about placement. Sort by kind first, then by export status within a kind, then +alphabetically (case insensitive) within that: + +1. Imports, ordered by the formatter +2. Exported constants, A-Z +3. Constants, A-Z +4. Exported types, A-Z +5. Types, A-Z +6. Exported functions, A-Z +7. Functions, A-Z +8. The framework class shell and its default export, last, since it is glue rather than logic + +Notes: + +- Alphabetical is mechanical on purpose; there is no judgement call to make and no debate to have +- A `DEFAULT_X` constant will therefore sit above the type it is annotated with; this is fine, + types hoist, and predictable placement is worth more than local reading order +- Test files group cases by the symbol under test, following the same order the source file declares + them; within one symbol's cases the happy path comes first, then the guards, failures, and edge + cases, never alphabetically diff --git a/skills-lock.json b/skills-lock.json index a392063..47cb832 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -5,7 +5,7 @@ "source": "revett/typescript-as-go", "sourceType": "github", "skillPath": "skills/typescript-as-go/SKILL.md", - "computedHash": "4b0f12431264d1d9662cfd3a4400f33bda79e818bbaa03dfdf6066dab231a4cb" + "computedHash": "2e4a96809e263b52af645f9461649980aa8ef7186b760d5e254378e012527ff5" } } } diff --git a/src/log/adapter.ts b/src/log/adapter.ts index 0dfcc7a..4e1d035 100644 --- a/src/log/adapter.ts +++ b/src/log/adapter.ts @@ -8,6 +8,11 @@ import { trimLogLines, } from "./log.ts"; +// COMPACT_INTERVAL is how many appends accumulate before the log file is trimmed back down to +// maxLines. Appending is cheap (DataAdapter.append); a full read/trim/write is not, so +// compaction runs in batches rather than after every single line. +const COMPACT_INTERVAL = 50; + // createLogSink returns the real file backed sink, or an in-memory fallback when dir is // undefined (some embedded/test hosts never set manifest.dir). export function createLogSink( @@ -21,11 +26,6 @@ export function createLogSink( return createObsidianLogSink(adapter, `${dir}/geode.log`, maxLines); } -// COMPACT_INTERVAL is how many appends accumulate before the log file is trimmed back down to -// maxLines. Appending is cheap (DataAdapter.append); a full read/trim/write is not, so -// compaction runs in batches rather than after every single line. -const COMPACT_INTERVAL = 50; - // createObsidianLogSink returns a LogSink that persists to a capped file at logPath via the vault // adapter, appending cheaply and periodically trimming back down to maxLines so the file can't // grow unbounded over a long running session. diff --git a/src/log/log.ts b/src/log/log.ts index e5bde7b..07e47fb 100644 --- a/src/log/log.ts +++ b/src/log/log.ts @@ -1,5 +1,4 @@ -// LogLevel orders from least to most severe. -export type LogLevel = "debug" | "info" | "warn" | "error"; +const LEVEL_ORDER: Record = { debug: 0, info: 1, warn: 2, error: 3 }; // LogEntry is one line of geode's log. export type LogEntry = { @@ -8,6 +7,17 @@ export type LogEntry = { message: string; }; +// Logger is the interface the rest of the plugin logs through. +export type Logger = { + debug: (message: string) => void; + info: (message: string) => void; + warn: (message: string) => void; + error: (message: string) => void; +}; + +// LogLevel orders from least to most severe. +export type LogLevel = "debug" | "info" | "warn" | "error"; + // LogSink persists log entries and reads them back. The real implementation writes to a capped // file inside the plugin's own data directory (see adapter.ts); tests, and the fallback used // when there's nowhere to persist to, use an in-memory sink (see createMemorySink below). @@ -17,23 +27,47 @@ export type LogSink = { clear: () => Promise; }; -// Logger is the interface the rest of the plugin logs through. -export type Logger = { - debug: (message: string) => void; - info: (message: string) => void; - warn: (message: string) => void; - error: (message: string) => void; -}; - -const LEVEL_ORDER: Record = { debug: 0, info: 1, warn: 2, error: 3 }; +// createLogger returns a Logger that mirrors every message to the console and persists it via +// sink, both gated by the same minLevel. +export function createLogger(sink: LogSink, minLevel: LogLevel): Logger { + const log = (level: LogLevel, message: string) => { + if (!levelEnabled(level, minLevel)) { + return; + } + consoleFor(level)(`geode: ${message}`); + // Fire and forget: the Logger API is synchronous, so append can't be awaited. Report a + // failed persist to the console rather than leaving it as an unhandled rejection, and never + // back into sink, which would recurse if the sink itself is what's failing. + sink.append({ time: Date.now(), level, message }).catch((err) => { + console.error(`geode: log sink append failed: ${err}`); + }); + }; -// levelEnabled reports whether a message at level should be logged when the minimum is minLevel. -export function levelEnabled(level: LogLevel, minLevel: LogLevel): boolean { - return LEVEL_ORDER[level] >= LEVEL_ORDER[minLevel]; + return { + debug: (message) => log("debug", message), + info: (message) => log("info", message), + warn: (message) => log("warn", message), + error: (message) => log("error", message), + }; } -function isLogLevel(value: string): value is LogLevel { - return value === "debug" || value === "info" || value === "warn" || value === "error"; +// createMemorySink returns a LogSink backed by an in-memory array, capped at maxLines. Used as +// the fallback logger when there's nowhere to persist to, and as the fake sink in tests. +export function createMemorySink(maxLines: number): LogSink { + let entries: LogEntry[] = []; + + return { + append: async (entry) => { + entries.push(entry); + if (entries.length > maxLines) { + entries = entries.slice(entries.length - maxLines); + } + }, + read: async () => [...entries], + clear: async () => { + entries = []; + }, + }; } // formatLogLine renders one entry as a single persisted line: an ISO timestamp, the level, then @@ -43,6 +77,11 @@ export function formatLogLine(entry: LogEntry): string { return `${new Date(entry.time).toISOString()}\t${entry.level}\t${entry.message}`; } +// levelEnabled reports whether a message at level should be logged when the minimum is minLevel. +export function levelEnabled(level: LogLevel, minLevel: LogLevel): boolean { + return LEVEL_ORDER[level] >= LEVEL_ORDER[minLevel]; +} + // parseLogLine reverses formatLogLine. A malformed line (a corrupt or truncated file) is dropped // rather than thrown, consistent with how state.json failures fail open elsewhere. export function parseLogLine(line: string): LogEntry | undefined { @@ -57,20 +96,6 @@ export function parseLogLine(line: string): LogEntry | undefined { return { time, level: rawLevel, message: rest.join("\t") }; } -// linesOf splits a log's text into its lines. Every persisted line ends in "\n", so a naive split -// leaves one trailing empty element; drop only that one rather than filtering every blank line -// generically, so a line that was genuinely empty for some other reason is never silently eaten. -function linesOf(text: string): string[] { - if (text === "") { - return []; - } - const parts = text.split("\n"); - if (parts[parts.length - 1] === "") { - return parts.slice(0, -1); - } - return parts; -} - // trimLogLines keeps only the last maxLines lines of a log, dropping the oldest. The result keeps // the same trailing newline the input had: appending assumes the file already ends in one, and // dropping it here would glue the next appended line onto the last one still kept. @@ -86,25 +111,6 @@ export function trimLogLines(text: string, maxLines: number): string { return `${kept.join("\n")}\n`; } -// createMemorySink returns a LogSink backed by an in-memory array, capped at maxLines. Used as -// the fallback logger when there's nowhere to persist to, and as the fake sink in tests. -export function createMemorySink(maxLines: number): LogSink { - let entries: LogEntry[] = []; - - return { - append: async (entry) => { - entries.push(entry); - if (entries.length > maxLines) { - entries = entries.slice(entries.length - maxLines); - } - }, - read: async () => [...entries], - clear: async () => { - entries = []; - }, - }; -} - // consoleFor returns the console method matching level, so console and persisted output agree on // severity. function consoleFor(level: LogLevel): (message: string) => void { @@ -117,26 +123,20 @@ function consoleFor(level: LogLevel): (message: string) => void { return (message) => console.log(message); } -// createLogger returns a Logger that mirrors every message to the console and persists it via -// sink, both gated by the same minLevel. -export function createLogger(sink: LogSink, minLevel: LogLevel): Logger { - const log = (level: LogLevel, message: string) => { - if (!levelEnabled(level, minLevel)) { - return; - } - consoleFor(level)(`geode: ${message}`); - // Fire and forget: the Logger API is synchronous, so append can't be awaited. Report a - // failed persist to the console rather than leaving it as an unhandled rejection, and never - // back into sink, which would recurse if the sink itself is what's failing. - sink.append({ time: Date.now(), level, message }).catch((err) => { - console.error(`geode: log sink append failed: ${err}`); - }); - }; +function isLogLevel(value: string): value is LogLevel { + return value === "debug" || value === "info" || value === "warn" || value === "error"; +} - return { - debug: (message) => log("debug", message), - info: (message) => log("info", message), - warn: (message) => log("warn", message), - error: (message) => log("error", message), - }; +// linesOf splits a log's text into its lines. Every persisted line ends in "\n", so a naive split +// leaves one trailing empty element; drop only that one rather than filtering every blank line +// generically, so a line that was genuinely empty for some other reason is never silently eaten. +function linesOf(text: string): string[] { + if (text === "") { + return []; + } + const parts = text.split("\n"); + if (parts[parts.length - 1] === "") { + return parts.slice(0, -1); + } + return parts; } diff --git a/src/log/view.ts b/src/log/view.ts index 004e668..c59605e 100644 --- a/src/log/view.ts +++ b/src/log/view.ts @@ -8,14 +8,6 @@ export const LOG_VIEW_TYPE = "geode-log-view"; // pane is sitting open still show up without a manual refresh. const POLL_INTERVAL_MS = 2000; -// renderRow draws one entry into list, colour coded by level via a geode-log-row.is- class. -function renderRow(list: HTMLElement, entry: LogEntry): void { - const row = list.createDiv({ cls: `geode-log-row is-${entry.level}` }); - row.createSpan({ cls: "geode-log-time", text: new Date(entry.time).toLocaleString() }); - row.createSpan({ cls: "geode-log-level", text: entry.level.toUpperCase() }); - row.createSpan({ cls: "geode-log-message", text: entry.message }); -} - // renderLogView draws entries into containerEl, most recent first. function renderLogView(containerEl: HTMLElement, entries: LogEntry[]): void { containerEl.empty(); @@ -32,6 +24,14 @@ function renderLogView(containerEl: HTMLElement, entries: LogEntry[]): void { } } +// renderRow draws one entry into list, colour coded by level via a geode-log-row.is- class. +function renderRow(list: HTMLElement, entry: LogEntry): void { + const row = list.createDiv({ cls: `geode-log-row is-${entry.level}` }); + row.createSpan({ cls: "geode-log-time", text: new Date(entry.time).toLocaleString() }); + row.createSpan({ cls: "geode-log-level", text: entry.level.toUpperCase() }); + row.createSpan({ cls: "geode-log-message", text: entry.message }); +} + // GeodeLogView renders geode's persisted log as a plain, most recent first list. Read only: it // has no way to write log entries, only display what the sink already recorded. export class GeodeLogView extends ItemView { diff --git a/src/main.ts b/src/main.ts index 1e50f83..4fdf6b8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,17 +19,17 @@ import { } from "./vault/obsidian"; import { diffSnapshots, takeSnapshot } from "./vault/vault"; -// VAULT_STATE_DEBOUNCE_MS delays a vault state refresh after the last file event, so a burst of -// edits (autosave, bulk rename, etc.) collapses into one snapshot instead of one per file. -const VAULT_STATE_DEBOUNCE_MS = 2000; +// LOG_MIN_LEVEL is fixed rather than user configurable: there's no meaningful "quiet" mode to +// offer today, so a verbosity setting would be a toggle with no observable effect. +const LOG_MIN_LEVEL = "debug"; // MAX_LOG_LINES caps how many lines geode.log keeps on disk, so a long running session can't // grow it unbounded. const MAX_LOG_LINES = 500; -// LOG_MIN_LEVEL is fixed rather than user configurable: there's no meaningful "quiet" mode to -// offer today, so a verbosity setting would be a toggle with no observable effect. -const LOG_MIN_LEVEL = "debug"; +// VAULT_STATE_DEBOUNCE_MS delays a vault state refresh after the last file event, so a burst of +// edits (autosave, bulk rename, etc.) collapses into one snapshot instead of one per file. +const VAULT_STATE_DEBOUNCE_MS = 2000; // AppWithSetting adds Obsidian's internal, undocumented settings-window API (there is no public // equivalent) so the Settings command can jump straight to Geode's tab, and opening the log view diff --git a/src/settings/settings.test.ts b/src/settings/settings.test.ts index 452609f..a7e496c 100644 --- a/src/settings/settings.test.ts +++ b/src/settings/settings.test.ts @@ -12,6 +12,11 @@ import { } from "./settings.ts"; const normalizeCases: { name: string; input: unknown; want: GeodeSettings }[] = [ + { + name: "partial legacy object", + input: { bucket: "my-bucket", accessKeyId: "AKIA123" }, + want: { ...DEFAULT_SETTINGS, bucket: "my-bucket", accessKeyId: "AKIA123" }, + }, { name: "null", input: null, @@ -27,11 +32,6 @@ const normalizeCases: { name: string; input: unknown; want: GeodeSettings }[] = input: {}, want: DEFAULT_SETTINGS, }, - { - name: "partial legacy object", - input: { bucket: "my-bucket", accessKeyId: "AKIA123" }, - want: { ...DEFAULT_SETTINGS, bucket: "my-bucket", accessKeyId: "AKIA123" }, - }, { name: "junk types in string fields", input: { accountId: 42, endpoint: true, region: [1, 2], bucket: null, accessKeyId: {} }, @@ -167,16 +167,6 @@ for (const { name, a, b, want } of settingsEqualCases) { } const hasConnectionConfigCases: { name: string; input: GeodeSettings; want: boolean }[] = [ - { - name: "empty settings are incomplete", - input: DEFAULT_SETTINGS, - want: false, - }, - { - name: "r2 missing account ID is incomplete", - input: { ...DEFAULT_SETTINGS, bucket: "b", accessKeyId: "a", secretId: "s" }, - want: false, - }, { name: "r2 with all fields is complete", input: { @@ -188,6 +178,16 @@ const hasConnectionConfigCases: { name: string; input: GeodeSettings; want: bool }, want: true, }, + { + name: "empty settings are incomplete", + input: DEFAULT_SETTINGS, + want: false, + }, + { + name: "r2 missing account ID is incomplete", + input: { ...DEFAULT_SETTINGS, bucket: "b", accessKeyId: "a", secretId: "s" }, + want: false, + }, { name: "custom missing region is incomplete", input: { diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 7fff142..5309083 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -1,3 +1,15 @@ +// DEFAULT_SETTINGS is the complete zero value used before any user configuration is loaded. +export const DEFAULT_SETTINGS: GeodeSettings = { + version: 1, + provider: "r2", + accountId: "", + endpoint: "", + region: "", + bucket: "", + accessKeyId: "", + secretId: "", +}; + // GeodeSettings is the persisted shape of a Geode plugin's user configuration. export type GeodeSettings = { version: number; @@ -14,32 +26,40 @@ export type GeodeSettings = { secretId: string; }; -// DEFAULT_SETTINGS is the complete zero value used before any user configuration is loaded. -export const DEFAULT_SETTINGS: GeodeSettings = { - version: 1, - provider: "r2", - accountId: "", - endpoint: "", - region: "", - bucket: "", - accessKeyId: "", - secretId: "", -}; +// draftForDisplay returns the draft a settings tab should show for a given render. +// When auto is true (Obsidian is opening the tab), the draft is re-seeded from saved +// settings so an external data.json update cannot leave a stale draft and phantom +// "Unsaved changes". When auto is false (an internal re-render such as a provider +// switch), the in-progress draft is kept. +export function draftForDisplay( + auto: boolean, + currentDraft: GeodeSettings, + savedSettings: GeodeSettings, +): GeodeSettings { + if (auto) { + return { ...savedSettings }; + } + return currentDraft; +} -// stringOr returns v if it is a string, otherwise fallback. -function stringOr(v: unknown, fallback: string): string { - if (typeof v === "string") { - return v; +// endpointFor returns the storage endpoint URL to use for the given settings. +export function endpointFor(settings: GeodeSettings): string { + if (settings.provider === "r2") { + return `https://${settings.accountId}.r2.cloudflarestorage.com`; } - return fallback; + + return settings.endpoint; } -// providerOr returns "custom" if v is "custom", otherwise "r2". -export function providerOr(v: unknown): "r2" | "custom" { - if (v === "custom") { - return "custom"; +// hasConnectionConfig reports whether settings have enough filled in to attempt a connection. +export function hasConnectionConfig(settings: GeodeSettings): boolean { + if (settings.bucket === "" || settings.accessKeyId === "" || settings.secretId === "") { + return false; } - return "r2"; + if (settings.provider === "r2") { + return settings.accountId !== ""; + } + return settings.endpoint !== "" && settings.region !== ""; } // normalizeSettings returns a complete GeodeSettings from whatever loadData produced, @@ -63,13 +83,12 @@ export function normalizeSettings(raw: unknown): GeodeSettings { }; } -// endpointFor returns the storage endpoint URL to use for the given settings. -export function endpointFor(settings: GeodeSettings): string { - if (settings.provider === "r2") { - return `https://${settings.accountId}.r2.cloudflarestorage.com`; +// providerOr returns "custom" if v is "custom", otherwise "r2". +export function providerOr(v: unknown): "r2" | "custom" { + if (v === "custom") { + return "custom"; } - - return settings.endpoint; + return "r2"; } // regionFor returns the signing region to use for the given settings. R2 always signs with @@ -97,29 +116,10 @@ export function settingsEqual(a: GeodeSettings, b: GeodeSettings): boolean { ); } -// hasConnectionConfig reports whether settings have enough filled in to attempt a connection. -export function hasConnectionConfig(settings: GeodeSettings): boolean { - if (settings.bucket === "" || settings.accessKeyId === "" || settings.secretId === "") { - return false; - } - if (settings.provider === "r2") { - return settings.accountId !== ""; - } - return settings.endpoint !== "" && settings.region !== ""; -} - -// draftForDisplay returns the draft a settings tab should show for a given render. -// When auto is true (Obsidian is opening the tab), the draft is re-seeded from saved -// settings so an external data.json update cannot leave a stale draft and phantom -// "Unsaved changes". When auto is false (an internal re-render such as a provider -// switch), the in-progress draft is kept. -export function draftForDisplay( - auto: boolean, - currentDraft: GeodeSettings, - savedSettings: GeodeSettings, -): GeodeSettings { - if (auto) { - return { ...savedSettings }; +// stringOr returns v if it is a string, otherwise fallback. +function stringOr(v: unknown, fallback: string): string { + if (typeof v === "string") { + return v; } - return currentDraft; + return fallback; } diff --git a/src/settings/tab.ts b/src/settings/tab.ts index 0172fdb..e8d2bb2 100644 --- a/src/settings/tab.ts +++ b/src/settings/tab.ts @@ -17,10 +17,139 @@ import { settingsEqual, } from "./settings"; +// DEBUG_LABEL_WIDTH is the column width debug info labels are padded to, so values line up. +const DEBUG_LABEL_WIDTH = 12; + // ConnectionStatus is the last known state of a Test Connection check. It lives only in memory; // it is never persisted and resets to "unknown" whenever the draft changes. type ConnectionStatus = "unknown" | "checking" | "ok" | "error"; +// renderSettingsTab draws every section into containerEl from the tab's current draft state. +export function renderSettingsTab(tab: GeodeSettingTab, containerEl: HTMLElement): void { + renderHeader(containerEl); + renderStorageSection(tab, containerEl); + renderSupportSection(tab, containerEl); +} + +// connectionMessageFor returns the connection half of the status line for the given state. +function connectionMessageFor(tab: GeodeSettingTab): string { + if (tab.connectionStatus === "checking") { + return "Checking connection..."; + } + if (tab.connectionStatus === "ok") { + return "Connected"; + } + if (tab.connectionStatus === "error") { + return tab.connectionMessage; + } + return "Not tested yet"; +} + +// connectionSummary returns a one line description of the last connection test, for debug info. +// Blank when nothing has been tested yet, there's nothing worth reporting. +function connectionSummary(tab: GeodeSettingTab): string { + if (tab.connectionStatus === "error") { + return `error - ${tab.connectionMessage}`; + } + if (tab.connectionStatus === "unknown") { + return ""; + } + return tab.connectionStatus; +} + +// debugInfoText builds the plain text block a user pastes into a support email or issue. +function debugInfoText(tab: GeodeSettingTab): string { + return [ + debugLine("Geode:", `v${tab.plugin.manifest.version}`), + debugLine("Obsidian:", `v${apiVersion}`), + debugLine("Platform:", platformLabel()), + debugLine("Provider:", tab.plugin.settings.provider), + debugLine("Connection:", connectionSummary(tab)), + ].join("\n"); +} + +// debugLine formats one "label: value" row of debug info, padded so values align in a column. +function debugLine(label: string, value: string): string { + return `${label.padEnd(DEBUG_LABEL_WIDTH)}${value}`; +} + +// flashButtonText sets a button's text to feedback, then reverts it to original after a delay. +function flashButtonText(button: ButtonComponent, original: string, feedback: string): void { + button.setButtonText(feedback); + window.setTimeout(() => button.setButtonText(original), 1500); +} + +// onFieldChanged clears any stale connection status and updates the actions row without +// redrawing the whole tab, so text inputs never lose focus mid keystroke. Whether the draft +// counts as dirty is derived by comparing it to saved settings, not tracked here. +function onFieldChanged(tab: GeodeSettingTab): void { + tab.connectionStatus = "unknown"; + tab.connectionMessage = ""; + tab.refreshActionsUI(); +} + +// platformLabel returns a short human readable name for the OS Obsidian is running on. +function platformLabel(): string { + if (Platform.isMacOS) { + return "macOS"; + } + if (Platform.isWin) { + return "Windows"; + } + if (Platform.isLinux) { + return "Linux"; + } + if (Platform.isIosApp) { + return "iOS"; + } + if (Platform.isAndroidApp) { + return "Android"; + } + return "Unknown"; +} + +// renderActions draws the status dot, status line, Test Connection button, and Save button, and +// stashes references on tab so later field edits can update them in place. The connection +// message and the dirty reminder are separate spans on one line so each keeps its own colour. +function renderActions(tab: GeodeSettingTab, containerEl: HTMLElement): void { + const setting = new Setting(containerEl).setName("Connection"); + + tab.statusDotEl = setting.nameEl.createSpan({ cls: "geode-status-dot" }); + setting.nameEl.prepend(tab.statusDotEl); + + const statusLine = setting.descEl.createSpan({ cls: "geode-status-line" }); + tab.connectionMessageEl = statusLine.createSpan({ cls: "geode-connection-message" }); + tab.statusSeparatorEl = statusLine.createSpan({ + cls: "geode-status-separator", + text: " · ", + }); + tab.dirtyTextEl = statusLine.createSpan({ + cls: "geode-dirty-text", + text: "Unsaved changes", + }); + + setting.addButton((button) => + button.setButtonText("Test").onClick(async () => { + await tab.checkConnection(); + }), + ); + + setting.addButton((button) => { + tab.saveButtonEl = button; + // Obsidian's own disabled-button styling forces cursor: not-allowed with its own + // !important rule; an inline style is the only thing guaranteed to win over that. + button.buttonEl.style.setProperty("cursor", "pointer", "important"); + button + .setButtonText("Save") + .setCta() + .onClick(async () => { + await tab.save(); + }); + }); + + tab.refreshActionsUI(); +} + // renderHeader draws the plugin title, subtitle, and external link buttons. The title is a plain // div rather than an h1: Obsidian's settings pane suppresses nested h1 elements. function renderHeader(containerEl: HTMLElement): void { @@ -51,15 +180,6 @@ function renderHeader(containerEl: HTMLElement): void { }); } -// onFieldChanged clears any stale connection status and updates the actions row without -// redrawing the whole tab, so text inputs never lose focus mid keystroke. Whether the draft -// counts as dirty is derived by comparing it to saved settings, not tracked here. -function onFieldChanged(tab: GeodeSettingTab): void { - tab.connectionStatus = "unknown"; - tab.connectionMessage = ""; - tab.refreshActionsUI(); -} - // renderProviderFields draws the fields specific to the selected provider. function renderProviderFields(tab: GeodeSettingTab, containerEl: HTMLElement): void { if (tab.draft.provider === "r2") { @@ -131,62 +251,6 @@ function renderSecretRow(tab: GeodeSettingTab, containerEl: HTMLElement): void { }); } -// connectionMessageFor returns the connection half of the status line for the given state. -function connectionMessageFor(tab: GeodeSettingTab): string { - if (tab.connectionStatus === "checking") { - return "Checking connection..."; - } - if (tab.connectionStatus === "ok") { - return "Connected"; - } - if (tab.connectionStatus === "error") { - return tab.connectionMessage; - } - return "Not tested yet"; -} - -// renderActions draws the status dot, status line, Test Connection button, and Save button, and -// stashes references on tab so later field edits can update them in place. The connection -// message and the dirty reminder are separate spans on one line so each keeps its own colour. -function renderActions(tab: GeodeSettingTab, containerEl: HTMLElement): void { - const setting = new Setting(containerEl).setName("Connection"); - - tab.statusDotEl = setting.nameEl.createSpan({ cls: "geode-status-dot" }); - setting.nameEl.prepend(tab.statusDotEl); - - const statusLine = setting.descEl.createSpan({ cls: "geode-status-line" }); - tab.connectionMessageEl = statusLine.createSpan({ cls: "geode-connection-message" }); - tab.statusSeparatorEl = statusLine.createSpan({ - cls: "geode-status-separator", - text: " · ", - }); - tab.dirtyTextEl = statusLine.createSpan({ - cls: "geode-dirty-text", - text: "Unsaved changes", - }); - - setting.addButton((button) => - button.setButtonText("Test").onClick(async () => { - await tab.checkConnection(); - }), - ); - - setting.addButton((button) => { - tab.saveButtonEl = button; - // Obsidian's own disabled-button styling forces cursor: not-allowed with its own - // !important rule; an inline style is the only thing guaranteed to win over that. - button.buttonEl.style.setProperty("cursor", "pointer", "important"); - button - .setButtonText("Save") - .setCta() - .onClick(async () => { - await tab.save(); - }); - }); - - tab.refreshActionsUI(); -} - // renderStorageSection draws the card of storage related settings. function renderStorageSection(tab: GeodeSettingTab, containerEl: HTMLElement): void { const card = containerEl.createDiv({ cls: "geode-card" }); @@ -238,63 +302,6 @@ function renderStorageSection(tab: GeodeSettingTab, containerEl: HTMLElement): v renderActions(tab, card); } -// platformLabel returns a short human readable name for the OS Obsidian is running on. -function platformLabel(): string { - if (Platform.isMacOS) { - return "macOS"; - } - if (Platform.isWin) { - return "Windows"; - } - if (Platform.isLinux) { - return "Linux"; - } - if (Platform.isIosApp) { - return "iOS"; - } - if (Platform.isAndroidApp) { - return "Android"; - } - return "Unknown"; -} - -// connectionSummary returns a one line description of the last connection test, for debug info. -// Blank when nothing has been tested yet, there's nothing worth reporting. -function connectionSummary(tab: GeodeSettingTab): string { - if (tab.connectionStatus === "error") { - return `error - ${tab.connectionMessage}`; - } - if (tab.connectionStatus === "unknown") { - return ""; - } - return tab.connectionStatus; -} - -// DEBUG_LABEL_WIDTH is the column width debug info labels are padded to, so values line up. -const DEBUG_LABEL_WIDTH = 12; - -// debugLine formats one "label: value" row of debug info, padded so values align in a column. -function debugLine(label: string, value: string): string { - return `${label.padEnd(DEBUG_LABEL_WIDTH)}${value}`; -} - -// debugInfoText builds the plain text block a user pastes into a support email or issue. -function debugInfoText(tab: GeodeSettingTab): string { - return [ - debugLine("Geode:", `v${tab.plugin.manifest.version}`), - debugLine("Obsidian:", `v${apiVersion}`), - debugLine("Platform:", platformLabel()), - debugLine("Provider:", tab.plugin.settings.provider), - debugLine("Connection:", connectionSummary(tab)), - ].join("\n"); -} - -// flashButtonText sets a button's text to feedback, then reverts it to original after a delay. -function flashButtonText(button: ButtonComponent, original: string, feedback: string): void { - button.setButtonText(feedback); - window.setTimeout(() => button.setButtonText(original), 1500); -} - // renderSupportSection draws the Support heading and its card of docs, email, and debug info. function renderSupportSection(tab: GeodeSettingTab, containerEl: HTMLElement): void { const heading = new Setting(containerEl).setName("Support").setHeading(); @@ -353,13 +360,6 @@ function renderSupportSection(tab: GeodeSettingTab, containerEl: HTMLElement): v }); } -// renderSettingsTab draws every section into containerEl from the tab's current draft state. -export function renderSettingsTab(tab: GeodeSettingTab, containerEl: HTMLElement): void { - renderHeader(containerEl); - renderStorageSection(tab, containerEl); - renderSupportSection(tab, containerEl); -} - // GeodeSettingTab renders the settings UI from an in-memory draft and only writes to plugin // settings when the user clicks Save. export class GeodeSettingTab extends PluginSettingTab { diff --git a/src/storage/storage.ts b/src/storage/storage.ts index bd441ae..96fa4aa 100644 --- a/src/storage/storage.ts +++ b/src/storage/storage.ts @@ -4,10 +4,6 @@ import { encodeKey } from "./encode.ts"; import { messageFor, statusForHttp } from "./errors.ts"; import { parseListObjectsXml } from "./xml.ts"; -// ResultStatus classifies the outcome of a storage operation so callers can distinguish absent -// objects from transient failures without parsing the message string. -export type ResultStatus = "ok" | "not_found" | "auth" | "server" | "network"; - // ConnectionResult reports whether a storage provider accepted a test request. Message is the // empty string when ok is true. export type ConnectionResult = { @@ -16,8 +12,9 @@ export type ConnectionResult = { message: string; }; -// PutResult reports whether an object was written. Message is the empty string when ok is true. -export type PutResult = { +// DeleteResult reports whether an object was removed. Message is the empty string when ok is +// true. +export type DeleteResult = { ok: boolean; status: ResultStatus; message: string; @@ -31,12 +28,12 @@ export type GetResult = { body: Uint8Array | null; }; -// DeleteResult reports whether an object was removed. Message is the empty string when ok is -// true. -export type DeleteResult = { +// ListResult reports whether a bucket listing succeeded. Objects is empty when ok is false. +export type ListResult = { ok: boolean; status: ResultStatus; message: string; + objects: ObjectMeta[]; }; // ObjectMeta describes one object returned by a bucket listing. @@ -46,14 +43,17 @@ export type ObjectMeta = { lastModified: string; }; -// ListResult reports whether a bucket listing succeeded. Objects is empty when ok is false. -export type ListResult = { +// PutResult reports whether an object was written. Message is the empty string when ok is true. +export type PutResult = { ok: boolean; status: ResultStatus; message: string; - objects: ObjectMeta[]; }; +// ResultStatus classifies the outcome of a storage operation so callers can distinguish absent +// objects from transient failures without parsing the message string. +export type ResultStatus = "ok" | "not_found" | "auth" | "server" | "network"; + // StorageClient reads, writes, deletes, and lists objects in a bucket. Every method takes and // returns plain data, never provider credentials or settings, so a future WebDAV or Dropbox // client can satisfy this same shape without changing anything that depends on it. @@ -64,19 +64,22 @@ export type StorageClient = { listObjects: (prefix?: string) => Promise; }; -// missingFieldFor returns the name of the first field testConnection needs but doesn't have, or -// "" if everything required is present. -function missingFieldFor(settings: GeodeSettings, secretAccessKey: string): string { - if (settings.bucket === "") { - return "bucket"; - } - if (settings.accessKeyId === "") { - return "access key ID"; - } - if (secretAccessKey === "") { - return "secret access key"; - } - return ""; +// createS3Client returns a StorageClient backed by the S3 compatible endpoint in settings. +export function createS3Client(settings: GeodeSettings, secretAccessKey: string): StorageClient { + const client = new AwsClient({ + accessKeyId: settings.accessKeyId, + secretAccessKey, + region: regionFor(settings), + service: "s3", + }); + const baseUrl = `${endpointFor(settings)}/${settings.bucket}`; + + return { + putObject: (key, body) => s3PutObject(client, baseUrl, key, body), + getObject: (key) => s3GetObject(client, baseUrl, key), + deleteObject: (key) => s3DeleteObject(client, baseUrl, key), + listObjects: (prefix) => s3ListObjects(client, baseUrl, prefix), + }; } // testConnection sends a signed HEAD request for the configured bucket and reports whether the @@ -115,21 +118,30 @@ export async function testConnection( return { ok: true, status: "ok", message: "" }; } -// s3PutObject writes body to key, creating or overwriting it. -async function s3PutObject( +// missingFieldFor returns the name of the first field testConnection needs but doesn't have, or +// "" if everything required is present. +function missingFieldFor(settings: GeodeSettings, secretAccessKey: string): string { + if (settings.bucket === "") { + return "bucket"; + } + if (settings.accessKeyId === "") { + return "access key ID"; + } + if (secretAccessKey === "") { + return "secret access key"; + } + return ""; +} + +// s3DeleteObject removes key from the bucket. +async function s3DeleteObject( client: AwsClient, baseUrl: string, key: string, - body: Uint8Array, -): Promise { +): Promise { let response: Response; try { - // Uint8Array vs DOM's ArrayBufferView is a TS lib mismatch, - // not a real runtime issue; every JS engine accepts a Uint8Array as a fetch body. - response = await client.fetch(`${baseUrl}/${encodeKey(key)}`, { - method: "PUT", - body: body as BodyInit, - }); + response = await client.fetch(`${baseUrl}/${encodeKey(key)}`, { method: "DELETE" }); } catch (err) { return { ok: false, status: "network", message: messageFor(err) }; } @@ -138,7 +150,7 @@ async function s3PutObject( return { ok: false, status: statusForHttp(response.status), - message: `Storage rejected the write (${response.status})`, + message: `Storage rejected the delete (${response.status})`, }; } return { ok: true, status: "ok", message: "" }; @@ -165,29 +177,6 @@ async function s3GetObject(client: AwsClient, baseUrl: string, key: string): Pro return { ok: true, status: "ok", message: "", body: new Uint8Array(buffer) }; } -// s3DeleteObject removes key from the bucket. -async function s3DeleteObject( - client: AwsClient, - baseUrl: string, - key: string, -): Promise { - let response: Response; - try { - response = await client.fetch(`${baseUrl}/${encodeKey(key)}`, { method: "DELETE" }); - } catch (err) { - return { ok: false, status: "network", message: messageFor(err) }; - } - - if (!response.ok) { - return { - ok: false, - status: statusForHttp(response.status), - message: `Storage rejected the delete (${response.status})`, - }; - } - return { ok: true, status: "ok", message: "" }; -} - // s3ListObjects lists objects in the bucket, optionally restricted to a key prefix. S3 caps a // single response at 1,000 keys, so it follows NextContinuationToken until the listing is complete // and returns every key. Stopping early would make unlisted keys look like remote deletions. @@ -232,20 +221,31 @@ async function s3ListObjects( return { ok: true, status: "ok", message: "", objects }; } -// createS3Client returns a StorageClient backed by the S3 compatible endpoint in settings. -export function createS3Client(settings: GeodeSettings, secretAccessKey: string): StorageClient { - const client = new AwsClient({ - accessKeyId: settings.accessKeyId, - secretAccessKey, - region: regionFor(settings), - service: "s3", - }); - const baseUrl = `${endpointFor(settings)}/${settings.bucket}`; +// s3PutObject writes body to key, creating or overwriting it. +async function s3PutObject( + client: AwsClient, + baseUrl: string, + key: string, + body: Uint8Array, +): Promise { + let response: Response; + try { + // Uint8Array vs DOM's ArrayBufferView is a TS lib mismatch, + // not a real runtime issue; every JS engine accepts a Uint8Array as a fetch body. + response = await client.fetch(`${baseUrl}/${encodeKey(key)}`, { + method: "PUT", + body: body as BodyInit, + }); + } catch (err) { + return { ok: false, status: "network", message: messageFor(err) }; + } - return { - putObject: (key, body) => s3PutObject(client, baseUrl, key, body), - getObject: (key) => s3GetObject(client, baseUrl, key), - deleteObject: (key) => s3DeleteObject(client, baseUrl, key), - listObjects: (prefix) => s3ListObjects(client, baseUrl, prefix), - }; + if (!response.ok) { + return { + ok: false, + status: statusForHttp(response.status), + message: `Storage rejected the write (${response.status})`, + }; + } + return { ok: true, status: "ok", message: "" }; } diff --git a/src/storage/xml.ts b/src/storage/xml.ts index d522a4d..24decce 100644 --- a/src/storage/xml.ts +++ b/src/storage/xml.ts @@ -8,15 +8,37 @@ export type ListPage = { nextContinuationToken: string | undefined; }; -// fieldFrom returns the text content of the first ... found in an XML fragment, or -// "" if it isn't present. -function fieldFrom(block: string, tag: string): string { - const pattern = new RegExp(`<${tag}>([\\s\\S]*?)`); - const found = pattern.exec(block); - if (found === null) { - return ""; +// parseListObjectsXml extracts object keys, sizes, and last-modified timestamps from an S3 +// ListObjectsV2 XML response, along with the continuation token when the listing is truncated. +// Regex rather than a DOM parser: the schema is narrow and stable, and DOMParser isn't available +// outside a browser-like runtime, which would make this untestable under node:test. +export function parseListObjectsXml(xml: string): ListPage { + const objects: ObjectMeta[] = []; + const contentsPattern = /([\s\S]*?)<\/Contents>/g; + let match = contentsPattern.exec(xml); + + while (match !== null) { + const block = match[1]; + objects.push({ + key: decodeXmlText(fieldFrom(block, "Key")), + size: Number(fieldFrom(block, "Size")), + lastModified: fieldFrom(block, "LastModified"), + }); + match = contentsPattern.exec(xml); } - return found[1]; + + // A token is only meaningful when IsTruncated is true. Guarding on both avoids looping forever + // if a provider echoes a stale token on the final page. + const truncated = fieldFrom(xml, "IsTruncated") === "true"; + const token = decodeXmlText(fieldFrom(xml, "NextContinuationToken")); + let nextContinuationToken: string | undefined; + if (truncated && token !== "") { + nextContinuationToken = token; + } + return { + objects, + nextContinuationToken, + }; } // decodeXmlText returns the plain text represented by XML character and entity references. @@ -57,35 +79,13 @@ function decodeXmlText(text: string): string { ); } -// parseListObjectsXml extracts object keys, sizes, and last-modified timestamps from an S3 -// ListObjectsV2 XML response, along with the continuation token when the listing is truncated. -// Regex rather than a DOM parser: the schema is narrow and stable, and DOMParser isn't available -// outside a browser-like runtime, which would make this untestable under node:test. -export function parseListObjectsXml(xml: string): ListPage { - const objects: ObjectMeta[] = []; - const contentsPattern = /([\s\S]*?)<\/Contents>/g; - let match = contentsPattern.exec(xml); - - while (match !== null) { - const block = match[1]; - objects.push({ - key: decodeXmlText(fieldFrom(block, "Key")), - size: Number(fieldFrom(block, "Size")), - lastModified: fieldFrom(block, "LastModified"), - }); - match = contentsPattern.exec(xml); - } - - // A token is only meaningful when IsTruncated is true. Guarding on both avoids looping forever - // if a provider echoes a stale token on the final page. - const truncated = fieldFrom(xml, "IsTruncated") === "true"; - const token = decodeXmlText(fieldFrom(xml, "NextContinuationToken")); - let nextContinuationToken: string | undefined; - if (truncated && token !== "") { - nextContinuationToken = token; +// fieldFrom returns the text content of the first ... found in an XML fragment, or +// "" if it isn't present. +function fieldFrom(block: string, tag: string): string { + const pattern = new RegExp(`<${tag}>([\\s\\S]*?)`); + const found = pattern.exec(block); + if (found === null) { + return ""; } - return { - objects, - nextContinuationToken, - }; + return found[1]; } diff --git a/src/sync/execute.ts b/src/sync/execute.ts index c11095d..b7aaed8 100644 --- a/src/sync/execute.ts +++ b/src/sync/execute.ts @@ -16,31 +16,6 @@ export type SyncFailure = { message: string; }; -// localFailureMessage turns whatever a local vault operation threw into a SyncFailure message. -// readFile throws when a file vanishes between the snapshot and now (a user deleting it mid sync), -// and writeFile/deleteFile/renameFile can throw on a disk full or permission error; routing all of -// them through failures keeps executeSyncPlan's "errors are values" contract, so one bad local -// operation is a per file failure like any storage error, not an exception that abandons the rest -// of the pass. -function localFailureMessage(err: unknown): string { - if (err instanceof Error) { - return err.message; - } - return "local file operation failed"; -} - -// applyLocalWrite runs one localWriter mutation, converting a thrown I/O error into a SyncFailure -// so it lands in the same failures array every storage operation already uses. Returns null when -// the write succeeded. -async function applyLocalWrite(path: string, op: () => Promise): Promise { - try { - await op(); - return null; - } catch (err) { - return { path, message: localFailureMessage(err) }; - } -} - // executeSyncPlan carries out every action against reader/localWriter (the local vault) and // storage (the remote bucket), and reports whatever couldn't be completed. now is passed in // rather than read internally so a conflict's copy name is deterministic under test. @@ -168,3 +143,28 @@ export async function executeSyncPlan( return failures; } + +// applyLocalWrite runs one localWriter mutation, converting a thrown I/O error into a SyncFailure +// so it lands in the same failures array every storage operation already uses. Returns null when +// the write succeeded. +async function applyLocalWrite(path: string, op: () => Promise): Promise { + try { + await op(); + return null; + } catch (err) { + return { path, message: localFailureMessage(err) }; + } +} + +// localFailureMessage turns whatever a local vault operation threw into a SyncFailure message. +// readFile throws when a file vanishes between the snapshot and now (a user deleting it mid sync), +// and writeFile/deleteFile/renameFile can throw on a disk full or permission error; routing all of +// them through failures keeps executeSyncPlan's "errors are values" contract, so one bad local +// operation is a per file failure like any storage error, not an exception that abandons the rest +// of the pass. +function localFailureMessage(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + return "local file operation failed"; +} diff --git a/src/sync/fake.ts b/src/sync/fake.ts index 71d2c74..b484fc5 100644 --- a/src/sync/fake.ts +++ b/src/sync/fake.ts @@ -11,36 +11,6 @@ import type { LocalWriter } from "./execute.ts"; // empty is the zero snapshot: a vault with no files. export const empty: Snapshot = { files: [] }; -// file builds a FileState for path with the given hash, using the hash length as a stand-in size. -export function file(path: string, hash: string): FileState { - return { path, size: hash.length, mtime: 1, hash }; -} - -// snapshot builds a Snapshot from the given file states. -export function snapshot(...files: FileState[]): Snapshot { - return { files }; -} - -// fakeReader returns a Reader backed by an in-memory map of path to content. -export function fakeReader(files: Record): Reader { - return { - listFiles: async () => { - const list = []; - for (const [path, content] of Object.entries(files)) { - list.push({ path, size: content.length, mtime: 1 }); - } - return list; - }, - readFile: async (path) => { - const content = files[path]; - if (content === undefined) { - throw new Error(`no such file: ${path}`); - } - return new TextEncoder().encode(content); - }, - }; -} - // fakeLocalWriter returns a LocalWriter backed by an in-memory map, and the map itself so tests // can assert on the result. export function fakeLocalWriter(): { writer: LocalWriter; files: Map } { @@ -63,6 +33,26 @@ export function fakeLocalWriter(): { writer: LocalWriter; files: Map): Reader { + return { + listFiles: async () => { + const list = []; + for (const [path, content] of Object.entries(files)) { + list.push({ path, size: content.length, mtime: 1 }); + } + return list; + }, + readFile: async (path) => { + const content = files[path]; + if (content === undefined) { + throw new Error(`no such file: ${path}`); + } + return new TextEncoder().encode(content); + }, + }; +} + // fakeStorage returns a StorageClient backed by an in-memory map of key to content. export function fakeStorage(objects: Record = {}): { storage: StorageClient; @@ -96,3 +86,13 @@ export function fakeStorage(objects: Record = {}): { }; return { storage, objects: store }; } + +// file builds a FileState for path with the given hash, using the hash length as a stand-in size. +export function file(path: string, hash: string): FileState { + return { path, size: hash.length, mtime: 1, hash }; +} + +// snapshot builds a Snapshot from the given file states. +export function snapshot(...files: FileState[]): Snapshot { + return { files }; +} diff --git a/src/sync/plan.ts b/src/sync/plan.ts index 2aea79f..a6da2c5 100644 --- a/src/sync/plan.ts +++ b/src/sync/plan.ts @@ -29,22 +29,6 @@ export function conflictCopyPath(path: string, now: number): string { return `${path.slice(0, lastDot)} (conflicted copy ${stamp})${path.slice(lastDot)}`; } -// changesByPath builds a lookup from path to change, for matching a local change against a -// remote change at that same path. -function changesByPath(changes: Change[]): Map { - const result = new Map(); - for (const change of changes) { - result.set(change.path, change); - } - return result; -} - -// isReservedPath reports whether path is geode's own bookkeeping, never a real vault file to -// sync, even if something in the vault happens to collide with it. -function isReservedPath(path: string): boolean { - return path === MANIFEST_KEY; -} - // planSync compares what changed locally since the last successful sync against what changed // remotely since that same sync, and decides what to push, what to pull, and what's a genuine // conflict: a path that changed on both sides to different content. previous is the snapshot @@ -111,3 +95,19 @@ export function planSync(previous: Snapshot, local: Snapshot, remote: Snapshot): return actions; } + +// changesByPath builds a lookup from path to change, for matching a local change against a +// remote change at that same path. +function changesByPath(changes: Change[]): Map { + const result = new Map(); + for (const change of changes) { + result.set(change.path, change); + } + return result; +} + +// isReservedPath reports whether path is geode's own bookkeeping, never a real vault file to +// sync, even if something in the vault happens to collide with it. +function isReservedPath(path: string): boolean { + return path === MANIFEST_KEY; +} diff --git a/src/vault/fs.ts b/src/vault/fs.ts index 40994da..4b0a377 100644 --- a/src/vault/fs.ts +++ b/src/vault/fs.ts @@ -8,30 +8,6 @@ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import type { DataAdapter, TFile, Vault } from "obsidian"; -// abs joins a vault relative, forward slash path onto the temp root as a real OS path. -function abs(root: string, path: string): string { - return join(root, ...path.split("/")); -} - -// walk returns every file (not directory) under the root as vault relative, forward slash paths, -// excluding anything under .obsidian, mirroring what Obsidian's Vault.getFiles() leaves out. -function walk(root: string, dir = ""): string[] { - const here = dir === "" ? root : abs(root, dir); - const out: string[] = []; - for (const entry of readdirSync(here, { withFileTypes: true })) { - if (entry.name === ".obsidian") { - continue; - } - const rel = dir === "" ? entry.name : `${dir}/${entry.name}`; - if (entry.isDirectory()) { - out.push(...walk(root, rel)); - continue; - } - out.push(rel); - } - return out; -} - // nodeVault returns a Vault and DataAdapter both backed by the same temp directory root. Only the // subset of methods obsidian.ts actually calls is implemented, then cast to the full Obsidian // interfaces, which is all the real code touches. @@ -86,3 +62,27 @@ export function nodeVault(root: string): { vault: Vault; adapter: DataAdapter } return { vault, adapter }; } + +// abs joins a vault relative, forward slash path onto the temp root as a real OS path. +function abs(root: string, path: string): string { + return join(root, ...path.split("/")); +} + +// walk returns every file (not directory) under the root as vault relative, forward slash paths, +// excluding anything under .obsidian, mirroring what Obsidian's Vault.getFiles() leaves out. +function walk(root: string, dir = ""): string[] { + const here = dir === "" ? root : abs(root, dir); + const out: string[] = []; + for (const entry of readdirSync(here, { withFileTypes: true })) { + if (entry.name === ".obsidian") { + continue; + } + const rel = dir === "" ? entry.name : `${dir}/${entry.name}`; + if (entry.isDirectory()) { + out.push(...walk(root, rel)); + continue; + } + out.push(rel); + } + return out; +} diff --git a/src/vault/obsidian.ts b/src/vault/obsidian.ts index bc69f4b..fc80c9a 100644 --- a/src/vault/obsidian.ts +++ b/src/vault/obsidian.ts @@ -2,19 +2,28 @@ import type { DataAdapter, Vault } from "obsidian"; import type { LocalWriter } from "../sync/execute.ts"; import { type FileInfo, isSnapshot, type Reader, type Snapshot, type Store } from "./vault.ts"; -// ensureParentDir creates path's parent folder, and any folders above it, before a write that -// might land somewhere the vault has never had a file before. mkdir is assumed to create -// intermediate folders the same way Obsidian's own folder creation does. -async function ensureParentDir(adapter: DataAdapter, path: string): Promise { - const lastSlash = path.lastIndexOf("/"); - if (lastSlash === -1) { - return; - } - const dir = path.slice(0, lastSlash); - const exists = await adapter.exists(dir); - if (!exists) { - await adapter.mkdir(dir); - } +// createObsidianLocalWriter returns a LocalWriter that applies pulled remote changes straight +// through the low level data adapter, rather than the Vault API, since a path pulled down for +// the first time has no TFile yet for Vault.modifyBinary/rename to operate on. +export function createObsidianLocalWriter(adapter: DataAdapter): LocalWriter { + return { + writeFile: async (path, data) => { + await ensureParentDir(adapter, path); + const buffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); + await adapter.writeBinary(path, buffer as ArrayBuffer); + }, + deleteFile: async (path) => { + const exists = await adapter.exists(path); + if (!exists) { + return; + } + await adapter.remove(path); + }, + renameFile: async (path, newPath) => { + await ensureParentDir(adapter, newPath); + await adapter.rename(path, newPath); + }, + }; } // createObsidianReader returns a Reader backed by the real vault's file tree. Obsidian @@ -68,26 +77,17 @@ export function createObsidianStore(adapter: DataAdapter, statePath: string): St }; } -// createObsidianLocalWriter returns a LocalWriter that applies pulled remote changes straight -// through the low level data adapter, rather than the Vault API, since a path pulled down for -// the first time has no TFile yet for Vault.modifyBinary/rename to operate on. -export function createObsidianLocalWriter(adapter: DataAdapter): LocalWriter { - return { - writeFile: async (path, data) => { - await ensureParentDir(adapter, path); - const buffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); - await adapter.writeBinary(path, buffer as ArrayBuffer); - }, - deleteFile: async (path) => { - const exists = await adapter.exists(path); - if (!exists) { - return; - } - await adapter.remove(path); - }, - renameFile: async (path, newPath) => { - await ensureParentDir(adapter, newPath); - await adapter.rename(path, newPath); - }, - }; +// ensureParentDir creates path's parent folder, and any folders above it, before a write that +// might land somewhere the vault has never had a file before. mkdir is assumed to create +// intermediate folders the same way Obsidian's own folder creation does. +async function ensureParentDir(adapter: DataAdapter, path: string): Promise { + const lastSlash = path.lastIndexOf("/"); + if (lastSlash === -1) { + return; + } + const dir = path.slice(0, lastSlash); + const exists = await adapter.exists(dir); + if (!exists) { + await adapter.mkdir(dir); + } } diff --git a/src/vault/vault.ts b/src/vault/vault.ts index 8b72aa9..965c2ef 100644 --- a/src/vault/vault.ts +++ b/src/vault/vault.ts @@ -1,25 +1,9 @@ -// FileState is what geode remembers about one vault file as of the last snapshot. -export type FileState = { +// Change describes one path whose state differs between two snapshots. +export type Change = { path: string; - size: number; - mtime: number; - hash: string; -}; - -// Snapshot is every file geode saw the last time it took a snapshot. -export type Snapshot = { - files: FileState[]; + kind: "added" | "modified" | "deleted"; }; -// isSnapshot reports whether a value parsed from untrusted JSON (a remote manifest, a local -// state.json) is shaped like a snapshot: a non-null object with a files array. Callers use this -// instead of a blind `as Snapshot` cast, so a body that parses but is the wrong shape becomes -// a handled corrupt/empty case rather than a TypeError when planSync later iterates files. The -// check stops at the array itself: a malformed entry degrades rather than crashes downstream. -export function isSnapshot(value: unknown): value is Snapshot { - return typeof value === "object" && value !== null && Array.isArray((value as Snapshot).files); -} - // FileInfo is one file as seen live in the vault, before hashing. export type FileInfo = { path: string; @@ -27,6 +11,14 @@ export type FileInfo = { mtime: number; }; +// FileState is what geode remembers about one vault file as of the last snapshot. +export type FileState = { + path: string; + size: number; + mtime: number; + hash: string; +}; + // Reader lists files present in the vault right now and reads their bytes. The real // implementation wraps Obsidian's Vault API (see obsidian.ts); tests use an in-memory fake. export type Reader = { @@ -34,6 +26,11 @@ export type Reader = { readFile: (path: string) => Promise; }; +// Snapshot is every file geode saw the last time it took a snapshot. +export type Snapshot = { + files: FileState[]; +}; + // Store reads and writes the persisted snapshot. The real implementation stores it inside // the plugin's own data directory (see obsidian.ts); tests use an in-memory fake. export type Store = { @@ -41,24 +38,6 @@ export type Store = { write: (snapshot: Snapshot) => Promise; }; -// Change describes one path whose state differs between two snapshots. -export type Change = { - path: string; - kind: "added" | "modified" | "deleted"; -}; - -// hashBytes returns the lowercase hex SHA-256 digest of data. -async function hashBytes(data: Uint8Array): Promise { - // Same TS/DOM lib generic mismatch as storage.ts's BodyInit cast: Uint8Array - // vs BufferSource's stricter ArrayBuffer expectation. Not a real runtime issue. - const digest = await crypto.subtle.digest("SHA-256", data as BufferSource); - let hex = ""; - for (const byte of new Uint8Array(digest)) { - hex += byte.toString(16).padStart(2, "0"); - } - return hex; -} - // byPath builds a lookup from path to file state, for matching a live file against what the // previous snapshot last saw at that same path. Exported for sync.ts, which needs the same // lookup to compare a local snapshot against a remote one. @@ -70,30 +49,39 @@ export function byPath(files: FileState[]): Map { return result; } -// mapWithConcurrency runs fn over each item with at most limit concurrent invocations, preserving -// input order in the returned results. -async function mapWithConcurrency( - items: T[], - limit: number, - fn: (item: T) => Promise, -): Promise { - const results: R[] = new Array(items.length); - let nextIndex = 0; +// diffSnapshots compares two snapshots and reports every path whose content differs. +export function diffSnapshots(previous: Snapshot, current: Snapshot): Change[] { + const previousByPath = byPath(previous.files); + const currentByPath = byPath(current.files); + const changes: Change[] = []; - async function worker(): Promise { - while (nextIndex < items.length) { - const currentIndex = nextIndex++; - results[currentIndex] = await fn(items[currentIndex]); + for (const file of current.files) { + const known = previousByPath.get(file.path); + if (known === undefined) { + changes.push({ path: file.path, kind: "added" }); + continue; + } + if (known.hash !== file.hash) { + changes.push({ path: file.path, kind: "modified" }); } } - const workers: Promise[] = []; - for (let i = 0; i < Math.min(limit, items.length); i++) { - workers.push(worker()); + for (const file of previous.files) { + if (!currentByPath.has(file.path)) { + changes.push({ path: file.path, kind: "deleted" }); + } } - await Promise.all(workers); - return results; + return changes; +} + +// isSnapshot reports whether a value parsed from untrusted JSON (a remote manifest, a local +// state.json) is shaped like a snapshot: a non-null object with a files array. Callers use this +// instead of a blind `as Snapshot` cast, so a body that parses but is the wrong shape becomes +// a handled corrupt/empty case rather than a TypeError when planSync later iterates files. The +// check stops at the array itself: a malformed entry degrades rather than crashes downstream. +export function isSnapshot(value: unknown): value is Snapshot { + return typeof value === "object" && value !== null && Array.isArray((value as Snapshot).files); } // takeSnapshot walks every file the reader currently sees and returns their content hashes. A @@ -127,28 +115,40 @@ export async function takeSnapshot( return { files }; } -// diffSnapshots compares two snapshots and reports every path whose content differs. -export function diffSnapshots(previous: Snapshot, current: Snapshot): Change[] { - const previousByPath = byPath(previous.files); - const currentByPath = byPath(current.files); - const changes: Change[] = []; +// hashBytes returns the lowercase hex SHA-256 digest of data. +async function hashBytes(data: Uint8Array): Promise { + // Same TS/DOM lib generic mismatch as storage.ts's BodyInit cast: Uint8Array + // vs BufferSource's stricter ArrayBuffer expectation. Not a real runtime issue. + const digest = await crypto.subtle.digest("SHA-256", data as BufferSource); + let hex = ""; + for (const byte of new Uint8Array(digest)) { + hex += byte.toString(16).padStart(2, "0"); + } + return hex; +} - for (const file of current.files) { - const known = previousByPath.get(file.path); - if (known === undefined) { - changes.push({ path: file.path, kind: "added" }); - continue; - } - if (known.hash !== file.hash) { - changes.push({ path: file.path, kind: "modified" }); +// mapWithConcurrency runs fn over each item with at most limit concurrent invocations, preserving +// input order in the returned results. +async function mapWithConcurrency( + items: T[], + limit: number, + fn: (item: T) => Promise, +): Promise { + const results: R[] = new Array(items.length); + let nextIndex = 0; + + async function worker(): Promise { + while (nextIndex < items.length) { + const currentIndex = nextIndex++; + results[currentIndex] = await fn(items[currentIndex]); } } - for (const file of previous.files) { - if (!currentByPath.has(file.path)) { - changes.push({ path: file.path, kind: "deleted" }); - } + const workers: Promise[] = []; + for (let i = 0; i < Math.min(limit, items.length); i++) { + workers.push(worker()); } + await Promise.all(workers); - return changes; + return results; } From 637ff802e4b480f3ef3e863125d95aa592af8905 Mon Sep 17 00:00:00 2001 From: revett <2796074+revett@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:08:22 +0100 Subject: [PATCH 2/2] Update skill --- .agents/skills/typescript-as-go/SKILL.md | 7 +++---- skills-lock.json | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.agents/skills/typescript-as-go/SKILL.md b/.agents/skills/typescript-as-go/SKILL.md index 64d1430..7155d2e 100644 --- a/.agents/skills/typescript-as-go/SKILL.md +++ b/.agents/skills/typescript-as-go/SKILL.md @@ -6,7 +6,7 @@ compatibility: Designed for Claude Code (or similar products) metadata: author: revett repo: https://github.com/revett/typescript-as-go - version: 0.5.0 + version: 0.5.1 --- # TypeScript As Go @@ -120,6 +120,5 @@ Notes: - Alphabetical is mechanical on purpose; there is no judgement call to make and no debate to have - A `DEFAULT_X` constant will therefore sit above the type it is annotated with; this is fine, types hoist, and predictable placement is worth more than local reading order -- Test files group cases by the symbol under test, following the same order the source file declares - them; within one symbol's cases the happy path comes first, then the guards, failures, and edge - cases, never alphabetically +- Test files are exempt and never resorted to match their source, reading instead as a narrative + that groups cases by the symbol under test from central to peripheral, happy path first diff --git a/skills-lock.json b/skills-lock.json index 47cb832..8765044 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -5,7 +5,7 @@ "source": "revett/typescript-as-go", "sourceType": "github", "skillPath": "skills/typescript-as-go/SKILL.md", - "computedHash": "2e4a96809e263b52af645f9461649980aa8ef7186b760d5e254378e012527ff5" + "computedHash": "83bd4aa6f247993b957dda43eba2a4c4f63ab673a9c6b4d232b6a73216e8c232" } } }