@gmickel/gno 1.31.0 → 1.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +15 -5
  2. package/assets/skill/SKILL.md +34 -0
  3. package/assets/skill/cli-reference.md +10 -2
  4. package/browser-extension/artifacts/{gno-browser-clipper-v1.31.0.zip → gno-browser-clipper-v1.33.0.zip} +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.33.0.zip.sha256 +1 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/package.json +5 -1
  8. package/spec/cli.md +35 -1
  9. package/spec/db/schema.sql +55 -0
  10. package/spec/mcp.md +114 -11
  11. package/spec/output-schemas/file-refactor-apply-result.schema.json +305 -0
  12. package/spec/output-schemas/file-refactor-preview.schema.json +393 -0
  13. package/spec/output-schemas/publish-artifact.schema.json +76 -1
  14. package/src/cli/commands/publish.ts +43 -7
  15. package/src/core/document-capabilities.ts +13 -0
  16. package/src/core/file-ops.ts +129 -1
  17. package/src/core/file-refactor-adapter.ts +329 -0
  18. package/src/core/file-refactor-apply-edits.ts +61 -0
  19. package/src/core/file-refactor-apply-fs.ts +512 -0
  20. package/src/core/file-refactor-apply-safety.ts +340 -0
  21. package/src/core/file-refactor-apply-validate.ts +401 -0
  22. package/src/core/file-refactor-contract.ts +486 -0
  23. package/src/core/file-refactor-destination.ts +123 -0
  24. package/src/core/file-refactor-from-snapshot.ts +148 -0
  25. package/src/core/file-refactor-journal-port.ts +150 -0
  26. package/src/core/file-refactor-journal.ts +347 -0
  27. package/src/core/file-refactor-paths.ts +60 -0
  28. package/src/core/file-refactor-plan-classify.ts +208 -0
  29. package/src/core/file-refactor-plan-validate.ts +169 -0
  30. package/src/core/file-refactor-planner-types.ts +62 -0
  31. package/src/core/file-refactor-planner.ts +423 -0
  32. package/src/core/file-refactor-resolve.ts +280 -0
  33. package/src/core/file-refactor-service.ts +468 -0
  34. package/src/core/file-refactors.ts +84 -56
  35. package/src/core/link-destination-parse.ts +275 -0
  36. package/src/core/link-inventory-markdown.ts +454 -0
  37. package/src/core/link-inventory-opaque.ts +244 -0
  38. package/src/core/link-inventory-types.ts +47 -0
  39. package/src/core/link-inventory.ts +182 -0
  40. package/src/core/link-relevance.ts +150 -0
  41. package/src/ingestion/strip.ts +152 -26
  42. package/src/mcp/tools/index.ts +18 -17
  43. package/src/mcp/tools/workspace-write.ts +215 -97
  44. package/src/publish/artifact-asset-codec.ts +75 -0
  45. package/src/publish/artifact-asset-contract.ts +152 -0
  46. package/src/publish/artifact-asset-parse.ts +401 -0
  47. package/src/publish/artifact-asset-sniff.ts +108 -0
  48. package/src/publish/artifact-asset-validate.ts +209 -0
  49. package/src/publish/artifact-assets.ts +58 -0
  50. package/src/publish/artifact-validation.ts +32 -6
  51. package/src/publish/artifact.ts +50 -3
  52. package/src/publish/attachment-bundle.ts +145 -0
  53. package/src/publish/attachment-discover.ts +203 -0
  54. package/src/publish/attachment-load.ts +133 -0
  55. package/src/publish/attachment-obsidian.ts +45 -0
  56. package/src/publish/attachment-path.ts +334 -0
  57. package/src/publish/attachment-raster.ts +852 -0
  58. package/src/publish/attachment-resolver.ts +280 -0
  59. package/src/publish/attachment-types.ts +54 -0
  60. package/src/publish/encrypted-export.ts +121 -44
  61. package/src/publish/export-attachments.ts +224 -0
  62. package/src/publish/export-service.ts +142 -80
  63. package/src/publish/obsidian-sanitize.ts +121 -13
  64. package/src/sdk/client.ts +167 -115
  65. package/src/sdk/index.ts +7 -0
  66. package/src/sdk/types.ts +32 -2
  67. package/src/serve/file-refactor-http.ts +239 -0
  68. package/src/serve/public/components/RefactorImpactPreview.tsx +227 -0
  69. package/src/serve/public/globals.built.css +1 -1
  70. package/src/serve/public/pages/DocView.tsx +176 -41
  71. package/src/serve/routes/api.ts +193 -105
  72. package/src/store/migrations/026-file-refactor-recovery-journal.ts +72 -0
  73. package/src/store/migrations/index.ts +2 -0
  74. package/src/store/sqlite/adapter.ts +452 -0
  75. package/src/store/sqlite/file-refactor-journal-store.ts +275 -0
  76. package/src/store/types.ts +84 -0
  77. package/browser-extension/artifacts/gno-browser-clipper-v1.31.0.zip.sha256 +0 -1
