From 39d8df339b083073c0c33cf1b359a14360dfddf6 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sat, 27 Jun 2026 12:36:31 +0800 Subject: [PATCH] Align official plugins with v2 runtime --- docs/PACKAGING.md | 45 ++-- plugins/default-editor/frontend/src/index.js | 66 +++-- plugins/default-editor/plugin.json | 2 +- plugins/files/frontend/src/index.js | 121 +++++++-- plugins/files/plugin.json | 2 +- plugins/notes/frontend/src/index.js | 208 +++++++++------ plugins/notes/plugin.json | 7 +- plugins/platform-test/plugin.json | 2 +- plugins/sync/frontend/src/SyncSettings.svelte | 39 ++- .../sync/frontend/src/SyncStatusBar.svelte | 12 +- plugins/sync/plugin.json | 2 +- scripts/build.sh | 33 ++- scripts/check.sh | 135 ++++++++-- scripts/smoke-notes-plugin.js | 236 ++++++++++++++++++ 14 files changed, 702 insertions(+), 208 deletions(-) create mode 100755 scripts/smoke-notes-plugin.js diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index 06ec033..45240c1 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -10,11 +10,11 @@ plugin-name/ plugin.json — обязательный manifest frontend/ - package.json — npm проект (Svelte) + package.json — опционально, npm проект (Svelte/Vite) src/ - index.js — entry point + index.js — source entry point для plain JS plugins ... - dist/ — сборка frontend (npm run build) + dist/ — ignored build output для npm frontend plugins index.js style.css backend/ — опционально (Go sidecar) @@ -33,7 +33,7 @@ plugin-name/ ``` dist/ plugin-name/ - plugin.json — копируется из корня исходного плагина + plugin.json — manifest для runtime package frontend/ dist/ — только собранные файлы (копируется содержимое) index.js @@ -46,7 +46,11 @@ dist/ ### plugin.json -Копируется как из корня исходного плагина. Содержит: +Исходный `plugin.json` копируется в package. Если plain JS plugin в исходниках +указывает `frontend.entry` на `frontend/src/index.js`, `build.sh` переписывает +это поле в packaged manifest на `frontend/dist/index.js`. + +Содержит: | Поле | Назначение в dist | |---|---| @@ -59,7 +63,14 @@ dist/ ### frontend/dist -Содержимое `frontend/dist/` копируется в `dist//frontend/dist/`. Это результат `npm run build` — скомпилированные JS/CSS без map-файлов и dev-зависимостей. +Для plugins с `frontend/package.json` содержимое `frontend/dist/` копируется в +`dist//frontend/dist/`. Это результат `npm run build` — +скомпилированные JS/CSS без map-файлов и dev-зависимостей. + +Для plain JS plugins без `frontend/package.json` `build.sh` всегда копирует +tracked `frontend/src/index.js` в `dist//frontend/dist/index.js`. +Ignored source-side `frontend/dist/` не используется для таких plugins, чтобы +локальные stale build artifacts не попадали в package. ### backend binary @@ -95,25 +106,23 @@ cd ~/git/verstak2/verstak-official-plugins `build.sh` для каждого плагина в `plugins/`: 1. Проверяет `plugin.json` (JSON validation). -2. Собирает frontend: `npm install && npm run build`. +2. Собирает frontend для plugins с `frontend/package.json`: `npm install && npm run build`. 3. Собирает backend: `go build -o .`. -4. Упаковывает в `dist//` через `package_plugin()`. +4. Упаковывает в `dist//` через `package_plugin()`. Plain JS + frontend берётся из `frontend/src/index.js`, packaged manifest получает + `frontend.entry = "frontend/dist/index.js"`. ### Сборка конкретного плагина ```bash -cd ~/git/verstak2/verstak-official-plugins/plugins/platform-test -# Frontend -cd frontend && npm install && npm run build -# Backend (если есть) -cd ../backend && go build -o platform-test . -# Package -mkdir -p ../../dist/platform-test -cp plugin.json ../../dist/platform-test/ -cp -r frontend/dist/. ../../dist/platform-test/frontend/dist/ -cp backend/platform-test ../../dist/platform-test/backend/ +cd ~/git/verstak2/verstak-official-plugins +./scripts/build.sh ``` +`build.sh` intentionally packages all official plugins because packaging includes +manifest rewriting for plain JS plugins. Do not hand-copy `plugin.json` for a +plain JS plugin without applying the same `frontend.entry` rewrite. + ### Backend build details ```bash diff --git a/plugins/default-editor/frontend/src/index.js b/plugins/default-editor/frontend/src/index.js index c8fd70e..9ec072e 100644 --- a/plugins/default-editor/frontend/src/index.js +++ b/plugins/default-editor/frontend/src/index.js @@ -84,6 +84,29 @@ return escapeHtml(s).replace(/"/g, '"'); } + function cleanPath(path) { + return String(path || '').split('/').filter(Boolean).join('/'); + } + + function normalizeNoteFilename(title) { + var value = String(title == null ? '' : title).trim(); + if (/\.markdown$/i.test(value) && value.length > 9) value = value.slice(0, -9); + else if (/\.md$/i.test(value) && value.length > 3) value = value.slice(0, -3); + if (!value) throw new Error('note title must not be empty'); + value = value.replace(/\s+/g, '_'); + value = value.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-'); + value = value.replace(/[<>:"/\\|?*\x00-\x1f\x7f]/g, ''); + var out = ''; + for (var i = 0; i < value.length; i++) { + var ch = value.charAt(i); + if (/[A-Za-z0-9._-]/.test(ch) || /[\p{L}\p{N}]/u.test(ch)) out += ch; + else if (/\S/.test(ch)) out += '_'; + } + out = out.replace(/[_.-]+/g, '_').replace(/^[._\-\s]+|[._\-\s]+$/g, ''); + if (!out) throw new Error('note title normalizes to an empty filename'); + return out + '.md'; + } + function renderInline(text, isNotesContext) { var html = escapeHtml(text); // Internal wiki links [[Title]] — only render in notes context @@ -408,14 +431,7 @@ if (!dirty || disposed) return Promise.resolve(); saveState = 'saving'; updateStatus(); - var savePromise; - if (editorMode === 'notes-markdown') { - savePromise = api.backend.call('SaveNote', resourcePath, currentContent).then(function (errStr) { - if (errStr) throw new Error(errStr); - }); - } else { - savePromise = api.files.writeText(resourcePath, currentContent, { createIfMissing: false, overwrite: true }); - } + var savePromise = api.files.writeText(resourcePath, currentContent, { createIfMissing: false, overwrite: true }); return savePromise.then(function () { if (disposed) return; savedContent = currentContent; @@ -442,15 +458,7 @@ if (dirty && !window.confirm('Discard unsaved changes and reload from disk?')) return; editorWrap.innerHTML = ''; editorWrap.appendChild(el('div', { className: 'de-loading' }, ['Loading...'])); - var readPromise; - if (editorMode === 'notes-markdown') { - readPromise = api.backend.call('ReadNote', resourcePath).then(function (result) { - var content = Array.isArray(result) ? result[0] : result; - return content == null ? '' : content; - }); - } else { - readPromise = api.files.readText(resourcePath); - } + var readPromise = api.files.readText(resourcePath); readPromise.then(function (content) { if (disposed) return; currentContent = String(content == null ? '' : content); @@ -512,15 +520,23 @@ event.preventDefault(); var noteTitle = link.getAttribute('data-note-link'); if (!noteTitle) return; - var parentPath = resourcePath; - var idx = parentPath.indexOf('/Notes/'); - if (idx !== -1) { - parentPath = parentPath.substring(0, idx); - } - api.request.open({ - path: parentPath + '/Notes/' + noteTitle + '.md', + var currentPath = cleanPath(resourcePath); + var notesIdx = currentPath.indexOf('/Notes/'); + var notesRoot = notesIdx === -1 ? 'Notes' : currentPath.slice(0, notesIdx) + '/Notes'; + var targetPath = cleanPath(notesRoot + '/' + normalizeNoteFilename(noteTitle)); + api.workbench.openResource({ + kind: 'vault-file', + path: targetPath, mode: 'view', - context: { notesMode: true } + extension: '.md', + context: { + sourcePluginId: 'verstak.default-editor', + sourceView: 'editor', + isInsideNotesFolder: true, + notesMode: true + } + }).catch(function (err) { + console.error('[default-editor] open internal link:', err); }); }); diff --git a/plugins/default-editor/plugin.json b/plugins/default-editor/plugin.json index e81c2f3..72b678f 100644 --- a/plugins/default-editor/plugin.json +++ b/plugins/default-editor/plugin.json @@ -20,7 +20,7 @@ "workbench.open" ], "frontend": { - "entry": "frontend/dist/index.js" + "entry": "frontend/src/index.js" }, "contributes": { "openProviders": [ diff --git a/plugins/files/frontend/src/index.js b/plugins/files/frontend/src/index.js index a5b2a58..8d00f0d 100644 --- a/plugins/files/frontend/src/index.js +++ b/plugins/files/frontend/src/index.js @@ -145,6 +145,35 @@ return dot > 0 ? name.slice(dot + 1).toLowerCase() : ''; } + function normalizeNoteFilename(title) { + var value = String(title == null ? '' : title).trim(); + if (/\.markdown$/i.test(value) && value.length > 9) value = value.slice(0, -9); + else if (/\.md$/i.test(value) && value.length > 3) value = value.slice(0, -3); + if (!value) throw new Error('note title must not be empty'); + value = value.replace(/\s+/g, '_'); + value = value.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-'); + value = value.replace(/[<>:"/\\|?*\x00-\x1f\x7f]/g, ''); + var out = ''; + for (var i = 0; i < value.length; i++) { + var ch = value.charAt(i); + if (/[A-Za-z0-9._-]/.test(ch) || /[\p{L}\p{N}]/u.test(ch)) out += ch; + else if (/\S/.test(ch)) out += '_'; + } + out = out.replace(/[_.-]+/g, '_').replace(/^[._\-\s]+|[._\-\s]+$/g, ''); + if (!out) throw new Error('note title normalizes to an empty filename'); + return out + '.md'; + } + + function isConflictError(err) { + var msg = (err && err.message) ? err.message : String(err || ''); + return /conflict|already exists|exists/i.test(msg); + } + + function isNotFoundError(err) { + var msg = (err && err.message) ? err.message : String(err || ''); + return /not.?found|does not exist|no such/i.test(msg); + } + var FILE_ICONS = { folder: 'M3 5a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v1H3V5Zm0 6h18v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7Z', markdown: 'M5 3h10l4 4v14H5V3Zm9 1.5V8h3.5L14 4.5ZM8 11h8v2H8v-2Zm0 4h8v2H8v-2Z', @@ -285,6 +314,56 @@ return full.indexOf(workspaceRoot + '/') === 0 ? full.slice(workspaceRoot.length + 1) : full; } + function ensureFolder(path) { + return api.files.createFolder(path).catch(function (err) { + if (!isConflictError(err)) throw err; + }); + } + + function createNoteInFolder(notesFolderPath, title) { + var trimmedTitle = String(title || '').trim(); + if (!trimmedTitle) return Promise.reject(new Error('note title must not be empty')); + var notePath = cleanPath(notesFolderPath) + '/' + normalizeNoteFilename(trimmedTitle); + return ensureFolder(notesFolderPath).then(function () { + return api.files.metadata(notePath).then(function () { + return { path: notePath, conflict: true }; + }).catch(function (err) { + if (!isNotFoundError(err)) throw err; + return api.files.writeText(notePath, '# ' + trimmedTitle + '\n', { + createIfMissing: true, + overwrite: false + }).then(function () { + return { path: notePath }; + }).catch(function (writeErr) { + if (isConflictError(writeErr)) return { path: notePath, conflict: true }; + throw writeErr; + }); + }); + }); + } + + function ensureOverviewInFolder(notesFolderPath) { + var folderPath = cleanPath(notesFolderPath); + var ovPath = folderPath + '/Overview.md'; + return api.files.metadata(ovPath).then(function () { + return { path: ovPath }; + }).catch(function (err) { + if (!isNotFoundError(err)) throw err; + return ensureFolder(folderPath).then(function () { + var local = localPath(folderPath); + var parentName = baseName(parentPath(local)) || baseName(folderPath) || 'Overview'; + return api.files.writeText(ovPath, '# ' + parentName + '\n', { + createIfMissing: true, + overwrite: false + }).catch(function (writeErr) { + if (!isConflictError(writeErr)) throw writeErr; + }).then(function () { + return { path: ovPath }; + }); + }); + }); + } + var toolbar = el('div', { className: 'files-toolbar' }); var breadcrumb = el('div', { className: 'files-breadcrumb' }); var backBtn = iconButton('back', 'Back', 'back', goBack); @@ -771,35 +850,31 @@ ctxMenu.appendChild(ctxItem('Create Note', '', function () { var title = prompt('Note title:'); if (!title) return; - api.backend.call('NormalizeNoteTitle', title).then(function (result) { - var safeTitle = Array.isArray(result) ? result[0] : (typeof result === 'string' ? result : title); - if (!safeTitle) safeTitle = title; - return api.backend.call('CreateNote', scopedPath(entryLocalPath), safeTitle).then(function (cr) { - var crErr = Array.isArray(cr) ? cr[1] : ''; - if (crErr) { window.alert(crErr); return; } - loadEntries(); - var crValue = Array.isArray(cr) ? cr[0] : cr; - var notePath = (crValue && crValue.path) ? crValue.path : ''; - if (notePath) { - api.workbench.openResource({ - kind: 'vault-file', - path: localPath(notePath), - mode: 'edit', - extension: '.md', - context: { notesMode: true, sourcePluginId: 'verstak.files' } - }).catch(function () {}); - } - }); + createNoteInFolder(entry.relativePath, title).then(function (created) { + if (created && created.conflict) { + window.alert('A note with this title already exists.'); + return; + } + loadEntries(); + var notePath = (created && created.path) ? created.path : ''; + if (notePath) { + api.workbench.openResource({ + kind: 'vault-file', + path: notePath, + mode: 'edit', + extension: '.md', + context: { notesMode: true, sourcePluginId: 'verstak.files' } + }).catch(function () {}); + } }).catch(function (err) { window.alert('Failed to create note: ' + (err.message || String(err))); }); }, 'create-note', 'markdownAdd')); ctxMenu.appendChild(ctxItem('Open Overview', '', function () { - api.backend.call('EnsureOverview', scopedPath(entryLocalPath)).then(function (result) { - var ovValue = Array.isArray(result) ? result[0] : result; - var overviewPath = (ovValue && ovValue.path) ? ovValue.path : ''; + ensureOverviewInFolder(entry.relativePath).then(function (result) { + var overviewPath = (result && result.path) ? result.path : ''; if (overviewPath) { api.workbench.openResource({ kind: 'vault-file', - path: localPath(overviewPath), + path: overviewPath, mode: 'view', extension: '.md', context: { notesMode: true, sourcePluginId: 'verstak.files' } diff --git a/plugins/files/plugin.json b/plugins/files/plugin.json index 494e03c..a62470e 100644 --- a/plugins/files/plugin.json +++ b/plugins/files/plugin.json @@ -22,7 +22,7 @@ "ui.register" ], "frontend": { - "entry": "frontend/dist/index.js" + "entry": "frontend/src/index.js" }, "contributes": { "workspaceItems": [ diff --git a/plugins/notes/frontend/src/index.js b/plugins/notes/frontend/src/index.js index 582acc6..1768be2 100644 --- a/plugins/notes/frontend/src/index.js +++ b/plugins/notes/frontend/src/index.js @@ -25,7 +25,6 @@ '.notes-list{flex:1;overflow:auto;min-height:0}', '.notes-item{display:flex;align-items:center;gap:.5rem;padding:.45rem .75rem;border-bottom:1px solid rgba(22,33,62,.55);cursor:pointer;font-size:.85rem}', '.notes-item:hover{background:#17172d}', - '.notes-item.overview{background:#111126;border-left:2px solid #4ecca3}', '.notes-item.selected{background:#1a2a3a}', '.notes-item-icon{width:1.25rem;height:1.25rem;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;color:#8b8ba8}', '.notes-item-icon svg{width:16px;height:16px;display:block;fill:currentColor}', @@ -101,6 +100,58 @@ return parts[parts.length - 1] || ''; } + function cleanPath(path) { + return String(path || '').split('/').filter(Boolean).join('/'); + } + + function parentPath(path) { + path = cleanPath(path); + var idx = path.lastIndexOf('/'); + return idx === -1 ? '' : path.slice(0, idx); + } + + function notesFolderPath(parent) { + parent = cleanPath(parent); + return parent ? parent + '/Notes' : 'Notes'; + } + + function titleFromFilename(filename) { + filename = String(filename || '').trim(); + if (/\.markdown$/i.test(filename)) filename = filename.slice(0, -9); + else if (/\.md$/i.test(filename)) filename = filename.slice(0, -3); + return filename.replace(/_/g, ' ').trim(); + } + + function normalizeNoteFilename(title) { + var original = String(title == null ? '' : title); + var value = original.trim(); + if (/\.markdown$/i.test(value) && value.length > 9) value = value.slice(0, -9); + else if (/\.md$/i.test(value) && value.length > 3) value = value.slice(0, -3); + if (!value) throw new Error('note title must not be empty'); + value = value.replace(/\s+/g, '_'); + value = value.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-'); + value = value.replace(/[<>:"/\\|?*\x00-\x1f\x7f]/g, ''); + var out = ''; + for (var i = 0; i < value.length; i++) { + var ch = value.charAt(i); + if (/[A-Za-z0-9._-]/.test(ch) || /[\p{L}\p{N}]/u.test(ch)) out += ch; + else if (/\S/.test(ch)) out += '_'; + } + out = out.replace(/[_.-]+/g, '_').replace(/^[._\-\s]+|[._\-\s]+$/g, ''); + if (!out) throw new Error('note title normalizes to an empty filename'); + return out + '.md'; + } + + function isConflictError(err) { + var msg = (err && err.message) ? err.message : String(err || ''); + return /conflict|already exists|exists/i.test(msg); + } + + function isNotFoundError(err) { + var msg = (err && err.message) ? err.message : String(err || ''); + return /not.?found|does not exist|no such/i.test(msg); + } + var NotesView = { mount: function (containerEl, props, api) { injectStyles(); @@ -122,17 +173,80 @@ return workspaceRoot || ''; } - // ─── Backend bridge ────────────────────────────────────── - // Uses api.backend.call() from VerstakPluginAPI — the official bridge. - // Direct window.go.api.App access is NOT allowed per platform rules. + function noteFromEntry(parent, entry) { + return { + title: titleFromFilename(entry.name), + filename: entry.name, + path: entry.relativePath, + parentPath: cleanPath(parent) + }; + } + + function sortNotes(list) { + return list.sort(function (a, b) { + return String(a.title || '').toLowerCase().localeCompare(String(b.title || '').toLowerCase()); + }); + } + + function listNotes(parent) { + return api.files.list(notesFolderPath(parent)).then(function (entries) { + return sortNotes((entries || []).filter(function (entry) { + return entry.type === 'file' && /\.(md|markdown)$/i.test(entry.name || ''); + }).map(function (entry) { + return noteFromEntry(parent, entry); + })); + }).catch(function (err) { + if (isNotFoundError(err)) return []; + throw err; + }); + } + + function ensureNotesFolder(parent) { + return api.files.createFolder(notesFolderPath(parent)).catch(function (err) { + if (!isConflictError(err)) throw err; + }); + } + + function createNote(parent, title) { + var trimmedTitle = String(title || '').trim(); + if (!trimmedTitle) return Promise.reject(new Error('note title must not be empty')); + var path = notesFolderPath(parent) + '/' + normalizeNoteFilename(trimmedTitle); + return ensureNotesFolder(parent).then(function () { + return api.files.writeText(path, '# ' + trimmedTitle + '\n', { + createIfMissing: true, + overwrite: false + }).then(function () { + return { path: path }; + }).catch(function (writeErr) { + if (isConflictError(writeErr)) return { path: path, conflict: true }; + throw writeErr; + }); + }); + } + + function renameNote(notePath, newTitle) { + var trimmedTitle = String(newTitle || '').trim(); + if (!trimmedTitle) return Promise.reject(new Error('note title must not be empty')); + var newPath = parentPath(notePath) + '/' + normalizeNoteFilename(trimmedTitle); + if (newPath === notePath) return Promise.resolve({ path: notePath }); + return api.files.metadata(newPath).then(function () { + return { path: newPath, conflict: true }; + }).catch(function (err) { + if (!isNotFoundError(err)) throw err; + return api.files.move(notePath, newPath, { overwrite: false }).then(function () { + return { path: newPath }; + }).catch(function (moveErr) { + if (isConflictError(moveErr)) return { path: newPath, conflict: true }; + throw moveErr; + }); + }); + } // ─── UI Elements ──────────────────────────────────────── var toolbar = el('div', { className: 'notes-toolbar' }); - var overviewBtn = el('button', { className: 'notes-btn primary', 'data-action': 'overview', innerHTML: iconSvg('overview') + ' Overview' }); var createBtn = el('button', { className: 'notes-btn', 'data-action': 'create', innerHTML: iconSvg('add') + ' New Note' }); var statusEl = el('span', { className: 'notes-status' }); - toolbar.appendChild(overviewBtn); toolbar.appendChild(createBtn); toolbar.appendChild(el('span', { style: { flex: '1' } })); toolbar.appendChild(statusEl); @@ -186,14 +300,9 @@ listContainer.appendChild(el('div', { className: 'notes-empty' }, ['Loading...'])); var parent = notesParent(); - api.backend.call('ListNotes', parent).then(function (result) { + listNotes(parent).then(function (result) { if (disposed) return; - var unpack = unpackResult(result); - if (unpack.error) { - renderEmpty('Could not load notes: ' + unpack.error); - return; - } - notes = unpack.value || []; + notes = result || []; renderList(); }).catch(function (err) { if (disposed) return; @@ -209,13 +318,13 @@ } notes.forEach(function (note) { var row = el('div', { - className: 'notes-item' + (note.isOverview ? ' overview' : '') + (note.path === selectedPath ? ' selected' : ''), + className: 'notes-item' + (note.path === selectedPath ? ' selected' : ''), 'data-note-path': note.path, 'data-note-title': note.title, onClick: function () { selectNote(note); }, onDblclick: function () { openNote(note); } }, [ - el('span', { className: 'notes-item-icon', innerHTML: note.isOverview ? iconSvg('overview') : iconSvg('note') }), + el('span', { className: 'notes-item-icon', innerHTML: iconSvg('note') }), el('span', { className: 'notes-item-name', textContent: note.title || fileName(note.path), title: note.title || note.path }), el('span', { className: 'notes-item-actions' }, [ el('button', { @@ -224,12 +333,12 @@ innerHTML: iconSvg('open'), onClick: function (e) { e.stopPropagation(); openNote(note); } }), - !note.isOverview ? el('button', { + el('button', { className: 'notes-item-btn', title: 'Rename', innerHTML: iconSvg('rename'), onClick: function (e) { e.stopPropagation(); beginRename(note); } - }) : null + }) ]) ]); listContainer.appendChild(row); @@ -239,7 +348,7 @@ function renderEmpty(msg) { listContainer.innerHTML = ''; listContainer.appendChild(el('div', { className: 'notes-empty' }, [ - el('div', {}, [iconSvg('note')]), + el('div', { innerHTML: iconSvg('note') }), el('div', {}, [msg]), el('div', { className: 'notes-empty-hint' }, ['Click "New Note" to create one']) ])); @@ -268,40 +377,6 @@ }).catch(function (err) { console.error('[notes] openResource:', err); }); } - function openOverview() { - setStatus('Opening overview...', 'loading'); - var parent = notesParent(); - api.backend.call('EnsureOverview', parent).then(function (result) { - if (disposed) return; - var unpack = unpackResult(result); - if (unpack.error) { - setStatus('Error: ' + unpack.error, 'error'); - return; - } - var overviewPath = (unpack.value && unpack.value.path) || ''; - if (!overviewPath) { - setStatus('Failed to get overview path', 'error'); - return; - } - loadNotes(); - api.workbench.openResource({ - kind: 'vault-file', - path: overviewPath, - mode: 'view', - extension: '.md', - context: { - sourcePluginId: 'verstak.notes', - sourceView: 'notes', - isInsideNotesFolder: true, - notesMode: true, - notesScopePath: notesParent() - } - }).catch(function (err) { setStatus('Error opening overview: ' + (err.message || err), 'error'); }); - }).catch(function (err) { - setStatus('Error: ' + (err.message || err), 'error'); - }); - } - // ─── Create ──────────────────────────────────────────── function showCreate() { @@ -319,14 +394,9 @@ if (!title) return; setStatus('Creating note...', 'loading'); var parent = notesParent(); - api.backend.call('CreateNote', parent, title).then(function (result) { + createNote(parent, title).then(function (data) { if (disposed) return; - var unpack = unpackResult(result); - if (unpack.error) { - setStatus('Error: ' + unpack.error, 'error'); - return; - } - var data = unpack.value || {}; + data = data || {}; if (data.conflict) { showConflictModal(title, data.path); return; @@ -375,14 +445,9 @@ var newTitle = renameInput.value.trim(); if (!newTitle) return; setStatus('Renaming...', 'loading'); - api.backend.call('RenameNote', renameTarget.path, newTitle).then(function (result) { + renameNote(renameTarget.path, newTitle).then(function (data) { if (disposed) return; - var unpack = unpackResult(result); - if (unpack.error) { - setStatus('Error: ' + unpack.error, 'error'); - return; - } - var data = unpack.value || {}; + data = data || {}; if (data.conflict) { showConflictModal(newTitle, data.path); return; @@ -415,7 +480,6 @@ // ─── Event Wiring ─────────────────────────────────────── - overviewBtn.addEventListener('click', openOverview); createBtn.addEventListener('click', showCreate); createConfirm.addEventListener('click', confirmCreate); createCancel.addEventListener('click', hideCreate); @@ -448,16 +512,6 @@ } }; - function unpackResult(result) { - if (Array.isArray(result) && result.length === 2) { - return { value: result[0], error: result[1] || '' }; - } - if (result && result.value !== undefined && result.error !== undefined) { - return result; - } - return { value: result, error: '' }; - } - window.VerstakPluginRegister('verstak.notes', { components: { NotesView: NotesView } }); diff --git a/plugins/notes/plugin.json b/plugins/notes/plugin.json index 6b9af22..960ae2f 100644 --- a/plugins/notes/plugin.json +++ b/plugins/notes/plugin.json @@ -6,7 +6,7 @@ "apiVersion": "0.1.0", "description": "Workspace-scoped notes manager with create, rename, overview, and workbench integration.", "source": "official", - "icon": "file-text", + "icon": "edit", "provides": [ "verstak/notes/v1" ], @@ -14,9 +14,6 @@ "verstak/core/files/v1", "verstak/core/workbench/v1" ], - "optionalRequires": [ - "verstak/core/notes/v1" - ], "permissions": [ "files.read", "files.write", @@ -31,7 +28,7 @@ { "id": "verstak.notes.workspace", "title": "Notes", - "icon": "file-text", + "icon": "edit", "component": "NotesView" } ] diff --git a/plugins/platform-test/plugin.json b/plugins/platform-test/plugin.json index aae9810..90e0f7b 100644 --- a/plugins/platform-test/plugin.json +++ b/plugins/platform-test/plugin.json @@ -34,7 +34,7 @@ "workbench.open" ], "frontend": { - "entry": "frontend/dist/index.js" + "entry": "frontend/src/index.js" }, "contributes": { "views": [ diff --git a/plugins/sync/frontend/src/SyncSettings.svelte b/plugins/sync/frontend/src/SyncSettings.svelte index 46f2f50..684cb9b 100644 --- a/plugins/sync/frontend/src/SyncSettings.svelte +++ b/plugins/sync/frontend/src/SyncSettings.svelte @@ -25,11 +25,9 @@ return s } - async function backendCall(method, ...args) { - if (api?.backend && typeof api.backend.call === 'function') { - return await api.backend.call(method, ...args) - } - throw new Error('Plugin API backend.call not available') + function syncAPI() { + if (!api?.sync) throw new Error('Plugin API sync namespace not available') + return api.sync } async function load() { @@ -45,7 +43,7 @@ } } catch (_) {} try { - settings = await backendCall('SyncStatus') + settings = await syncAPI().status() if (settings) { if (settings.serverUrl) serverUrl = settings.serverUrl if (settings.syncInterval != null) syncInterval = settings.syncInterval @@ -65,9 +63,10 @@ errorMsg = '' resultMsg = '' try { - if (api?.settings?.write) { - await api.settings.write({ serverUrl, username, autoSync, syncInterval }) + if (api?.settings?.writeAll) { + await api.settings.writeAll({ serverUrl, username, autoSync, syncInterval }) } + await syncAPI().setInterval(autoSync ? syncInterval : 0) resultMsg = 'Settings saved.' resultKind = '' } catch (e) { @@ -83,7 +82,7 @@ connectionOk = null errorMsg = '' try { - await backendCall('SyncTestConnection', serverUrl, username, password) + await syncAPI().testConnection(serverUrl, username, password) connectionOk = true connectionResult = 'Connection successful.' } catch (e) { @@ -99,7 +98,7 @@ errorMsg = '' connectionResult = '' try { - await backendCall('SyncConfigure', serverUrl, username, password) + await syncAPI().configure(serverUrl, username, password) connectionResult = 'Connected successfully.' connectionOk = true username = '' @@ -125,7 +124,7 @@ errorMsg = '' resultMsg = '' try { - const r = await backendCall('SyncNow') + const r = await syncAPI().now() const summary = 'Pushed ' + (r?.pushed || 0) + ', pulled ' + (r?.pulled || 0) const warning = syncResultWarning(r) resultMsg = warning ? summary + ' · ' + warning : summary @@ -157,7 +156,7 @@ errorMsg = '' resultMsg = '' try { - await backendCall('SyncDisconnect') + await syncAPI().disconnect() resultMsg = 'Disconnected from server.' resultKind = '' settings = null @@ -184,18 +183,18 @@

