diff --git a/examples/plugins/mill-bookmark/manifest.json b/examples/plugins/mill-bookmark/manifest.json index c6662ed1d..2aeab2c95 100644 --- a/examples/plugins/mill-bookmark/manifest.json +++ b/examples/plugins/mill-bookmark/manifest.json @@ -5,5 +5,8 @@ "description": "Keeps a web address on the board and opens it in your browser.", "author": "Mill examples", "minMillVersion": "0.9.0", - "capabilities": ["open-url"] + "capabilities": ["open-url"], + "contributes": { + "canvasObjects": [{ "kind": "bookmark", "pastesURLs": true }] + } } diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/models.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/models.ts index 61ca9a279..189300cf4 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/models.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/models.ts @@ -328,6 +328,13 @@ export interface PasteResult { * paste names exactly one path or URL. */ "Images": number; + + /** + * PluginObjects counts a paste landing a runtime plugin's claimed + * board object (docs/goals/0251, atlaspasteplugin.go) -- always 0 + * or 1, same single-payload property Images documents. + */ + "PluginObjects": number; "SkippedPages": string[] | null; } diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/index.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/index.ts index e3f53cf55..987a2e18e 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/index.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/index.ts @@ -7,7 +7,9 @@ export { }; export type { + CanvasObjectContribution, GuardedActionDecision, Manifest, + ManifestContributes, PluginInfo } from "./models.js"; diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/models.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/models.ts index 076487c55..e1e3f87e6 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/models.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/pluginsvc/models.ts @@ -1,6 +1,21 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +/** + * CanvasObjectContribution claims the ingestion doors for one canvas + * object kind: which dropped-file extensions and which clipboard + * shapes land as this plugin's object. Payload shape is not declared + * here -- it derives from the object's own registered source (a + * fileExtensions claim requires a file-backed object landing + * mirrorPath+title; PastesURLs requires a url-backed one landing + * url+title), enforced host-side at registration. + */ +export interface CanvasObjectContribution { + "kind": string; + "fileExtensions": string[] | null; + "pastesURLs": boolean; +} + /** * GuardedActionDecision is RequestGuardedAction's wire shape. */ @@ -18,9 +33,11 @@ export interface GuardedActionDecision { /** * Manifest is the converged plugin manifest shape (docs/adr/0047 §1: - * identity metadata + a declared capability set; contributions happen - * at activate() time through the host API, so they are not restated - * here). + * identity metadata + a declared capability set). Rendering + * contributions happen at activate() time through the host API, so + * they are not restated here; INGESTION claims are the deliberate + * exception (docs/goals/0251) -- both ingestion chains must consult + * them without running plugin code, so they live in Contributes. */ export interface Manifest { "id": string; @@ -30,6 +47,17 @@ export interface Manifest { "author": string; "minMillVersion": string; "capabilities": string[] | null; + "contributes": ManifestContributes; +} + +/** + * ManifestContributes is the manifest's declarative contribution + * point (docs/goals/0251, the VSCode-shaped convention): data other + * parts of Mill read to ROUTE to a plugin, distinct from capabilities + * (what a plugin may ask to do). + */ +export interface ManifestContributes { + "canvasObjects": CanvasObjectContribution[] | null; } /** diff --git a/frontend/e2e/runtime-plugins.spec.ts b/frontend/e2e/runtime-plugins.spec.ts index 5b7faede2..b033737e4 100644 --- a/frontend/e2e/runtime-plugins.spec.ts +++ b/frontend/e2e/runtime-plugins.spec.ts @@ -161,6 +161,9 @@ test('the Extensions page tells the install story: plugin row with manifest meta await expect(bookmarkRow).toContainText('Bookmark') await expect(bookmarkRow).toContainText('1.0.0') await expect(bookmarkRow).toContainText('open-url') + // Ingestion claims render declare-first (goal 0251): the row + // states what the plugin catches before it ever runs. + await expect(bookmarkRow.locator('[data-testid="extensions-plugin-catches"]')).toContainText('web links pasted') await expect(bookmarkRow.locator('[data-testid="extensions-plugin-toggle"]')).toBeVisible() // A broken folder is a visible row naming its exact problem -- @@ -175,3 +178,36 @@ test('the Extensions page tells the install story: plugin row with manifest meta await close() } }) + +test('a URL pasted from another app lands as the claiming plugin object, not a note (goal 0251)', async () => { + const { page, close } = await launchWithPlugins(6) + try { + await page.goto('/') + await page.getByRole('link', { name: 'Atlas' }).click() + const board = page.getByTestId('atlas-board') + await expect(board).toBeVisible() + + // The paste anchors at the pointer -- aim at open canvas first + // (atlas-paste-convert.spec.ts's own cursor-position gesture). + const spot = await findEmptyBoardRect(page, board, 300, 200) + // eslint-disable-next-line no-restricted-syntax -- cursor-position-only gesture, not a checkable interaction (atlas-paste-convert.spec.ts's pasteText comment has the full reasoning) + await page.mouse.move(spot.x + 20, spot.y + 20) + await page.evaluate(() => { + const dt = new DataTransfer() + dt.setData('text/plain', 'https://example.com/some/page') + window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })) + }) + + // The whole claims chain fires: manifest scan -> wiring's + // enablement filter -> the Go recognizer -> a bookmark object + // rendered by the plugin's own face, carrying the pasted URL. + const face = page.locator('[data-testid="plugin-face-bookmark"]') + await expect(face).toBeVisible() + await expect(face.locator('[data-testid="bookmark-url-input"]')).toHaveValue('https://example.com/some/page') + + // And it landed as the claimed object, never the note fallback. + await expect(page.locator('[data-testid="atlas-sticky-note"]')).toHaveCount(0) + } finally { + await close() + } +}) diff --git a/frontend/src/atlas/atlasNounRegistry.ts b/frontend/src/atlas/atlasNounRegistry.ts index 7f03e1f4f..910028bae 100644 --- a/frontend/src/atlas/atlasNounRegistry.ts +++ b/frontend/src/atlas/atlasNounRegistry.ts @@ -367,6 +367,10 @@ export type ThirdPartyNounShape = Omit & // The owning plugin (manifest id) -- the Extensions page's join key. pluginId: string defaultPayload: Record + // Dropped-file extensions this object claims (docs/goals/0251), + // joined from the manifest's contributes by the host -- lowercased + // ".ext" entries the drop router compares against extensionOf(). + fileExtensions: string[] } const thirdPartyRegistry = new Map() @@ -392,3 +396,16 @@ export function isThirdPartyToolId(id: string): boolean { export function thirdPartyNounFor(id: string): ThirdPartyNounShape | undefined { return thirdPartyRegistry.get(id) } + +// thirdPartyNounForExtension -- the drop router's claim lookup +// (docs/goals/0251). Registration order decides ties (first claimant +// wins, deterministic: the loader activates plugins in ListPlugins' +// sorted id order). The registry only ever holds ENABLED plugins (the +// loader skips disabled ids at boot), so presence here IS enablement. +export function thirdPartyNounForExtension(ext: string): ThirdPartyNounShape | undefined { + const lower = ext.toLowerCase() + for (const noun of thirdPartyRegistry.values()) { + if (noun.fileExtensions.includes(lower)) return noun + } + return undefined +} diff --git a/frontend/src/atlas/pasteSummary.test.ts b/frontend/src/atlas/pasteSummary.test.ts index c7210386b..3136b11ef 100644 --- a/frontend/src/atlas/pasteSummary.test.ts +++ b/frontend/src/atlas/pasteSummary.test.ts @@ -6,7 +6,7 @@ import { pasteSummaryText } from './pasteSummary' const t = ((key: string, opts?: Record) => (opts ? `${key}:${JSON.stringify(opts)}` : key)) as TFunction<'atlas'> function result(overrides: Partial = {}): PasteResult { - return { Recognized: true, Cards: 0, Links: 0, Tables: 0, Images: 0, SkippedPages: null, ...overrides } + return { Recognized: true, Cards: 0, Links: 0, Tables: 0, Images: 0, PluginObjects: 0, SkippedPages: null, ...overrides } } describe('pasteSummaryText', () => { diff --git a/frontend/src/atlas/pasteSummary.ts b/frontend/src/atlas/pasteSummary.ts index d204b59fb..36b1eb8c7 100644 --- a/frontend/src/atlas/pasteSummary.ts +++ b/frontend/src/atlas/pasteSummary.ts @@ -10,6 +10,7 @@ export function pasteSummaryText(t: TFunction<'atlas'>, res: PasteResult): strin res.Cards > 0 ? t('paste.cards', { count: res.Cards }) : '', res.Links > 0 ? t('paste.links', { count: res.Links }) : '', res.Images > 0 ? t('paste.images', { count: res.Images }) : '', + res.PluginObjects > 0 ? t('paste.pluginObjects', { count: res.PluginObjects }) : '', ].filter(Boolean) const summary = t('paste.converted', { what: parts.join(', ') }) if (!res.SkippedPages || res.SkippedPages.length === 0) return summary diff --git a/frontend/src/atlas/useAtlasNativeFileDrop.test.ts b/frontend/src/atlas/useAtlasNativeFileDrop.test.ts index e2dc2836b..b869d84b5 100644 --- a/frontend/src/atlas/useAtlasNativeFileDrop.test.ts +++ b/frontend/src/atlas/useAtlasNativeFileDrop.test.ts @@ -1,9 +1,15 @@ import { describe, expect, it } from 'vitest' import { resolveFileDropKind } from './useAtlasNativeFileDrop' +import type { ThirdPartyNounShape } from './atlasNounRegistry' const alwaysEnabled = () => true const alwaysDisabled = () => false +// Only the fields the drop router reads -- claim-lookup tests inject +// this instead of registering into the real third-party registry. +const claimedNoun = { id: 'bookmark', boardObjectKind: 'bookmark', fileExtensions: ['.webloc'] } as unknown as ThirdPartyNounShape +const claimLookup = (ext: string) => (claimedNoun.fileExtensions.includes(ext) ? claimedNoun : undefined) + describe('resolveFileDropKind (goal 0237 S3 rider)', () => { it('routes a diagram path to "diagram" when the diagram extension is enabled', () => { expect(resolveFileDropKind('/tmp/plan.drawio', alwaysEnabled)).toBe('diagram') @@ -36,4 +42,22 @@ describe('resolveFileDropKind (goal 0237 S3 rider)', () => { it('falls an unrelated extension through to "card"', () => { expect(resolveFileDropKind('/tmp/notes.md', alwaysEnabled)).toBe('card') }) + + // Plugin ingestion claims (docs/goals/0251): a manifest-claimed + // extension routes to the plugin's noun -- behind every built-in + // shape, ahead of the card fallback. + it('routes a plugin-claimed extension to the claiming noun', () => { + expect(resolveFileDropKind('/tmp/site.webloc', alwaysEnabled, claimLookup)).toBe(claimedNoun) + }) + + it('never lets a plugin claim shadow a built-in shape', () => { + const greedy = () => claimedNoun + expect(resolveFileDropKind('/tmp/plan.drawio', alwaysEnabled, greedy)).toBe('diagram') + expect(resolveFileDropKind('/tmp/photo.png', alwaysEnabled, greedy)).toBe('image') + expect(resolveFileDropKind('/tmp/data.xlsx', alwaysEnabled, greedy)).toBe('sheet') + }) + + it('falls an unclaimed extension through to "card" even with claims registered', () => { + expect(resolveFileDropKind('/tmp/notes.md', alwaysEnabled, claimLookup)).toBe('card') + }) }) diff --git a/frontend/src/atlas/useAtlasNativeFileDrop.ts b/frontend/src/atlas/useAtlasNativeFileDrop.ts index 1a142d4cb..155c10728 100644 --- a/frontend/src/atlas/useAtlasNativeFileDrop.ts +++ b/frontend/src/atlas/useAtlasNativeFileDrop.ts @@ -8,6 +8,8 @@ import { isExtensionEnabled } from '../shared/extensionEnablementStore' import { useAtlasFolderImportRequestStore } from './atlasFolderImportRequest' import { FILE_DROP_EVENT_NAME, FILE_DROP_CONTEXT_BOARD } from './atlasFileDropShared' import { frameContainingPoint } from './atlasFramePoint' +import { extensionOf } from './unitRegistry' +import { thirdPartyNounForExtension, type ThirdPartyNounShape } from './atlasNounRegistry' import { isDiagramPath, useAtlasDiagramObjectCreate } from './useAtlasDiagramObjectCreate' import { isImagePath, useAtlasImageObjectCreate } from './useAtlasImageObjectCreate' import { isSheetPath, useAtlasSheetObjectCreate } from './useAtlasSheetObjectCreate' @@ -19,22 +21,29 @@ const PULSE_MS_REDUCED = 1500 // resolveFileDropKind -- the pure routing decision behind the Events.On // handler below: which board-object kind a resolved drop path lands -// as, or 'card' for the generic reference-card fallback. Pulled out as -// its own function (goal 0237 S3 rider) purely so it's Vitest-testable -// on its own -- the real OS drop GESTURE that reaches it is a -// structural e2e gap (testing.md's own manual-only registry: +// as, a runtime plugin's noun when its manifest claims the extension +// (docs/goals/0251 -- always BEHIND the built-in shapes, ahead of the +// card fallback, so a plugin can extend routing but never shadow a +// built-in), or 'card' for the generic reference-card fallback. Pulled +// out as its own function (goal 0237 S3 rider) purely so it's +// Vitest-testable on its own -- the real OS drop GESTURE that reaches +// it is a structural e2e gap (testing.md's own manual-only registry: // WindowFilesDropped needs a real *WebviewWindow, which server-mode // Playwright's connection is not), but this decision is plain data in, -// data out. isEnabled is injected rather than read from the store -// directly so a test can drive both branches without touching global -// state. +// data out. isEnabled and the claim lookup are injected rather than +// read from the stores directly so a test can drive every branch +// without touching global state. export type FileDropKind = 'diagram' | 'image' | 'sheet' | 'card' -export function resolveFileDropKind(path: string, isEnabled: (id: string) => boolean): FileDropKind { +export function resolveFileDropKind( + path: string, + isEnabled: (id: string) => boolean, + claimedNounForExtension: (ext: string) => ThirdPartyNounShape | undefined = thirdPartyNounForExtension, +): FileDropKind | ThirdPartyNounShape { if (isDiagramPath(path) && isEnabled('diagram')) return 'diagram' if (isImagePath(path)) return 'image' if (isSheetPath(path) && isEnabled('sheet')) return 'sheet' - return 'card' + return claimedNounForExtension(extensionOf(path)) ?? 'card' } // The board-context half of the native OS file-drop door (goal 0081 @@ -92,7 +101,16 @@ export function useAtlasNativeFileDrop({ parentID, topLevelBoxes, screenToFlowPo // rider -- neither noun has a tray button to hide) falls the // drop through to the plain-card path instead, exactly the // resolveFileDropKind decision above states. - switch (resolveFileDropKind(path, isExtensionEnabled)) { + const verdict = resolveFileDropKind(path, isExtensionEnabled) + if (typeof verdict === 'object') { + // A claimed-extension drop lands the plugin's own object + // through the same file-backed payload contract diagram/ + // sheet use (mirrorPath + title) -- the file stays where + // it is, the object points at it (docs/goals/0251). + return AtlasService.CreateBoardObject(verdict.boardObjectKind, { ...verdict.defaultPayload, mirrorPath: path, title: titleFromFilename(path) }, { X: point.x, Y: point.y }, targetParentID) + .then(() => refreshAtlas()) + } + switch (verdict) { case 'diagram': return diagramCreate.land(path, targetParentID, { X: point.x, Y: point.y }) case 'image': diff --git a/frontend/src/locales/en/atlas/shared.json b/frontend/src/locales/en/atlas/shared.json index 3bc6e46b3..a5bc9f36f 100644 --- a/frontend/src/locales/en/atlas/shared.json +++ b/frontend/src/locales/en/atlas/shared.json @@ -404,6 +404,8 @@ "links_other": "{{count}} links", "images_one": "{{count}} image", "images_other": "{{count}} images", + "pluginObjects_one": "{{count}} object", + "pluginObjects_other": "{{count}} objects", "pagesSkipped_one": "{{count}} page couldn't be read: {{pages}}", "pagesSkipped_other": "{{count}} pages couldn't be read: {{pages}}" }, diff --git a/frontend/src/locales/en/views.json b/frontend/src/locales/en/views.json index 9c4a7833c..c3c736cc2 100644 --- a/frontend/src/locales/en/views.json +++ b/frontend/src/locales/en/views.json @@ -47,6 +47,8 @@ "installHint": "Copy a plugin's folder here, then reload.", "noPlugins": "No plugins installed yet.", "pluginCapabilities": "Can request: {{list}}", + "pluginCatchesFiles": "Catches {{list}} files dropped on the board", + "pluginCatchesLinks": "Catches web links pasted on the board", "pluginDisabledNote": "Turned off. Turn on and reload to load it.", "pluginToggleAria": "Enable {{name}}" }, diff --git a/frontend/src/plugins/hostApi.ts b/frontend/src/plugins/hostApi.ts index f5dce4d0c..f3f003ec3 100644 --- a/frontend/src/plugins/hostApi.ts +++ b/frontend/src/plugins/hostApi.ts @@ -3,6 +3,7 @@ import type { Icon } from '@primer/octicons-react' import { registerThirdPartyNoun } from '../atlas/atlasNounRegistry' import { PluginService } from '../../bindings/github.com/alicoding/mill/internal/services/pluginsvc' import type { Manifest } from '../../bindings/github.com/alicoding/mill/internal/services/pluginsvc/models' +import { ingestionClaimMismatch } from './ingestionClaims' import { collectPluginCommand } from './pluginCommands' import { pluginFaceComponent } from './PluginFaceContent' import type { CanvasObjectDecl, MillPluginAPI } from './sdk' @@ -42,12 +43,16 @@ export function buildPluginAPI(manifest: Manifest, millVersion: string): MillPlu if (!SOURCES.has(decl.source)) throw new Error(`plugin ${pluginId}: unknown source "${decl.source}"`) if (!EDIT_ROUTES.has(decl.editRoute)) throw new Error(`plugin ${pluginId}: unknown editRoute "${decl.editRoute}"`) if (typeof decl.renderFace !== 'function') throw new Error(`plugin ${pluginId}: renderFace must be a function`) + const contribution = (manifest.contributes?.canvasObjects ?? []).find((c) => c.kind === decl.kind) + const claimError = ingestionClaimMismatch(contribution, decl.source) + if (claimError) throw new Error(`plugin ${pluginId}: ${claimError}`) registerThirdPartyNoun({ id: decl.kind, interaction: 'arm-then-click', thirdParty: true, pluginId, defaultPayload: { ...(decl.defaultPayload ?? {}) }, + fileExtensions: (contribution?.fileExtensions ?? []).map((e) => e.toLowerCase()), icon: emojiIcon(decl.icon), label: decl.label, nounName: decl.label, diff --git a/frontend/src/plugins/ingestionClaims.test.ts b/frontend/src/plugins/ingestionClaims.test.ts new file mode 100644 index 000000000..6c5313839 --- /dev/null +++ b/frontend/src/plugins/ingestionClaims.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { ingestionClaimMismatch } from './ingestionClaims' +import type { CanvasObjectContribution } from '../../bindings/github.com/alicoding/mill/internal/services/pluginsvc/models' + +const claim = (partial: Partial): CanvasObjectContribution => + ({ kind: 'bookmark', fileExtensions: null, pastesURLs: false, ...partial }) as CanvasObjectContribution + +describe('ingestionClaimMismatch (goal 0251)', () => { + it('accepts an object with no contribution at all', () => { + expect(ingestionClaimMismatch(undefined, 'board-local')).toBeNull() + }) + + it('accepts matched pairings', () => { + expect(ingestionClaimMismatch(claim({ fileExtensions: ['.webloc'] }), 'file')).toBeNull() + expect(ingestionClaimMismatch(claim({ pastesURLs: true }), 'url')).toBeNull() + expect(ingestionClaimMismatch(claim({}), 'board-local')).toBeNull() + }) + + it('rejects a file-extension claim on a non-file source, naming the kind', () => { + const err = ingestionClaimMismatch(claim({ fileExtensions: ['.webloc'] }), 'url') + expect(err).toContain('bookmark') + expect(err).toContain('"file"') + }) + + it('rejects a pasted-links claim on a non-url source, naming the kind', () => { + const err = ingestionClaimMismatch(claim({ pastesURLs: true }), 'board-local') + expect(err).toContain('bookmark') + expect(err).toContain('"url"') + }) +}) diff --git a/frontend/src/plugins/ingestionClaims.ts b/frontend/src/plugins/ingestionClaims.ts new file mode 100644 index 000000000..9a058f947 --- /dev/null +++ b/frontend/src/plugins/ingestionClaims.ts @@ -0,0 +1,20 @@ +import type { CanvasObjectContribution } from '../../bindings/github.com/alicoding/mill/internal/services/pluginsvc/models' + +// ingestionClaimMismatch enforces the claim/source pairing +// (docs/goals/0251): an ingestion claim's payload shape derives from +// the object's declared source (fileExtensions land mirrorPath on a +// file-backed object; pastesURLs lands url on a url-backed one), so a +// mismatched pairing would route content into a payload key the +// object never reads. Failing the LOAD with a stated reason keeps the +// claim from being silently dead. Its own module (type-only bindings +// import) so the unit test never evaluates the runtime-bound host API. +export function ingestionClaimMismatch(contribution: CanvasObjectContribution | undefined, source: 'board-local' | 'url' | 'file'): string | null { + if (!contribution) return null + if ((contribution.fileExtensions?.length ?? 0) > 0 && source !== 'file') { + return `the manifest claims file extensions for "${contribution.kind}", so its source must be "file"` + } + if (contribution.pastesURLs && source !== 'url') { + return `the manifest claims pasted links for "${contribution.kind}", so its source must be "url"` + } + return null +} diff --git a/frontend/src/views/ExtensionsInstalledPlugins.tsx b/frontend/src/views/ExtensionsInstalledPlugins.tsx index 7a3719ad7..f0bea7655 100644 --- a/frontend/src/views/ExtensionsInstalledPlugins.tsx +++ b/frontend/src/views/ExtensionsInstalledPlugins.tsx @@ -15,6 +15,17 @@ import styles from '../shared/ListCard.module.css' // disabled, or visibly broken with the exact reason. The install // story lives here too: the folder is one click away, and a fresh // install takes effect on reload (plugins load at app start). +// The row states a plugin's ingestion claims (docs/goals/0251) so +// what a plugin catches is visible before it ever runs -- the same +// declare-first posture the capabilities line carries. +function claimedExtensions(p: PluginInfo): string[] { + return (p.Manifest.contributes?.canvasObjects ?? []).flatMap((c) => c.fileExtensions ?? []) +} + +function claimsURLPastes(p: PluginInfo): boolean { + return (p.Manifest.contributes?.canvasObjects ?? []).some((c) => c.pastesURLs) +} + export function ExtensionsInstalledPlugins() { const { t } = useTranslation('views') const disabledIds = useExtensionEnablementStore((s) => s.disabledExtensionIds) @@ -79,6 +90,16 @@ export function ExtensionsInstalledPlugins() { {t('settings.extensions.pluginCapabilities', { list: (p.Manifest.capabilities ?? []).join(', ') })} )} + {claimedExtensions(p).length > 0 && ( + + {t('settings.extensions.pluginCatchesFiles', { list: claimedExtensions(p).join(', ') })} + + )} + {claimsURLPastes(p) && ( + + {t('settings.extensions.pluginCatchesLinks')} + + )} {error && ( diff --git a/internal/services/atlassvc/atlaspastebuild.go b/internal/services/atlassvc/atlaspastebuild.go index 19d752697..5d1a429b6 100644 --- a/internal/services/atlassvc/atlaspastebuild.go +++ b/internal/services/atlassvc/atlaspastebuild.go @@ -170,83 +170,6 @@ func contains(list []string, v string) bool { return false } -// PasteResult reports what a paste became; Recognized=false means the -// text wasn't diagram-shaped and nothing was created. SkippedPages -// names any multi-page source page that failed to decode -- non-empty -// only when Recognized is also true, since a page can only be skipped -// out of a file that WAS recognized as one. -type PasteResult struct { - Recognized bool - Cards int - Links int - Tables int - // Images counts a paste landing an "image" board object (goal 0179 - // Slice 0, atlaspasteimage.go) -- always 0 or 1, since a single - // paste names exactly one path or URL. - Images int - SkippedPages []string -} - -// WirePasteListWrites installs the Configure-owned write seams the -// table conversion runs through (wired from the composition root, -// backend.md's injected-func rule). -// -//wails:ignore -func (a *AtlasService) WirePasteListWrites(factory func(label string, columns []typedfield.Field) (string, error), appendRow func(listID string, values map[string]string) error) { - a.pasteListFactory = factory - a.pasteRowAppender = appendRow -} - -// pasteRecognizer is one entry in the paste door's own ordered -// recognizer chain (docs/goals/0218). Each tries to recognize (text, -// html) and, on a match, performs the create and returns ok=true; -// ok=false means "not this shape," letting PasteToBoard try the next -// entry. Uniform signature (both text and html handed to every entry, -// even the ones that only read one) so the chain below stays a plain -// slice literal -- adding a clipboard shape is one new function plus -// one new line in pasteRecognizers, never a re-architecture. -type pasteRecognizer func(a *AtlasService, text, html, parentID string, pos atlas.Position) (PasteResult, bool, error) - -// pasteRecognizers is the paste door's one ordered chain: drawio XML -// (a diagramming tool's own clipboard payload) -> HTML table (an M365 -// app's copied table) -> TSV (a spreadsheet range) -> an image path/URL -// (atlaspasteimage.go, goal 0179 Slice 0). The image entry runs LAST of -// the Go-side recognizers -- each earlier entry demands specific markup -// (mxGraph XML, an HTML , tab-separated columns) a bare path or -// URL string never carries, so checking it first would cost every other -// shape a wasted url.Parse/os.Stat for nothing; ordering it after them -// costs nothing since their own detection already rejects a bare -// string immediately. When NONE recognize the payload, the frontend's -// own paste handler lands the pasted content as a note at the pointer -// instead -- the named last resort, never a card (docs/goals/0179, -// 0218). -var pasteRecognizers = []pasteRecognizer{ - recognizeDrawioPaste, - recognizeHTMLTablePaste, - recognizeTSVPaste, - recognizeImagePaste, -} - -// PasteToBoard converts understood clipboard content into entities -// under parentID, starting placement at (x, y). A user's own paste is -// a direct edit -- ungated, like every direct create. -func (a *AtlasService) PasteToBoard(text, html, parentID string, x, y float64) (PasteResult, error) { - pos := atlas.Position{X: x, Y: y} - // A multi-table/multi-card paste lands as ONE undo step (ADR-0044 - // decision 2's "multi-paste landing") -- every entity this call - // creates already journals through CreateBoardObject/CreateCard/ - // CreateLink individually; grouping them here is the only change - // needed. - a.BeginUndoMark() - defer a.EndUndoMark() - for _, recognize := range pasteRecognizers { - if res, ok, err := recognize(a, text, html, parentID, pos); ok { - return res, err - } - } - return PasteResult{}, nil -} - // pasteMultiTable lands one board object per table, offset like the // multi-page drawio precedent -- shared by every recognizer that can // produce more than one table from a single paste (HTML, and drawio's diff --git a/internal/services/atlassvc/atlaspastedoor.go b/internal/services/atlassvc/atlaspastedoor.go new file mode 100644 index 000000000..d8dbe7fb7 --- /dev/null +++ b/internal/services/atlassvc/atlaspastedoor.go @@ -0,0 +1,96 @@ +package atlassvc + +import ( + "github.com/alicoding/mill/internal/domain/atlas" + "github.com/alicoding/mill/internal/domain/typedfield" +) + +// The paste DOOR itself (docs/goals/0218): the wire-shape result, the +// ordered recognizer chain, and the one bound entry point -- split +// from atlaspastebuild.go, which owns the drawio-model-to-primitives +// conversion the first recognizers delegate to. + +// PasteResult reports what a paste became; Recognized=false means the +// text wasn't diagram-shaped and nothing was created. SkippedPages +// names any multi-page source page that failed to decode -- non-empty +// only when Recognized is also true, since a page can only be skipped +// out of a file that WAS recognized as one. +type PasteResult struct { + Recognized bool + Cards int + Links int + Tables int + // Images counts a paste landing an "image" board object (goal 0179 + // Slice 0, atlaspasteimage.go) -- always 0 or 1, since a single + // paste names exactly one path or URL. + Images int + // PluginObjects counts a paste landing a runtime plugin's claimed + // board object (docs/goals/0251, atlaspasteplugin.go) -- always 0 + // or 1, same single-payload property Images documents. + PluginObjects int + SkippedPages []string +} + +// WirePasteListWrites installs the Configure-owned write seams the +// table conversion runs through (wired from the composition root, +// backend.md's injected-func rule). +// +//wails:ignore +func (a *AtlasService) WirePasteListWrites(factory func(label string, columns []typedfield.Field) (string, error), appendRow func(listID string, values map[string]string) error) { + a.pasteListFactory = factory + a.pasteRowAppender = appendRow +} + +// pasteRecognizer is one entry in the paste door's own ordered +// recognizer chain (docs/goals/0218). Each tries to recognize (text, +// html) and, on a match, performs the create and returns ok=true; +// ok=false means "not this shape," letting PasteToBoard try the next +// entry. Uniform signature (both text and html handed to every entry, +// even the ones that only read one) so the chain below stays a plain +// slice literal -- adding a clipboard shape is one new function plus +// one new line in pasteRecognizers, never a re-architecture. +type pasteRecognizer func(a *AtlasService, text, html, parentID string, pos atlas.Position) (PasteResult, bool, error) + +// pasteRecognizers is the paste door's one ordered chain: drawio XML +// (a diagramming tool's own clipboard payload) -> HTML table (an M365 +// app's copied table) -> TSV (a spreadsheet range) -> an image path/URL +// (atlaspasteimage.go, goal 0179 Slice 0). The image entry runs LAST of +// the Go-side recognizers -- each earlier entry demands specific markup +// (mxGraph XML, an HTML
, tab-separated columns) a bare path or +// URL string never carries, so checking it first would cost every other +// shape a wasted url.Parse/os.Stat for nothing; ordering it after them +// costs nothing since their own detection already rejects a bare +// string immediately. recognizePluginURLPaste (docs/goals/0251, +// atlaspasteplugin.go) runs after image so a pasted image URL still +// lands as an image -- a runtime plugin's claim can extend the chain +// but never shadow a built-in shape. When NONE recognize the payload, +// the frontend's own paste handler lands the pasted content as a note +// at the pointer instead -- the named last resort, never a card +// (docs/goals/0179, 0218). +var pasteRecognizers = []pasteRecognizer{ + recognizeDrawioPaste, + recognizeHTMLTablePaste, + recognizeTSVPaste, + recognizeImagePaste, + recognizePluginURLPaste, +} + +// PasteToBoard converts understood clipboard content into entities +// under parentID, starting placement at (x, y). A user's own paste is +// a direct edit -- ungated, like every direct create. +func (a *AtlasService) PasteToBoard(text, html, parentID string, x, y float64) (PasteResult, error) { + pos := atlas.Position{X: x, Y: y} + // A multi-table/multi-card paste lands as ONE undo step (ADR-0044 + // decision 2's "multi-paste landing") -- every entity this call + // creates already journals through CreateBoardObject/CreateCard/ + // CreateLink individually; grouping them here is the only change + // needed. + a.BeginUndoMark() + defer a.EndUndoMark() + for _, recognize := range pasteRecognizers { + if res, ok, err := recognize(a, text, html, parentID, pos); ok { + return res, err + } + } + return PasteResult{}, nil +} diff --git a/internal/services/atlassvc/atlaspasteplugin.go b/internal/services/atlassvc/atlaspasteplugin.go new file mode 100644 index 000000000..6f982f01f --- /dev/null +++ b/internal/services/atlassvc/atlaspasteplugin.go @@ -0,0 +1,56 @@ +package atlassvc + +import ( + "strings" + + "github.com/alicoding/mill/internal/domain/atlas" +) + +// PluginPasteClaim is one plugin's declared claim on bare-URL pastes +// (docs/goals/0251) as the paste chain sees it -- just the +// board-object kind to land. Which plugins are valid and enabled is +// the composition root's concern (wiring.WirePluginIngestion), never +// this package's: atlassvc must not import pluginsvc (backend.md's +// injected-func rule). +type PluginPasteClaim struct { + Kind string +} + +// WirePluginPasteClaims installs the claims lookup, called fresh on +// every paste so a plugin installed or toggled mid-session is honored +// without restart on this side. +// +//wails:ignore +func (a *AtlasService) WirePluginPasteClaims(claims func() []PluginPasteClaim) { + a.pluginPasteClaims = claims +} + +// recognizePluginURLPaste is the recognizer chain's LAST entry +// (docs/goals/0251): a single-token bare http(s) URL lands as the +// first claiming plugin's own board object, with the url-source +// contract's payload (url + title=host). Ordered after +// recognizeImagePaste so a pasted image URL still lands as an image; +// with no claims wired (or none matching) the paste falls through to +// the frontend's note fallback exactly as before, so built-in +// behavior is unchanged until a plugin actually claims URLs. +func recognizePluginURLPaste(a *AtlasService, text, _, parentID string, pos atlas.Position) (PasteResult, bool, error) { + if a.pluginPasteClaims == nil { + return PasteResult{}, false, nil + } + candidate := strings.TrimSpace(text) + if candidate == "" || strings.ContainsAny(candidate, " \t\n\r") { + return PasteResult{}, false, nil + } + u, ok := parsedHTTPURL(candidate) + if !ok { + return PasteResult{}, false, nil + } + claims := a.pluginPasteClaims() + if len(claims) == 0 { + return PasteResult{}, false, nil + } + if _, err := a.CreateBoardObject(claims[0].Kind, map[string]string{"url": candidate, "title": u.Host}, pos, parentID); err != nil { + return PasteResult{}, true, err + } + return PasteResult{Recognized: true, PluginObjects: 1}, true, nil +} diff --git a/internal/services/atlassvc/atlaspasteplugin_test.go b/internal/services/atlassvc/atlaspasteplugin_test.go new file mode 100644 index 000000000..cc8c66c57 --- /dev/null +++ b/internal/services/atlassvc/atlaspasteplugin_test.go @@ -0,0 +1,95 @@ +package atlassvc + +import ( + "testing" +) + +func wireBookmarkClaim(a *AtlasService) { + a.WirePluginPasteClaims(func() []PluginPasteClaim { + return []PluginPasteClaim{{Kind: "bookmark"}} + }) +} + +// A bare URL paste with a wired claim lands the claiming kind's board +// object carrying the url-source payload contract (url + title=host). +func TestPasteToBoard_PluginURLClaim_LandsClaimedObject(t *testing.T) { + a := newTestAtlasService(t) + wireBookmarkClaim(a) + res, err := a.PasteToBoard("https://example.com/some/page", "", "", 40, 50) + if err != nil { + t.Fatalf("PasteToBoard: %v", err) + } + if !res.Recognized || res.PluginObjects != 1 { + t.Fatalf("result = %+v, want recognized with 1 plugin object", res) + } + var found bool + for _, got := range a.Objects() { + if got.Kind != "bookmark" { + continue + } + found = true + if got.Payload["url"] != "https://example.com/some/page" { + t.Errorf("Payload[url] = %q, want the pasted URL", got.Payload["url"]) + } + if got.Payload["title"] != "example.com" { + t.Errorf("Payload[title] = %q, want the host", got.Payload["title"]) + } + if got.Position.X != 40 || got.Position.Y != 50 { + t.Errorf("Position = %+v, want the paste point (40,50)", got.Position) + } + } + if !found { + t.Fatal("no bookmark object landed") + } +} + +// With no claims wired (or none returned), a URL paste stays +// unrecognized so the frontend's note fallback still lands it. +func TestPasteToBoard_PluginURLClaim_NoClaimsFallsThrough(t *testing.T) { + a := newTestAtlasService(t) + res, err := a.PasteToBoard("https://example.com/x", "", "", 0, 0) + if err != nil || res.Recognized { + t.Fatalf("unwired result = %+v err=%v, want unrecognized", res, err) + } + + a.WirePluginPasteClaims(func() []PluginPasteClaim { return nil }) + res, err = a.PasteToBoard("https://example.com/x", "", "", 0, 0) + if err != nil || res.Recognized { + t.Fatalf("empty-claims result = %+v err=%v, want unrecognized", res, err) + } +} + +// A claim only catches a single bare URL token -- prose containing a +// URL, a multi-line paste, or a non-URL string all stay note-bound. +func TestPasteToBoard_PluginURLClaim_OnlyBareURLs(t *testing.T) { + a := newTestAtlasService(t) + wireBookmarkClaim(a) + for _, text := range []string{ + "see https://example.com for details", + "https://example.com/x\nhttps://example.com/y", + "not a url at all", + "ftp://example.com/file", + } { + res, err := a.PasteToBoard(text, "", "", 0, 0) + if err != nil || res.Recognized { + t.Errorf("paste %q = %+v err=%v, want unrecognized", text, res, err) + } + } +} + +// The chain's built-in entries stay ahead of plugin claims: an image +// URL still lands an image object, and TSV still lands a table, even +// with a URL claim wired. +func TestPasteToBoard_PluginURLClaim_BuiltInsWinFirst(t *testing.T) { + a := newTestAtlasService(t) + a.SetCapturesDir(t.TempDir()) + wireBookmarkClaim(a) + a.imageURLFetcher = func(string) ([]byte, error) { return pngFixtureBytes, nil } + res, err := a.PasteToBoard("https://example.com/pic.png", "", "", 0, 0) + if err != nil { + t.Fatalf("image URL paste: %v", err) + } + if !res.Recognized || res.Images != 1 || res.PluginObjects != 0 { + t.Fatalf("image URL result = %+v, want the image entry to win", res) + } +} diff --git a/internal/services/atlassvc/atlasservice.go b/internal/services/atlassvc/atlasservice.go index 137c07c6b..d4f29c1e5 100644 --- a/internal/services/atlassvc/atlasservice.go +++ b/internal/services/atlassvc/atlasservice.go @@ -84,6 +84,11 @@ type AtlasService struct { // overwrites this field directly so no test suite ever makes a // real network call. imageURLFetcher func(rawURL string) ([]byte, error) + // pluginPasteClaims resolves runtime plugins' bare-URL paste + // claims (docs/goals/0251, atlaspasteplugin.go) -- nil-means-off + // like pasteListFactory above; wired from the composition root + // (wiring.WirePluginIngestion). + pluginPasteClaims func() []PluginPasteClaim cards []atlas.Card links []atlas.Link notes []atlas.Note diff --git a/internal/services/atlassvc/atlasundo_doors.go b/internal/services/atlassvc/atlasundo_doors.go index 53339ba8f..6da7dc35e 100644 --- a/internal/services/atlassvc/atlasundo_doors.go +++ b/internal/services/atlassvc/atlasundo_doors.go @@ -133,5 +133,6 @@ var notMutationDoors = map[string]string{ "TableProjectionExport": "read/export", "UndoState": "read", "WireCompositionSeams": "wails:ignore wiring call", "WireFileDropWindow": "wails:ignore wiring call", "WireListProjection": "wails:ignore wiring call", "WirePasteListWrites": "wails:ignore wiring call", + "WirePluginPasteClaims": "wails:ignore wiring call", "WireSourceRecognition": "wails:ignore wiring call", } diff --git a/internal/services/pluginsvc/pluginservice.go b/internal/services/pluginsvc/pluginservice.go index 2777d9e5b..40f72b03a 100644 --- a/internal/services/pluginsvc/pluginservice.go +++ b/internal/services/pluginsvc/pluginservice.go @@ -23,17 +23,41 @@ import ( ) // Manifest is the converged plugin manifest shape (docs/adr/0047 §1: -// identity metadata + a declared capability set; contributions happen -// at activate() time through the host API, so they are not restated -// here). +// identity metadata + a declared capability set). Rendering +// contributions happen at activate() time through the host API, so +// they are not restated here; INGESTION claims are the deliberate +// exception (docs/goals/0251) -- both ingestion chains must consult +// them without running plugin code, so they live in Contributes. type Manifest struct { - ID string `json:"id"` - Name string `json:"name"` - Version string `json:"version"` - Description string `json:"description"` - Author string `json:"author"` - MinMillVersion string `json:"minMillVersion"` - Capabilities []string `json:"capabilities"` + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Author string `json:"author"` + MinMillVersion string `json:"minMillVersion"` + Capabilities []string `json:"capabilities"` + Contributes ManifestContributes `json:"contributes"` +} + +// ManifestContributes is the manifest's declarative contribution +// point (docs/goals/0251, the VSCode-shaped convention): data other +// parts of Mill read to ROUTE to a plugin, distinct from capabilities +// (what a plugin may ask to do). +type ManifestContributes struct { + CanvasObjects []CanvasObjectContribution `json:"canvasObjects"` +} + +// CanvasObjectContribution claims the ingestion doors for one canvas +// object kind: which dropped-file extensions and which clipboard +// shapes land as this plugin's object. Payload shape is not declared +// here -- it derives from the object's own registered source (a +// fileExtensions claim requires a file-backed object landing +// mirrorPath+title; PastesURLs requires a url-backed one landing +// url+title), enforced host-side at registration. +type CanvasObjectContribution struct { + Kind string `json:"kind"` + FileExtensions []string `json:"fileExtensions"` + PastesURLs bool `json:"pastesURLs"` } // PluginInfo is one scanned plugin as the Extensions surface and the @@ -166,9 +190,68 @@ func (p *PluginService) scanOne(folder string) PluginInfo { } } } + if info.Error == "" { + info.Error = validateContributes(m.Contributes) + } return info } +// fileExtensionPattern pins a contributed extension claim to the +// ".ext" shape the drop router compares against (unitRegistry's own +// extensionOf yields a lowercased dot-prefixed extension). +var fileExtensionPattern = regexp.MustCompile(`^\.[a-z0-9]+$`) + +// validateContributes fail-closes ingestion claims the same way an +// unknown capability does: a malformed claim blocks the load with a +// human-readable reason, never routes half-right. +func validateContributes(c ManifestContributes) string { + for _, obj := range c.CanvasObjects { + if !pluginIDPattern.MatchString(obj.Kind) { + return fmt.Sprintf("contributed canvas object kind %q must be lowercase letters, digits, and hyphens", obj.Kind) + } + for _, ext := range obj.FileExtensions { + if !fileExtensionPattern.MatchString(ext) { + return fmt.Sprintf("contributed file extension %q must look like \".ext\" in lowercase", ext) + } + } + } + return "" +} + +// IngestionClaim is one valid plugin's claim on bare-URL pastes as +// the paste chain's wiring consumes it (docs/goals/0251). +type IngestionClaim struct { + PluginID string + Kind string +} + +// URLPasteClaims returns the claims of every VALID plugin whose +// manifest sets pastesURLs, in ListPlugins' own deterministic id +// order. Consulted by the paste recognizer chain through the +// composition root's enablement filter -- never by running plugin +// code: a claim only routes the paste; the plugin's JS renders the +// object it produced, later, in the webview. +// +//wails:ignore +func (p *PluginService) URLPasteClaims() []IngestionClaim { + infos, err := p.ListPlugins() + if err != nil { + return nil + } + var out []IngestionClaim + for _, info := range infos { + if info.Error != "" { + continue + } + for _, obj := range info.Manifest.Contributes.CanvasObjects { + if obj.PastesURLs { + out = append(out, IngestionClaim{PluginID: info.Manifest.ID, Kind: obj.Kind}) + } + } + } + return out +} + // GuardedActionDecision is RequestGuardedAction's wire shape. type GuardedActionDecision struct { Approved bool diff --git a/internal/services/pluginsvc/pluginservice_test.go b/internal/services/pluginsvc/pluginservice_test.go index 20e1b880d..7368cfc0f 100644 --- a/internal/services/pluginsvc/pluginservice_test.go +++ b/internal/services/pluginsvc/pluginservice_test.go @@ -132,3 +132,47 @@ func TestPerform_OpenURLRejectsNonHTTP(t *testing.T) { t.Fatalf("https open failed: ok=%v err=%v opened=%q", ok, err, opened) } } + +// Ingestion claims (docs/goals/0251) fail closed the same way an +// unknown capability does: a malformed kind or extension blocks the +// load with a stated reason. +func TestListPlugins_ValidatesContributes(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "claims-ok", `{"id":"claims-ok","name":"C","version":"1","contributes":{"canvasObjects":[{"kind":"bookmark","pastesURLs":true,"fileExtensions":[".webloc"]}]}}`, nil) + writePlugin(t, root, "bad-kind", `{"id":"bad-kind","name":"C","version":"1","contributes":{"canvasObjects":[{"kind":"Not A Slug"}]}}`, nil) + writePlugin(t, root, "bad-ext", `{"id":"bad-ext","name":"C","version":"1","contributes":{"canvasObjects":[{"kind":"thing","fileExtensions":["webloc"]}]}}`, nil) + + svc := New(root, nil) + infos, err := svc.ListPlugins() + if err != nil { + t.Fatal(err) + } + byID := map[string]PluginInfo{} + for _, i := range infos { + byID[filepath.Base(i.Dir)] = i + } + if got := byID["claims-ok"]; got.Error != "" { + t.Fatalf("claims-ok should be valid, got error %q", got.Error) + } + if got := byID["bad-kind"]; !strings.Contains(got.Error, "canvas object kind") { + t.Fatalf("bad-kind error = %q", got.Error) + } + if got := byID["bad-ext"]; !strings.Contains(got.Error, "file extension") { + t.Fatalf("bad-ext error = %q", got.Error) + } +} + +// URLPasteClaims returns only VALID plugins' claims, in id order -- +// a broken manifest or one without pastesURLs never routes a paste. +func TestURLPasteClaims_ValidClaimersOnly(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "bookmarker", `{"id":"bookmarker","name":"B","version":"1","contributes":{"canvasObjects":[{"kind":"bookmark","pastesURLs":true}]}}`, nil) + writePlugin(t, root, "no-claim", `{"id":"no-claim","name":"N","version":"1"}`, nil) + writePlugin(t, root, "broken-claimer", `{"id":"broken-claimer","name":"X","version":"1","capabilities":["format-disk"],"contributes":{"canvasObjects":[{"kind":"thing","pastesURLs":true}]}}`, nil) + + svc := New(root, nil) + claims := svc.URLPasteClaims() + if len(claims) != 1 || claims[0].PluginID != "bookmarker" || claims[0].Kind != "bookmark" { + t.Fatalf("URLPasteClaims() = %+v, want exactly bookmarker/bookmark", claims) + } +} diff --git a/internal/services/wiring/plugins.go b/internal/services/wiring/plugins.go index ddc1e7648..c41d6f90d 100644 --- a/internal/services/wiring/plugins.go +++ b/internal/services/wiring/plugins.go @@ -5,9 +5,13 @@ import ( "os" "path/filepath" + "github.com/alicoding/mill/internal/services/atlassvc" "github.com/alicoding/mill/internal/services/guardrailsvc" + "github.com/alicoding/mill/internal/services/notificationsvc" "github.com/alicoding/mill/internal/services/pluginsvc" "github.com/alicoding/mill/internal/services/remoteauthsvc" + "github.com/alicoding/mill/internal/services/settingssvc" + "github.com/alicoding/mill/internal/services/triggersvc" ) // NewPluginService resolves the plugins directory and constructs the @@ -32,3 +36,36 @@ func ComposedAssetMiddleware(remoteAuth *remoteauthsvc.RemoteAuthService, plugin return AssetMiddleware(remoteAuth)(plugins.AssetMiddleware()(next)) } } + +// WireSettingsEraSeams bundles the cross-service seams that can only +// exist once SettingsService is constructed (main.go calls it as one +// line right after that construction -- composition-root grouping, +// the backupsvc.Wire shape): notification channels, the phone +// channel, update trigger events, and plugin ingestion claims. +func WireSettingsEraSeams(settings *settingssvc.SettingsService, notif *notificationsvc.NotificationService, remoteAuth *remoteauthsvc.RemoteAuthService, triggers *triggersvc.TriggerService, atlas *atlassvc.AtlasService, plugins *pluginsvc.PluginService) { + WireNotificationChannels(settings, notif) // docs/goals/0171-notification-spine.md + WirePhoneChannel(remoteAuth, notif) // docs/goals/0132-remote-access.md SLICE B + WireUpdateEvents(settings, triggers) + WirePluginIngestion(atlas, plugins, settings) // docs/goals/0251-plugin-ingestion-claims.md +} + +// WirePluginIngestion connects the paste chain's plugin-claims seam +// (docs/goals/0251): every valid manifest claiming bare-URL pastes, +// minus plugins the user has turned off -- the SAME disabled- +// extensions list the frontend loader consults, keyed by plugin id, +// so both ingestion chains and the tray agree on what "off" means. +func WirePluginIngestion(atlas *atlassvc.AtlasService, plugins *pluginsvc.PluginService, settings *settingssvc.SettingsService) { + atlas.WirePluginPasteClaims(func() []atlassvc.PluginPasteClaim { + disabled := map[string]bool{} + for _, id := range settings.GetDisabledExtensions() { + disabled[id] = true + } + var out []atlassvc.PluginPasteClaim + for _, c := range plugins.URLPasteClaims() { + if !disabled[c.PluginID] { + out = append(out, atlassvc.PluginPasteClaim{Kind: c.Kind}) + } + } + return out + }) +} diff --git a/main.go b/main.go index 9b8ac4860..72277515c 100644 --- a/main.go +++ b/main.go @@ -258,9 +258,7 @@ func main() { remoteAuthService := wiring.WireRemoteAuth(settingsStore, logger) // docs/goals/0132-remote-access.md SLICE 1 settingsService := settingssvc.NewSettingsService(settingsStore, triggerService, settingsPath != defaultSettingsPath) - wiring.WireNotificationChannels(settingsService, notificationService) // docs/goals/0171-notification-spine.md - wiring.WirePhoneChannel(remoteAuthService, notificationService) // docs/goals/0132-remote-access.md SLICE B - wiring.WireUpdateEvents(settingsService, triggerService) + wiring.WireSettingsEraSeams(settingsService, notificationService, remoteAuthService, triggerService, atlasService, pluginService) settingsService.SetAppVersion(millUpdateVersion) // The user's persisted channel opt-in wins over the build stamp -- // a source-built copy can deliberately follow the beta feed diff --git a/scripts/check-go-coverage.sh b/scripts/check-go-coverage.sh index b7c2212a8..c74c30997 100755 --- a/scripts/check-go-coverage.sh +++ b/scripts/check-go-coverage.sh @@ -16,7 +16,7 @@ PROFILE="${1:?usage: check-go-coverage.sh }" # latter. Re-raise only when the CI number climbs clear of the floor by # more than the observed ~0.5pt inter-PR swing, never on a single # comfortably-green reading. -FLOOR="71.0" +FLOOR="75.0" TOTAL=$(go tool cover -func="$PROFILE" | awk '/^total:/ {gsub(/%/,"",$3); print $3}') if [ -z "$TOTAL" ]; then echo "check-go-coverage: no total in $PROFILE" >&2 diff --git a/userdocs/reference/install-a-plugin.md b/userdocs/reference/install-a-plugin.md index bb34f034c..e7f9c217e 100644 --- a/userdocs/reference/install-a-plugin.md +++ b/userdocs/reference/install-a-plugin.md @@ -40,6 +40,33 @@ that — opening a web address in your browser, say — it must: Undeclared asks are refused outright. Approved actions are performed by Mill itself, never by the plugin's own code. +## Catching drops and pastes + +A plugin can claim the two ways outside content lands on the board: +a file dragged in from your file manager, and content pasted from +another app. Claims are declared in the manifest, so the Extensions +row shows what a plugin catches before it ever runs: + +```json +"contributes": { + "canvasObjects": [ + { "kind": "bookmark", "pastesURLs": true, "fileExtensions": [".webloc"] } + ] +} +``` + +- `fileExtensions` — dropped files with a listed extension land as + this plugin's object, pointing at the file where it is. Requires + `source: "file"` on the registered object. +- `pastesURLs` — a web address pasted from any app lands as this + plugin's object instead of a note. Requires `source: "url"`. + +Mill's own built-in shapes always win first — a diagram, image, or +spreadsheet file keeps landing as its built-in object — and anything +no one claims still lands the way it does today. With the Bookmark +example installed, pasting a link from your browser drops a bookmark +right on the board. + ## The example plugin Mill's repository ships a working example, **Bookmark** — a web