@@ -2,12 +2,23 @@
2
2
  * Obsidian-aware markdown pre-processor for publish export.
3
3
  *
4
4
  * Strips wikilinks, drops navigation sidebar idioms, removes references to
5
- * private (`_internal/`) paths, and warns on unresolvable image embeds before
6
- * the markdown enters the publish artifact.
5
+ * private (`_internal/`) paths, and resolves/bundles local raster embeds when
6
+ * an attachment context is provided (otherwise drops image embeds with a
7
+ * warning for backward compatibility).
7
8
  *
8
9
  * @module src/publish/obsidian-sanitize
9
10
  */
10
11
 
12
+ import { discoverImageOccurrences } from "./attachment-discover";
13
+ import { safePercentDecode } from "./attachment-path";
14
+ import {
15
+ rewriteAttachmentsInMarkdown,
16
+ type AttachmentDiagnostic,
17
+ type AttachmentResolveContext,
18
+ type AttachmentRewriteResult,
19
+ type PendingAssetPayload,
20
+ } from "./attachment-resolver";
21
+
11
22
  const WIKILINK_INTERNAL_PREFIX = /_internal\//i;
12
23
  const NAV_SIDEBAR_LINE = /^(!?\[\[[^\]]+\]\]\s*\|?\s*)+$/;
13
24
  const IMAGE_EMBED = /!\[\[([^\]]+)\]\]/g;
@@ -16,6 +27,7 @@ const ALIASED_WIKILINK = /\[\[([^\]|]+)\|([^\]]+)\]\]/g;
16
27
  const BARE_WIKILINK = /\[\[([^\]]+)\]\]/g;
17
28
  const TAIL_SEGMENT = /[^/]+$/;
18
29
  const BLOCK_ID_SUFFIX = /#\^?[\w-]+$/;
30
+ const EXTERNAL_DESTINATION = /^(?:\/\/|[a-z][a-z0-9+.-]*:)/iu;
19
31
 