Server

- - e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={serverUrl} placeholder="https://example.com" /> + + e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={serverUrl} placeholder="https://example.com" />
- - e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={username} /> + + e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={username} />
- - e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={password} /> + + e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={password} />
@@ -217,9 +216,9 @@
- +
- + minutes
diff --git a/plugins/sync/frontend/src/SyncStatusBar.svelte b/plugins/sync/frontend/src/SyncStatusBar.svelte index 4d605fc..498e31b 100644 --- a/plugins/sync/frontend/src/SyncStatusBar.svelte +++ b/plugins/sync/frontend/src/SyncStatusBar.svelte @@ -6,16 +6,6 @@ let status = 'disabled' let interval - function wailsCall(method, ...args) { - try { - if (window['go'] && window['go']['main'] && window['go']['main']['App']) { - const fn = window['go']['main']['App'][method] - if (typeof fn === 'function') return fn(...args) - } - } catch (e) { console.error('Wails error:', method, e) } - return Promise.reject(new Error('Wails not connected: ' + method)) - } - async function loadStatus() { try { const s = await api?.settings?.read?.() @@ -25,7 +15,7 @@ } } catch (_) {} try { - const s = await wailsCall('SyncStatus') + const s = await api?.sync?.status?.() if (s?.statusLabel) { status = s.statusLabel } else if (s?.configured === false) { diff --git a/plugins/sync/plugin.json b/plugins/sync/plugin.json index 8380b20..a9debdf 100644 --- a/plugins/sync/plugin.json +++ b/plugins/sync/plugin.json @@ -7,7 +7,7 @@ "source": "official", "provides": ["verstak/sync/v1", "verstak/sync.status/v1"], "requires": ["verstak/core/files/v1"], - "permissions": ["files.read", "files.write", "network.remote", "settings.read", "settings.write", "ui.register"], + "permissions": ["files.read", "files.write", "network.remote", "storage.namespace", "sync.participate", "ui.register"], "frontend": { "entry": "frontend/dist/index.js" }, "contributes": { "settingsPanels": [{ "id": "verstak.sync.settings", "title": "Sync", "component": "SyncSettings" }], diff --git a/scripts/build.sh b/scripts/build.sh index df8f7f9..e1ef4e5 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -36,6 +36,7 @@ package_plugin() { local plugin_dir="$1" local plugin_name="$2" local dist_dir="$ROOT/dist/$plugin_name" + local copied_plain_source=0 echo " → packaging dist/$plugin_name" rm -rf "$dist_dir" @@ -47,14 +48,35 @@ package_plugin() { fi # 2. frontend/dist/ - if [ -d "$plugin_dir/frontend/dist" ]; then + if [ ! -f "$plugin_dir/frontend/package.json" ] && [ -f "$plugin_dir/frontend/src/index.js" ]; then + mkdir -p "$dist_dir/frontend/dist" + cp "$plugin_dir/frontend/src/index.js" "$dist_dir/frontend/dist/index.js" + copied_plain_source=1 + echo " └─ frontend/dist/index.js (from frontend/src/index.js)" + elif [ -d "$plugin_dir/frontend/dist" ]; then mkdir -p "$dist_dir/frontend/dist" cp -r "$plugin_dir/frontend/dist/." "$dist_dir/frontend/dist/" echo " └─ frontend/dist ($(find "$dist_dir/frontend/dist" -type f | wc -l) file(s))" - elif [ -f "$plugin_dir/frontend/src/index.js" ]; then - mkdir -p "$dist_dir/frontend/dist" - cp "$plugin_dir/frontend/src/index.js" "$dist_dir/frontend/dist/index.js" - echo " └─ frontend/dist/index.js (from frontend/src/index.js)" + fi + + if [ "$copied_plain_source" -eq 1 ] && [ -f "$dist_dir/plugin.json" ]; then + if command -v python3 &>/dev/null; then + python3 -c " +import json +path = '$dist_dir/plugin.json' +with open(path, encoding='utf-8') as f: + manifest = json.load(f) +frontend = manifest.setdefault('frontend', {}) +frontend['entry'] = 'frontend/dist/index.js' +with open(path, 'w', encoding='utf-8') as f: + json.dump(manifest, f, ensure_ascii=False, indent=2) + f.write('\n') +" + echo " └─ plugin.json frontend.entry -> frontend/dist/index.js" + else + echo " ❌ python3 required to rewrite packaged plugin.json for plain JS frontend" + return 1 + fi fi # 3. backend binary @@ -88,6 +110,7 @@ HAS_DEPS=1 if ! command -v node &>/dev/null; then echo " ❌ node: not found"; HAS_DEPS=0; else echo " ✅ node $(node --version)"; fi if ! command -v npm &>/dev/null; then echo " ❌ npm: not found"; HAS_DEPS=0; fi if ! command -v go &>/dev/null; then echo " ❌ go: not found"; HAS_DEPS=0; else echo " ✅ go $(go version | grep -oP 'go\S+')"; fi +if ! command -v python3 &>/dev/null; then echo " ❌ python3: not found"; HAS_DEPS=0; else echo " ✅ python3 $(python3 --version | awk '{print $2}')"; fi if [ "$HAS_DEPS" -eq 0 ]; then echo " ⚠️ some deps missing — will skip matching plugin parts" fi diff --git a/scripts/check.sh b/scripts/check.sh index c2adfb2..b9fbec7 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -24,44 +24,51 @@ if [ "$HAS_PYTHON" -eq 1 ]; then echo "[manifest validation]" SDK_SCHEMA="$ROOT/../verstak-sdk/schemas/manifest.json" if [ -f "$SDK_SCHEMA" ]; then + set +e python3 -c " -import json, glob +import json, glob, os, sys +from jsonschema import Draft202012Validator skipped = [] problems = [] +with open('$SDK_SCHEMA') as f: + schema = json.load(f) + +validator = Draft202012Validator(schema) + for plugin_dir in glob.glob('$ROOT/plugins/*/'): manifest_path = plugin_dir + 'plugin.json' + plugin_name = os.path.basename(os.path.dirname(manifest_path)) try: with open(manifest_path) as f: manifest = json.load(f) except FileNotFoundError: - skipped.append(plugin_dir.split('/')[-2]) + skipped.append(plugin_name) continue except json.JSONDecodeError as e: - problems.append(plugin_dir.split('/')[-2] + ': invalid JSON — ' + str(e)) + problems.append(plugin_name + ': invalid JSON - ' + str(e)) continue - checks = { - 'id': isinstance(manifest.get('id'), str) and '.' in manifest['id'], - 'version': isinstance(manifest.get('version'), str), - 'schemaVersion': manifest.get('schemaVersion') == 1, - 'provides': isinstance(manifest.get('provides'), list), - 'requires': isinstance(manifest.get('requires'), list), - } - for check, ok in checks.items(): - if not ok: - problems.append(manifest.get('id', plugin_dir.split('/')[-2]) + ': missing/empty \"' + check + '\"') + for err in sorted(validator.iter_errors(manifest), key=lambda e: list(e.path)): + where = '.'.join(str(part) for part in err.path) or '' + problems.append(manifest.get('id', plugin_name) + ': ' + where + ': ' + err.message) + for field in ('requires', 'optionalRequires'): + if 'verstak/core/notes/v1' in manifest.get(field, []): + problems.append(manifest.get('id', plugin_name) + ': ' + field + ': core notes capability is not part of v2 platform contract') if skipped: - print(' \u26a0\ufe0f skipped (no plugin.json): ' + ', '.join(skipped)) + print(' warning: skipped (no plugin.json): ' + ', '.join(skipped)) if problems: for p in problems: - print(' \u274c ' + p) + print(' FAIL ' + p) + sys.exit(1) else: - print(' \u2705 all manifests valid') + print(' OK all manifests valid') " - report "manifests valid" $? + STATUS=$? + set -e + report "manifests valid" "$STATUS" else echo " ℹ️ SDK schema not found at $SDK_SCHEMA — run build.sh in verstak-sdk first" fi @@ -69,6 +76,91 @@ else echo " ℹ️ python3 not available — skipping manifest validation" fi +echo "" +# Guard official plugins against bypassing the v2 plugin API for note features. +echo "[frontend API boundary]" +if [ "$HAS_PYTHON" -eq 1 ]; then + set +e + python3 -c " +import os, re, sys + +root = '$ROOT/plugins' +forbidden = re.compile(r\"api\\.backend\\.call|api\\.request\\.open|window(?:\\.go|\\[['\\\"]go['\\\"]\\])\") +problems = [] + +for dirpath, _, filenames in os.walk(root): + if '/node_modules/' in dirpath: + continue + for filename in filenames: + if not filename.endswith(('.js', '.svelte', '.ts')): + continue + path = os.path.join(dirpath, filename) + with open(path, encoding='utf-8') as f: + for lineno, line in enumerate(f, 1): + if forbidden.search(line): + problems.append(f'{os.path.relpath(path, \"$ROOT\")}:{lineno}: {line.strip()}') + +if problems: + for p in problems: + print(' FAIL ' + p) + sys.exit(1) +print(' OK official plugins use public VerstakPluginAPI only') +" + STATUS=$? + set -e + report "frontend API boundary" "$STATUS" +else + echo " ⚠️ python3 not available — skipping frontend API boundary" +fi + +echo "" +# Ensure source manifests do not require ignored dist files for plain JS plugins. +echo "[frontend entry source contract]" +if [ "$HAS_PYTHON" -eq 1 ]; then + set +e + python3 -c " +import json, os, sys + +root = '$ROOT/plugins' +problems = [] + +for plugin_name in sorted(os.listdir(root)): + plugin_dir = os.path.join(root, plugin_name) + manifest_path = os.path.join(plugin_dir, 'plugin.json') + if not os.path.isfile(manifest_path): + continue + with open(manifest_path, encoding='utf-8') as f: + manifest = json.load(f) + frontend = manifest.get('frontend') or {} + entry = frontend.get('entry') + if not entry: + continue + entry_path = os.path.join(plugin_dir, entry) + has_build_step = os.path.isfile(os.path.join(plugin_dir, 'frontend', 'package.json')) + has_plain_source = os.path.isfile(os.path.join(plugin_dir, 'frontend', 'src', 'index.js')) + if has_build_step: + if entry != 'frontend/dist/index.js': + problems.append(f'{plugin_name}: build frontend entry must be frontend/dist/index.js, got {entry}') + continue + if has_plain_source and entry != 'frontend/src/index.js': + problems.append(f'{plugin_name}: plain JS frontend entry must be frontend/src/index.js, got {entry}') + continue + if not os.path.isfile(entry_path): + problems.append(f'{plugin_name}: frontend entry does not exist: {entry}') + +if problems: + for p in problems: + print(' FAIL ' + p) + sys.exit(1) +print(' OK source manifests reference tracked frontend entries') +" + STATUS=$? + set -e + report "frontend entry source contract" "$STATUS" +else + echo " ⚠️ python3 not available — skipping frontend entry source contract" +fi + echo "" # Check all scripts in plugins are executable echo "[script permissions]" @@ -88,6 +180,8 @@ echo "[frontend smoke]" if command -v node &>/dev/null; then node "$ROOT/scripts/smoke-platform-frontend.js" report "platform-test frontend components mount" $? + node "$ROOT/scripts/smoke-notes-plugin.js" + report "notes frontend behavior" $? else echo " ⚠️ node not available — skipping frontend smoke" fi @@ -104,10 +198,11 @@ if command -v node &>/dev/null; then entry=$(node -e "const m=require('$manifest');console.log(m.frontend&&m.frontend.entry||'')" 2>/dev/null) if [ -z "$entry" ]; then continue; fi bundle="$plugin_dir$entry" - if [ ! -f "$bundle" ] && [ "$entry" = "frontend/dist/index.js" ] && [ -f "$plugin_dir/frontend/src/index.js" ]; then - bundle="$plugin_dir/frontend/src/index.js" - fi if [ ! -f "$bundle" ]; then + if [ -f "$plugin_dir/frontend/package.json" ]; then + echo " ⚠️ $plugin_id: bundle not built at $entry — run scripts/build.sh for packaged frontend smoke" + continue + fi echo " ❌ $plugin_id: bundle not found at $entry" BUNDLE_FAILED=1 continue diff --git a/scripts/smoke-notes-plugin.js b/scripts/smoke-notes-plugin.js new file mode 100755 index 0000000..45772e0 --- /dev/null +++ b/scripts/smoke-notes-plugin.js @@ -0,0 +1,236 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const root = path.resolve(__dirname, '..'); +const sourcePath = path.join(root, 'plugins', 'notes', 'frontend', 'src', 'index.js'); +const source = fs.readFileSync(sourcePath, 'utf8'); + +class FakeNode { + constructor(tagName) { + this.tagName = String(tagName || '').toUpperCase(); + this.children = []; + this.attributes = {}; + this.listeners = {}; + this.style = {}; + this.className = ''; + this.id = ''; + this.value = ''; + this._innerHTML = ''; + this._textContent = ''; + } + + appendChild(node) { + if (!(node instanceof FakeNode)) throw new TypeError('appendChild expects FakeNode'); + this.children.push(node); + node.parentNode = this; + return node; + } + + remove() { + if (!this.parentNode) return; + this.parentNode.children = this.parentNode.children.filter((child) => child !== this); + this.parentNode = null; + } + + setAttribute(name, value) { + this.attributes[name] = String(value); + if (name === 'id') this.id = String(value); + } + + getAttribute(name) { + return this.attributes[name]; + } + + addEventListener(type, handler) { + this.listeners[type] = this.listeners[type] || []; + this.listeners[type].push(handler); + } + + dispatchEvent(type, event = {}) { + const handlers = this.listeners[type] || []; + handlers.forEach((handler) => handler({ stopPropagation() {}, ...event })); + } + + click() { + this.dispatchEvent('click'); + } + + focus() {} + select() {} + + set innerHTML(value) { + this._innerHTML = String(value || ''); + this.children = []; + } + + get innerHTML() { + return this._innerHTML; + } + + set textContent(value) { + this._textContent = String(value || ''); + this.children = []; + } + + get textContent() { + if (this.tagName === '#TEXT') return this._textContent; + return this._textContent + this.children.map((child) => child.textContent).join(''); + } +} + +function walk(node, fn) { + if (fn(node)) return node; + for (const child of node.children) { + const found = walk(child, fn); + if (found) return found; + } + return null; +} + +function makeDocument() { + const body = new FakeNode('body'); + return { + body, + head: new FakeNode('head'), + createElement(tagName) { + return new FakeNode(tagName); + }, + createTextNode(text) { + const node = new FakeNode('#text'); + node.textContent = text; + return node; + }, + getElementById() { + return null; + }, + }; +} + +function loadNotesComponent(document) { + const registry = {}; + const sandbox = { + console, + setTimeout, + clearTimeout, + document, + window: { + VerstakPluginRegister(pluginId, bundle) { + registry[pluginId] = bundle.components || {}; + }, + }, + }; + sandbox.window.window = sandbox.window; + sandbox.window.document = document; + vm.runInNewContext(source, sandbox, { filename: sourcePath }); + const component = registry['verstak.notes'] && registry['verstak.notes'].NotesView; + if (!component) throw new Error('NotesView was not registered'); + return component; +} + +function makeApi(options = {}) { + const entries = new Map(); + const opened = []; + return { + entries, + opened, + files: { + list: async (relativeDir) => { + const prefix = relativeDir ? `${relativeDir}/` : ''; + if (!entries.has(relativeDir)) throw new Error(`not-found: ${relativeDir}`); + return Array.from(entries.entries()) + .filter(([entryPath]) => entryPath.startsWith(prefix) && entryPath !== relativeDir && !entryPath.slice(prefix.length).includes('/')) + .map(([entryPath, entry]) => ({ + name: path.basename(entryPath), + relativePath: entryPath, + type: entry.type, + })); + }, + metadata: async (relativePath) => { + if (options.metadataAlwaysExists) return { relativePath, type: 'file' }; + const entry = entries.get(relativePath); + if (!entry) throw new Error(`not-found: ${relativePath}`); + return { relativePath, type: entry.type }; + }, + createFolder: async (relativePath) => { + if (entries.has(relativePath)) throw new Error(`conflict: ${relativePath}`); + entries.set(relativePath, { type: 'folder' }); + }, + writeText: async (relativePath, content, writeOptions = {}) => { + if (entries.has(relativePath) && !writeOptions.overwrite) throw new Error(`conflict: ${relativePath}`); + const parent = relativePath.split('/').slice(0, -1).join('/'); + if (parent && !entries.has(parent)) throw new Error(`parent-not-found: ${parent}`); + entries.set(relativePath, { type: 'file', content }); + }, + move: async (fromRelativePath, toRelativePath) => { + const entry = entries.get(fromRelativePath); + if (!entry) throw new Error(`not-found: ${fromRelativePath}`); + if (entries.has(toRelativePath)) throw new Error(`conflict: ${toRelativePath}`); + entries.set(toRelativePath, entry); + entries.delete(fromRelativePath); + }, + }, + workbench: { + openResource: async (request) => { + opened.push(request); + return { status: 'opened' }; + }, + }, + }; +} + +async function flush() { + for (let i = 0; i < 8; i++) { + await Promise.resolve(); + } +} + +async function mountNotes(api) { + const document = makeDocument(); + const component = loadNotesComponent(document); + const container = new FakeNode('div'); + component.mount(container, { workspaceNode: { name: 'Project' } }, api); + await flush(); + return { container, document, component }; +} + +(async () => { + const emptyApi = makeApi(); + const emptyMounted = await mountNotes(emptyApi); + if (walk(emptyMounted.container, (node) => node.getAttribute && node.getAttribute('data-action') === 'overview')) { + throw new Error('NotesView must not render a persistent Overview button'); + } + if (emptyMounted.container.textContent.includes(' node.getAttribute && node.getAttribute('data-action') === 'create'); + if (!createButton) throw new Error('create button not found'); + createButton.click(); + const input = walk(container, (node) => node.getAttribute && node.getAttribute('data-notes-create-input') !== undefined); + if (!input) throw new Error('create input not found'); + input.value = 'First Note'; + const confirm = walk(container, (node) => node.tagName === 'BUTTON' && node.textContent === 'Create'); + if (!confirm) throw new Error('create confirm button not found'); + confirm.click(); + await flush(); + + const created = createApi.entries.get('Project/Notes/First_Note.md'); + if (!created || created.content !== '# First Note\n') { + throw new Error('create note did not write the markdown file'); + } + if (document.body.children.some((node) => node.className === 'notes-modal-overlay')) { + throw new Error('create note showed a conflict modal for a new file'); + } + if (!createApi.opened.some((request) => request.path === 'Project/Notes/First_Note.md')) { + throw new Error('create note did not open the newly created file'); + } + + console.log('notes plugin smoke passed'); +})().catch((err) => { + console.error(err); + process.exit(1); +});