20
32
  export interface SanitizeWarning {
21
33
  kind:
@@ -31,6 +43,13 @@ export interface SanitizeResult {
31
43
  warnings: SanitizeWarning[];
32
44
  }
33
45
 
46
+ export interface PublishSanitizeResult extends SanitizeResult {
47
+ diagnostics: AttachmentDiagnostic[];
48
+ externalCount: number;
49
+ payloads: Map<string, PendingAssetPayload>;
50
+ preDedupRawBytes: number;
51
+ }
52
+
34
53
  const splitFrontmatter = (
35
54
  source: string
36
55
  ): { body: string; frontmatter: string } => {
@@ -61,8 +80,57 @@ const deriveLinkDisplay = (target: string): string => {
61
80
  return tail.trim() || raw;
62
81
  };
63
82
 
64
- export function sanitizeObsidianMarkdown(source: string): SanitizeResult {
65
- const { body, frontmatter } = splitFrontmatter(source);
83
+ const isPrivateImageReference = (sourceRef: string): boolean => {
84
+ const trimmed = sourceRef.trim();
85
+ if (EXTERNAL_DESTINATION.test(trimmed)) {
86
+ return false;
87
+ }
88
+ const decoded = safePercentDecode(trimmed);
89
+ if (decoded === null) {
90
+ return false;
91
+ }
92
+ return (
93
+ decoded
94
+ .split(/[?#]/u, 1)[0]
95
+ ?.split("/")
96
+ .some((segment) => segment.toLowerCase() === "_internal") ?? false
97
+ );
98
+ };
99
+
100
+ /**
101
+ * Remove private image references before attachment resolution can read bytes
102
+ * or replace the authored path with an opaque gno-asset sentinel.
103
+ */
104
+ const stripPrivateImageReferences = (
105
+ body: string
106
+ ): { markdown: string; warnings: SanitizeWarning[] } => {
107
+ const replacements = discoverImageOccurrences(body)
108
+ .filter((occurrence) => isPrivateImageReference(occurrence.sourceRef))
109
+ .map((occurrence) => ({
110
+ detail: occurrence.sourceRef.trim(),
111
+ end: occurrence.end,
112
+ start: occurrence.start,
113
+ }));
114
+ let markdown = body;
115
+ for (const replacement of [...replacements].sort(
116
+ (left, right) => right.start - left.start
117
+ )) {
118
+ markdown =
119
+ markdown.slice(0, replacement.start) + markdown.slice(replacement.end);
120
+ }
121
+ return {
122
+ markdown,
123
+ warnings: replacements.map((replacement) => ({
124
+ detail: replacement.detail,
125
+ kind: "internal-reference-stripped" as const,
126
+ })),
127
+ };
128
+ };
129
+
130
+ const applyWikilinkSanitize = (
131
+ body: string,
132
+ options: { dropImageEmbeds: boolean }
133
+ ): { markdown: string; warnings: SanitizeWarning[] } => {
66
134
  const warnings: SanitizeWarning[] = [];
67
135
  const lines = body.split("\n");
68
136
  const output: string[] = [];
@@ -81,13 +149,15 @@ export function sanitizeObsidianMarkdown(source: string): SanitizeResult {
81
149
 
82
150
  let line = rawLine;
83
151
 
84
- line = line.replace(IMAGE_EMBED, (_match, target: string) => {
85
- warnings.push({
86
- kind: "image-embed-dropped",
87
- detail: target.trim(),
152
+ if (options.dropImageEmbeds) {
153
+ line = line.replace(IMAGE_EMBED, (_match, target: string) => {
154
+ warnings.push({
155
+ kind: "image-embed-dropped",
156
+ detail: target.trim(),
157
+ });
158
+ return "";
88
159
  });
89
- return "";
90
- });
160
+ }
91
161
 
92
162
  line = line.replace(INTERNAL_WIKILINK, (match) => {
93
163
  warnings.push({
@@ -134,9 +204,46 @@ export function sanitizeObsidianMarkdown(source: string): SanitizeResult {
134
204
  output.push(line);
135
205
  }
136
206
 
207
+ return { markdown: output.join("\n"), warnings };
208
+ };
209
+
210
+ /**
211
+ * Legacy sync sanitizer: drops Obsidian image embeds and converts wikilinks.
212
+ * Prefer `sanitizePublishMarkdown` when attachment bundling is enabled.
213
+ */
214
+ export function sanitizeObsidianMarkdown(source: string): SanitizeResult {
215
+ const { body, frontmatter } = splitFrontmatter(source);
216
+ const sanitized = applyWikilinkSanitize(body, { dropImageEmbeds: true });
217
+ return {
218
+ markdown: `${frontmatter}${sanitized.markdown}`,
219
+ warnings: sanitized.warnings,
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Parser-aware publish sanitize: resolve/rewrite local rasters in the same
225
+ * pass as Obsidian cleanup (image discovery is not a second independent scan).
226
+ */
227
+ export async function sanitizePublishMarkdown(
228
+ source: string,
229
+ attachmentCtx: AttachmentResolveContext
230
+ ): Promise<PublishSanitizeResult> {
231
+ const { body, frontmatter } = splitFrontmatter(source);
232
+ const privateImages = stripPrivateImageReferences(body);
233
+ const rewritten: AttachmentRewriteResult = await rewriteAttachmentsInMarkdown(
234
+ privateImages.markdown,
235
+ attachmentCtx
236
+ );
237
+ const sanitized = applyWikilinkSanitize(rewritten.markdown, {
238
+ dropImageEmbeds: false,
239
+ });
137
240
  return {
138
- markdown: `${frontmatter}${output.join("\n")}`,
139
- warnings,
241
+ diagnostics: rewritten.diagnostics,
242
+ externalCount: rewritten.externalCount,
243
+ markdown: `${frontmatter}${sanitized.markdown}`,
244
+ payloads: rewritten.payloads,
245
+ preDedupRawBytes: rewritten.preDedupRawBytes,
246
+ warnings: [...privateImages.warnings, ...sanitized.warnings],
140
247
  };
141
248
  }
142
249
 
@@ -159,7 +266,8 @@ export function formatSanitizeWarnings(warnings: SanitizeWarning[]): string[] {
159
266
  }
160
267
 
161
268
  const labels: Record<SanitizeWarning["kind"], string> = {
162
- "image-embed-dropped": "Image embeds dropped (attachments not bundled yet)",
269
+ "image-embed-dropped":
270
+ "Image embeds dropped (unsupported, missing, or unresolved)",
163
271
  "internal-reference-stripped": "Private `_internal/` references stripped",
164
272
  "nav-sidebar-dropped": "Navigation sidebar lines dropped",
165
273
  "wikilink-unresolved":
package/src/sdk/client.ts CHANGED
@@ -34,10 +34,12 @@ import type {
34
34
  GnoIndexResult,
35
35
  GnoIndexStatus,
36
36
  GnoListOptions,
37
+ GnoMoveNoteApplyOptions,
37
38
  GnoMoveNoteOptions,
38
39
  GnoMultiGetOptions,
39
40
  GnoQueryOptions,
40
41
  GnoRefactorNoteResult,
42
+ GnoRenameNoteApplyOptions,
41
43
  GnoRenameNoteOptions,
42
44
  GnoSearchOptions,
43
45
  GnoUpdateOptions,
@@ -86,20 +88,23 @@ import { writeCapturePlanFile } from "../core/capture-write";
86
88
  import { projectCollectionEgressPolicy } from "../core/collection-egress-policy-projection";
87
89
  import { CollectionEgressPolicyService } from "../core/collection-egress-policy-service";
88
90
  import { applyConfigChange } from "../core/config-mutation";
91
+ import { getDocumentCapabilities } from "../core/document-capabilities";
89
92
  import { EgressAuditService } from "../core/egress-audit";
90
93
  import { authorizeCurrentEgress } from "../core/egress-authorization";
94
+ import { atomicWrite, copyFilePath, createFolderPath } from "../core/file-ops";
91
95
  import {
92
- atomicWrite,
93
- copyFilePath,
94
- createFolderPath,
95
- renameFilePath,
96
- } from "../core/file-ops";
97
- import {
96
+ applyCanonicalFileRefactor,
97
+ assertFileRefactorSyncConverged,
98
+ buildCanonicalRefactorPlan,
99
+ buildDurableFileRefactorApplyDeps,
98
100
  buildRefactorWarnings,
101
+ parseRefactorApplyConfirmation,
99
102
  planCreateFolder,
100
103
  planDuplicateRefactor,
101
- planMoveRefactor,
102
- planRenameRefactor,
104
+ resolveMoveTarget,
105
+ resolveRenameTarget,
106
+ type FileRefactorApplyResult,
107
+ type FileRefactorPreviewPlan,
103
108
  } from "../core/file-refactors";
104
109
  import { resolveEffectiveIndex } from "../core/indexed-reference";
105
110
  import {
@@ -334,6 +339,52 @@ class GnoClientImpl implements GnoClient {
334
339
  return filtered;
335
340
  }
336
341
 
342
+ private requireRefactorApplyConfirmation(options: {
343
+ schemaVersion?: unknown;
344
+ planDigest?: unknown;
345
+ confirmation?: unknown;
346
+ }) {
347
+ const parsed = parseRefactorApplyConfirmation(options);
348
+ if ("error" in parsed) {
349
+ throw sdkError("VALIDATION", parsed.message);
350
+ }
351
+ return parsed;
352
+ }
353
+
354
+ private async prepareRefactorSource(ref: string) {
355
+ const doc = await getDocumentByRef(this.store, this.config, ref, {});
356
+ const stored = await this.store.getDocumentByUri(doc.uri);
357
+ if (!stored.ok || !stored.value) {
358
+ throw sdkError("NOT_FOUND", "Document not found");
359
+ }
360
+ const storedDoc = stored.value;
361
+ const collection = this.getCollections(storedDoc.collection)[0];
362
+ if (!collection) {
363
+ throw sdkError(
364
+ "VALIDATION",
365
+ `Collection not found: ${storedDoc.collection}`
366
+ );
367
+ }
368
+ const capabilities = getDocumentCapabilities({
369
+ sourceExt: storedDoc.sourceExt,
370
+ sourceMime: storedDoc.sourceMime,
371
+ contentAvailable: storedDoc.mirrorHash !== null,
372
+ recordKey: storedDoc.recordKey,
373
+ });
374
+ if (!capabilities.editable) {
375
+ throw sdkError(
376
+ "VALIDATION",
377
+ capabilities.reason ?? "Document is read-only in place."
378
+ );
379
+ }
380
+ return {
381
+ storedDoc,
382
+ collection,
383
+ sourceFullPath: `${collection.path}/${storedDoc.relPath}`,
384
+ editable: capabilities.editable,
385
+ };
386
+ }
387
+
337
388
  private async createRuntimePorts(options: {
338
389
  embed?: boolean;
339
390
  expand?: boolean;
@@ -1657,125 +1708,126 @@ class GnoClientImpl implements GnoClient {
1657
1708
  };
1658
1709
  }
1659
1710
 
1660
- async renameNote(
1711
+ async previewRenameNote(
1661
1712
  options: GnoRenameNoteOptions
1662
- ): Promise<GnoRefactorNoteResult> {
1713
+ ): Promise<FileRefactorPreviewPlan> {
1663
1714
  this.assertOpen();
1664
- const doc = await getDocumentByRef(
1665
- this.store,
1666
- this.config,
1667
- options.ref,
1668
- {}
1669
- );
1670
- const stored = await this.store.getDocumentByUri(doc.uri);
1671
- if (!stored.ok || !stored.value) {
1672
- throw sdkError("NOT_FOUND", "Document not found");
1673
- }
1674
- const storedDoc = stored.value;
1675
- const collection = this.getCollections(storedDoc.collection)[0];
1676
- if (!collection) {
1677
- throw sdkError(
1678
- "VALIDATION",
1679
- `Collection not found: ${storedDoc.collection}`
1680
- );
1681
- }
1682
- const plan = planRenameRefactor({
1683
- collection: collection.name,
1684
- currentRelPath: storedDoc.relPath,
1715
+ const prepared = await this.prepareRefactorSource(options.ref);
1716
+ const target = resolveRenameTarget({
1717
+ collection: prepared.collection.name,
1718
+ currentRelPath: prepared.storedDoc.relPath,
1685
1719
  nextName: options.name,
1686
1720
  });
1687
- const currentPath = `${collection.path}/${storedDoc.relPath}`;
1688
- const nextPath = `${collection.path}/${plan.nextRelPath}`;
1689
- await renameFilePath(currentPath, nextPath);
1690
- await defaultSyncService.syncCollection(
1691
- collection,
1692
- this.store,
1693
- withContentTypeRules({ runUpdateCmd: false }, this.config)
1694
- );
1695
- const linksResult = await this.store.getLinksForDoc(storedDoc.id);
1696
- const backlinksResult = await this.store.getBacklinksForDoc(storedDoc.id);
1697
- if (!linksResult.ok || !backlinksResult.ok) {
1698
- throw sdkError("STORE", "Failed to compute refactor warnings");
1699
- }
1700
- return {
1701
- uri: plan.nextUri,
1702
- path: nextPath,
1703
- relPath: plan.nextRelPath,
1704
- warnings: buildRefactorWarnings(
1705
- {
1706
- backlinks: backlinksResult.value.length,
1707
- wikiLinks: linksResult.value.filter(
1708
- (entry) => entry.linkType === "wiki"
1709
- ).length,
1710
- markdownLinks: linksResult.value.filter(
1711
- (entry) => entry.linkType === "markdown"
1712
- ).length,
1721
+ return buildCanonicalRefactorPlan({
1722
+ operation: "rename",
1723
+ doc: prepared.storedDoc,
1724
+ collection: prepared.collection,
1725
+ sourceFullPath: prepared.sourceFullPath,
1726
+ target,
1727
+ store: this.store,
1728
+ sourceEditable: prepared.editable,
1729
+ });
1730
+ }
1731
+
1732
+ async previewMoveNote(
1733
+ options: GnoMoveNoteOptions
1734
+ ): Promise<FileRefactorPreviewPlan> {
1735
+ this.assertOpen();
1736
+ const prepared = await this.prepareRefactorSource(options.ref);
1737
+ const target = resolveMoveTarget({
1738
+ collection: prepared.collection.name,
1739
+ currentRelPath: prepared.storedDoc.relPath,
1740
+ folderPath: options.folderPath,
1741
+ nextName: options.name,
1742
+ });
1743
+ return buildCanonicalRefactorPlan({
1744
+ operation: "move",
1745
+ doc: prepared.storedDoc,
1746
+ collection: prepared.collection,
1747
+ sourceFullPath: prepared.sourceFullPath,
1748
+ target,
1749
+ store: this.store,
1750
+ sourceEditable: prepared.editable,
1751
+ });
1752
+ }
1753
+
1754
+ async renameNote(
1755
+ options: GnoRenameNoteApplyOptions
1756
+ ): Promise<FileRefactorApplyResult> {
1757
+ this.assertOpen();
1758
+ const confirmation = this.requireRefactorApplyConfirmation(options);
1759
+ const prepared = await this.prepareRefactorSource(options.ref);
1760
+ const target = resolveRenameTarget({
1761
+ collection: prepared.collection.name,
1762
+ currentRelPath: prepared.storedDoc.relPath,
1763
+ nextName: options.name,
1764
+ });
1765
+ const plan = await buildCanonicalRefactorPlan({
1766
+ operation: "rename",
1767
+ doc: prepared.storedDoc,
1768
+ collection: prepared.collection,
1769
+ sourceFullPath: prepared.sourceFullPath,
1770
+ target,
1771
+ store: this.store,
1772
+ sourceEditable: prepared.editable,
1773
+ });
1774
+ const apply = await applyCanonicalFileRefactor({
1775
+ plan,
1776
+ confirmation,
1777
+ deps: buildDurableFileRefactorApplyDeps({
1778
+ collection: prepared.collection,
1779
+ store: this.store,
1780
+ syncAfterCommit: async () => {
1781
+ const syncResult = await defaultSyncService.syncCollection(
1782
+ prepared.collection,
1783
+ this.store,
1784
+ withContentTypeRules({ runUpdateCmd: false }, this.config)
1785
+ );
1786
+ assertFileRefactorSyncConverged(syncResult);
1713
1787
  },
1714
- { filenameChanged: true }
1715
- ).warnings,
1716
- };
1788
+ }),
1789
+ });
1790
+ return apply;
1717
1791
  }
1718
1792
 
1719
- async moveNote(options: GnoMoveNoteOptions): Promise<GnoRefactorNoteResult> {
1793
+ async moveNote(
1794
+ options: GnoMoveNoteApplyOptions
1795
+ ): Promise<FileRefactorApplyResult> {
1720
1796
  this.assertOpen();
1721
- const doc = await getDocumentByRef(
1722
- this.store,
1723
- this.config,
1724
- options.ref,
1725
- {}
1726
- );
1727
- const stored = await this.store.getDocumentByUri(doc.uri);
1728
- if (!stored.ok || !stored.value) {
1729
- throw sdkError("NOT_FOUND", "Document not found");
1730
- }
1731
- const storedDoc = stored.value;
1732
- const collection = this.getCollections(storedDoc.collection)[0];
1733
- if (!collection) {
1734
- throw sdkError(
1735
- "VALIDATION",
1736
- `Collection not found: ${storedDoc.collection}`
1737
- );
1738
- }
1739
- const plan = planMoveRefactor({
1740
- collection: collection.name,
1741
- currentRelPath: storedDoc.relPath,
1797
+ const confirmation = this.requireRefactorApplyConfirmation(options);
1798
+ const prepared = await this.prepareRefactorSource(options.ref);
1799
+ const target = resolveMoveTarget({
1800
+ collection: prepared.collection.name,
1801
+ currentRelPath: prepared.storedDoc.relPath,
1742
1802
  folderPath: options.folderPath,
1743
1803
  nextName: options.name,
1744
1804
  });
1745
- const currentPath = `${collection.path}/${storedDoc.relPath}`;
1746
- const nextPath = `${collection.path}/${plan.nextRelPath}`;
1747
- await mkdir(dirname(nextPath), { recursive: true });
1748
- await renameFilePath(currentPath, nextPath);
1749
- await defaultSyncService.syncCollection(
1750
- collection,
1751
- this.store,
1752
- withContentTypeRules({ runUpdateCmd: false }, this.config)
1753
- );
1754
- const linksResult = await this.store.getLinksForDoc(storedDoc.id);
1755
- const backlinksResult = await this.store.getBacklinksForDoc(storedDoc.id);
1756
- if (!linksResult.ok || !backlinksResult.ok) {
1757
- throw sdkError("STORE", "Failed to compute refactor warnings");
1758
- }
1759
- return {
1760
- uri: plan.nextUri,
1761
- path: nextPath,
1762
- relPath: plan.nextRelPath,
1763
- warnings: buildRefactorWarnings(
1764
- {
1765
- backlinks: backlinksResult.value.length,
1766
- wikiLinks: linksResult.value.filter(
1767
- (entry) => entry.linkType === "wiki"
1768
- ).length,
1769
- markdownLinks: linksResult.value.filter(
1770
- (entry) => entry.linkType === "markdown"
1771
- ).length,
1805
+ const plan = await buildCanonicalRefactorPlan({
1806
+ operation: "move",
1807
+ doc: prepared.storedDoc,
1808
+ collection: prepared.collection,
1809
+ sourceFullPath: prepared.sourceFullPath,
1810
+ target,
1811
+ store: this.store,
1812
+ sourceEditable: prepared.editable,
1813
+ });
1814
+ const apply = await applyCanonicalFileRefactor({
1815
+ plan,
1816
+ confirmation,
1817
+ deps: buildDurableFileRefactorApplyDeps({
1818
+ collection: prepared.collection,
1819
+ store: this.store,
1820
+ syncAfterCommit: async () => {
1821
+ const syncResult = await defaultSyncService.syncCollection(
1822
+ prepared.collection,
1823
+ this.store,
1824
+ withContentTypeRules({ runUpdateCmd: false }, this.config)
1825
+ );
1826
+ assertFileRefactorSyncConverged(syncResult);
1772
1827
  },
1773
- {
1774
- folderChanged: true,
1775
- filenameChanged: Boolean(options.name),
1776
- }
1777
- ).warnings,
1778
- };
1828
+ }),
1829
+ });
1830
+ return apply;
1779
1831
  }
1780
1832
 
1781
1833
  async duplicateNote(
package/src/sdk/index.ts CHANGED
@@ -40,6 +40,9 @@ export type {
40
40
  GnoClientInitOptions,
41
41
  GnoEmbedOptions,
42
42
  GnoEmbedResult,
43
+ GnoFileRefactorApplyConfirmation,
44
+ GnoFileRefactorApplyResult,
45
+ GnoFileRefactorPreviewPlan,
43
46
  GnoGetOptions,
44
47
  GnoGetResult,
45
48
  GnoIndexOptions,
@@ -49,10 +52,14 @@ export type {
49
52
  GnoListOptions,
50
53
  GnoListResult,
51
54
  GnoModelOverrides,
55
+ GnoMoveNoteApplyOptions,
56
+ GnoMoveNoteOptions,
52
57
  GnoMultiGetDocument,
53
58
  GnoMultiGetOptions,
54
59
  GnoMultiGetResult,
55
60
  GnoQueryOptions,
61
+ GnoRenameNoteApplyOptions,
62
+ GnoRenameNoteOptions,
56
63
  GnoProjectHintOptions,
57
64
  GnoSearchOptions,
58
65
  GnoSkippedDocument,
package/src/sdk/types.ts CHANGED
@@ -32,6 +32,10 @@ import type {
32
32
  EgressAuditShowResult,
33
33
  EgressAuditStatusResult,
34
34
  } from "../core/egress-audit";
35
+ import type {
36
+ FileRefactorApplyResult,
37
+ FileRefactorPreviewPlan,
38
+ } from "../core/file-refactor-contract";
35
39
  import type {
36
40
  KnowledgeChangesResult,
37
41
  KnowledgeDiffResult,
@@ -262,6 +266,22 @@ export interface GnoMoveNoteOptions {
262
266
  name?: string;
263
267
  }
264
268
 
269
+ /** Canonical apply confirmation — preview first, then pass exact digest. */
270
+ export interface GnoFileRefactorApplyConfirmation {
271
+ schemaVersion: "1.0";
272
+ planDigest: string;
273
+ confirmation: "apply";
274
+ }
275
+
276
+ export interface GnoRenameNoteApplyOptions
277
+ extends GnoRenameNoteOptions, GnoFileRefactorApplyConfirmation {}
278
+
279
+ export interface GnoMoveNoteApplyOptions
280
+ extends GnoMoveNoteOptions, GnoFileRefactorApplyConfirmation {}
281
+
282
+ export type GnoFileRefactorPreviewPlan = FileRefactorPreviewPlan;
283
+ export type GnoFileRefactorApplyResult = FileRefactorApplyResult;
284
+
265
285
  export interface GnoDuplicateNoteOptions {
266
286
  ref: string;
267
287
  folderPath?: string;
@@ -350,8 +370,18 @@ export interface GnoClient {
350
370
  capture(options: GnoCaptureOptions): Promise<GnoCaptureResult>;
351
371
  createNote(options: GnoCreateNoteOptions): Promise<GnoCreateNoteResult>;
352
372
  createFolder(options: GnoCreateFolderOptions): Promise<GnoCreateFolderResult>;
353
- renameNote(options: GnoRenameNoteOptions): Promise<GnoRefactorNoteResult>;
354
- moveNote(options: GnoMoveNoteOptions): Promise<GnoRefactorNoteResult>;
373
+ previewRenameNote(
374
+ options: GnoRenameNoteOptions
375
+ ): Promise<GnoFileRefactorPreviewPlan>;
376
+ previewMoveNote(
377
+ options: GnoMoveNoteOptions
378
+ ): Promise<GnoFileRefactorPreviewPlan>;
379
+ renameNote(
380
+ options: GnoRenameNoteApplyOptions
381
+ ): Promise<GnoFileRefactorApplyResult>;
382
+ moveNote(
383
+ options: GnoMoveNoteApplyOptions
384
+ ): Promise<GnoFileRefactorApplyResult>;
355
385
  duplicateNote(
356
386
  options: GnoDuplicateNoteOptions
357
387
  ): Promise<GnoRefactorNoteResult>;