Align official plugins with v2 runtime
This commit is contained in:
parent
321c8e58d1
commit
39d8df339b
|
|
@ -10,11 +10,11 @@
|
||||||
plugin-name/
|
plugin-name/
|
||||||
plugin.json — обязательный manifest
|
plugin.json — обязательный manifest
|
||||||
frontend/
|
frontend/
|
||||||
package.json — npm проект (Svelte)
|
package.json — опционально, npm проект (Svelte/Vite)
|
||||||
src/
|
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
|
index.js
|
||||||
style.css
|
style.css
|
||||||
backend/ — опционально (Go sidecar)
|
backend/ — опционально (Go sidecar)
|
||||||
|
|
@ -33,7 +33,7 @@ plugin-name/
|
||||||
```
|
```
|
||||||
dist/
|
dist/
|
||||||
plugin-name/
|
plugin-name/
|
||||||
plugin.json — копируется из корня исходного плагина
|
plugin.json — manifest для runtime package
|
||||||
frontend/
|
frontend/
|
||||||
dist/ — только собранные файлы (копируется содержимое)
|
dist/ — только собранные файлы (копируется содержимое)
|
||||||
index.js
|
index.js
|
||||||
|
|
@ -46,7 +46,11 @@ dist/
|
||||||
|
|
||||||
### plugin.json
|
### plugin.json
|
||||||
|
|
||||||
Копируется как из корня исходного плагина. Содержит:
|
Исходный `plugin.json` копируется в package. Если plain JS plugin в исходниках
|
||||||
|
указывает `frontend.entry` на `frontend/src/index.js`, `build.sh` переписывает
|
||||||
|
это поле в packaged manifest на `frontend/dist/index.js`.
|
||||||
|
|
||||||
|
Содержит:
|
||||||
|
|
||||||
| Поле | Назначение в dist |
|
| Поле | Назначение в dist |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
@ -59,7 +63,14 @@ dist/
|
||||||
|
|
||||||
### frontend/dist
|
### frontend/dist
|
||||||
|
|
||||||
Содержимое `frontend/dist/` копируется в `dist/<plugin-name>/frontend/dist/`. Это результат `npm run build` — скомпилированные JS/CSS без map-файлов и dev-зависимостей.
|
Для plugins с `frontend/package.json` содержимое `frontend/dist/` копируется в
|
||||||
|
`dist/<plugin-name>/frontend/dist/`. Это результат `npm run build` —
|
||||||
|
скомпилированные JS/CSS без map-файлов и dev-зависимостей.
|
||||||
|
|
||||||
|
Для plain JS plugins без `frontend/package.json` `build.sh` всегда копирует
|
||||||
|
tracked `frontend/src/index.js` в `dist/<plugin-name>/frontend/dist/index.js`.
|
||||||
|
Ignored source-side `frontend/dist/` не используется для таких plugins, чтобы
|
||||||
|
локальные stale build artifacts не попадали в package.
|
||||||
|
|
||||||
### backend binary
|
### backend binary
|
||||||
|
|
||||||
|
|
@ -95,25 +106,23 @@ cd ~/git/verstak2/verstak-official-plugins
|
||||||
`build.sh` для каждого плагина в `plugins/`:
|
`build.sh` для каждого плагина в `plugins/`:
|
||||||
|
|
||||||
1. Проверяет `plugin.json` (JSON validation).
|
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 <plugin-name> .`.
|
3. Собирает backend: `go build -o <plugin-name> .`.
|
||||||
4. Упаковывает в `dist/<plugin-name>/` через `package_plugin()`.
|
4. Упаковывает в `dist/<plugin-name>/` через `package_plugin()`. Plain JS
|
||||||
|
frontend берётся из `frontend/src/index.js`, packaged manifest получает
|
||||||
|
`frontend.entry = "frontend/dist/index.js"`.
|
||||||
|
|
||||||
### Сборка конкретного плагина
|
### Сборка конкретного плагина
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/git/verstak2/verstak-official-plugins/plugins/platform-test
|
cd ~/git/verstak2/verstak-official-plugins
|
||||||
# Frontend
|
./scripts/build.sh
|
||||||
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/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`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
|
### Backend build details
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,29 @@
|
||||||
return escapeHtml(s).replace(/"/g, '"');
|
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) {
|
function renderInline(text, isNotesContext) {
|
||||||
var html = escapeHtml(text);
|
var html = escapeHtml(text);
|
||||||
// Internal wiki links [[Title]] — only render in notes context
|
// Internal wiki links [[Title]] — only render in notes context
|
||||||
|
|
@ -408,14 +431,7 @@
|
||||||
if (!dirty || disposed) return Promise.resolve();
|
if (!dirty || disposed) return Promise.resolve();
|
||||||
saveState = 'saving';
|
saveState = 'saving';
|
||||||
updateStatus();
|
updateStatus();
|
||||||
var savePromise;
|
var savePromise = api.files.writeText(resourcePath, currentContent, { createIfMissing: false, overwrite: true });
|
||||||
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 });
|
|
||||||
}
|
|
||||||
return savePromise.then(function () {
|
return savePromise.then(function () {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
savedContent = currentContent;
|
savedContent = currentContent;
|
||||||
|
|
@ -442,15 +458,7 @@
|
||||||
if (dirty && !window.confirm('Discard unsaved changes and reload from disk?')) return;
|
if (dirty && !window.confirm('Discard unsaved changes and reload from disk?')) return;
|
||||||
editorWrap.innerHTML = '';
|
editorWrap.innerHTML = '';
|
||||||
editorWrap.appendChild(el('div', { className: 'de-loading' }, ['Loading...']));
|
editorWrap.appendChild(el('div', { className: 'de-loading' }, ['Loading...']));
|
||||||
var readPromise;
|
var readPromise = api.files.readText(resourcePath);
|
||||||
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);
|
|
||||||
}
|
|
||||||
readPromise.then(function (content) {
|
readPromise.then(function (content) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
currentContent = String(content == null ? '' : content);
|
currentContent = String(content == null ? '' : content);
|
||||||
|
|
@ -512,15 +520,23 @@
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
var noteTitle = link.getAttribute('data-note-link');
|
var noteTitle = link.getAttribute('data-note-link');
|
||||||
if (!noteTitle) return;
|
if (!noteTitle) return;
|
||||||
var parentPath = resourcePath;
|
var currentPath = cleanPath(resourcePath);
|
||||||
var idx = parentPath.indexOf('/Notes/');
|
var notesIdx = currentPath.indexOf('/Notes/');
|
||||||
if (idx !== -1) {
|
var notesRoot = notesIdx === -1 ? 'Notes' : currentPath.slice(0, notesIdx) + '/Notes';
|
||||||
parentPath = parentPath.substring(0, idx);
|
var targetPath = cleanPath(notesRoot + '/' + normalizeNoteFilename(noteTitle));
|
||||||
}
|
api.workbench.openResource({
|
||||||
api.request.open({
|
kind: 'vault-file',
|
||||||
path: parentPath + '/Notes/' + noteTitle + '.md',
|
path: targetPath,
|
||||||
mode: 'view',
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
"workbench.open"
|
"workbench.open"
|
||||||
],
|
],
|
||||||
"frontend": {
|
"frontend": {
|
||||||
"entry": "frontend/dist/index.js"
|
"entry": "frontend/src/index.js"
|
||||||
},
|
},
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"openProviders": [
|
"openProviders": [
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,35 @@
|
||||||
return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
|
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 = {
|
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',
|
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',
|
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;
|
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 toolbar = el('div', { className: 'files-toolbar' });
|
||||||
var breadcrumb = el('div', { className: 'files-breadcrumb' });
|
var breadcrumb = el('div', { className: 'files-breadcrumb' });
|
||||||
var backBtn = iconButton('back', 'Back', 'back', goBack);
|
var backBtn = iconButton('back', 'Back', 'back', goBack);
|
||||||
|
|
@ -771,35 +850,31 @@
|
||||||
ctxMenu.appendChild(ctxItem('Create Note', '', function () {
|
ctxMenu.appendChild(ctxItem('Create Note', '', function () {
|
||||||
var title = prompt('Note title:');
|
var title = prompt('Note title:');
|
||||||
if (!title) return;
|
if (!title) return;
|
||||||
api.backend.call('NormalizeNoteTitle', title).then(function (result) {
|
createNoteInFolder(entry.relativePath, title).then(function (created) {
|
||||||
var safeTitle = Array.isArray(result) ? result[0] : (typeof result === 'string' ? result : title);
|
if (created && created.conflict) {
|
||||||
if (!safeTitle) safeTitle = title;
|
window.alert('A note with this title already exists.');
|
||||||
return api.backend.call('CreateNote', scopedPath(entryLocalPath), safeTitle).then(function (cr) {
|
return;
|
||||||
var crErr = Array.isArray(cr) ? cr[1] : '';
|
}
|
||||||
if (crErr) { window.alert(crErr); return; }
|
loadEntries();
|
||||||
loadEntries();
|
var notePath = (created && created.path) ? created.path : '';
|
||||||
var crValue = Array.isArray(cr) ? cr[0] : cr;
|
if (notePath) {
|
||||||
var notePath = (crValue && crValue.path) ? crValue.path : '';
|
api.workbench.openResource({
|
||||||
if (notePath) {
|
kind: 'vault-file',
|
||||||
api.workbench.openResource({
|
path: notePath,
|
||||||
kind: 'vault-file',
|
mode: 'edit',
|
||||||
path: localPath(notePath),
|
extension: '.md',
|
||||||
mode: 'edit',
|
context: { notesMode: true, sourcePluginId: 'verstak.files' }
|
||||||
extension: '.md',
|
}).catch(function () {});
|
||||||
context: { notesMode: true, sourcePluginId: 'verstak.files' }
|
}
|
||||||
}).catch(function () {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}).catch(function (err) { window.alert('Failed to create note: ' + (err.message || String(err))); });
|
}).catch(function (err) { window.alert('Failed to create note: ' + (err.message || String(err))); });
|
||||||
}, 'create-note', 'markdownAdd'));
|
}, 'create-note', 'markdownAdd'));
|
||||||
ctxMenu.appendChild(ctxItem('Open Overview', '', function () {
|
ctxMenu.appendChild(ctxItem('Open Overview', '', function () {
|
||||||
api.backend.call('EnsureOverview', scopedPath(entryLocalPath)).then(function (result) {
|
ensureOverviewInFolder(entry.relativePath).then(function (result) {
|
||||||
var ovValue = Array.isArray(result) ? result[0] : result;
|
var overviewPath = (result && result.path) ? result.path : '';
|
||||||
var overviewPath = (ovValue && ovValue.path) ? ovValue.path : '';
|
|
||||||
if (overviewPath) {
|
if (overviewPath) {
|
||||||
api.workbench.openResource({
|
api.workbench.openResource({
|
||||||
kind: 'vault-file',
|
kind: 'vault-file',
|
||||||
path: localPath(overviewPath),
|
path: overviewPath,
|
||||||
mode: 'view',
|
mode: 'view',
|
||||||
extension: '.md',
|
extension: '.md',
|
||||||
context: { notesMode: true, sourcePluginId: 'verstak.files' }
|
context: { notesMode: true, sourcePluginId: 'verstak.files' }
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
"ui.register"
|
"ui.register"
|
||||||
],
|
],
|
||||||
"frontend": {
|
"frontend": {
|
||||||
"entry": "frontend/dist/index.js"
|
"entry": "frontend/src/index.js"
|
||||||
},
|
},
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"workspaceItems": [
|
"workspaceItems": [
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@
|
||||||
'.notes-list{flex:1;overflow:auto;min-height:0}',
|
'.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{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:hover{background:#17172d}',
|
||||||
'.notes-item.overview{background:#111126;border-left:2px solid #4ecca3}',
|
|
||||||
'.notes-item.selected{background:#1a2a3a}',
|
'.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{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}',
|
'.notes-item-icon svg{width:16px;height:16px;display:block;fill:currentColor}',
|
||||||
|
|
@ -101,6 +100,58 @@
|
||||||
return parts[parts.length - 1] || '';
|
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 = {
|
var NotesView = {
|
||||||
mount: function (containerEl, props, api) {
|
mount: function (containerEl, props, api) {
|
||||||
injectStyles();
|
injectStyles();
|
||||||
|
|
@ -122,17 +173,80 @@
|
||||||
return workspaceRoot || '';
|
return workspaceRoot || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Backend bridge ──────────────────────────────────────
|
function noteFromEntry(parent, entry) {
|
||||||
// Uses api.backend.call() from VerstakPluginAPI — the official bridge.
|
return {
|
||||||
// Direct window.go.api.App access is NOT allowed per platform rules.
|
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 ────────────────────────────────────────
|
// ─── UI Elements ────────────────────────────────────────
|
||||||
|
|
||||||
var toolbar = el('div', { className: 'notes-toolbar' });
|
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 createBtn = el('button', { className: 'notes-btn', 'data-action': 'create', innerHTML: iconSvg('add') + ' New Note' });
|
||||||
var statusEl = el('span', { className: 'notes-status' });
|
var statusEl = el('span', { className: 'notes-status' });
|
||||||
toolbar.appendChild(overviewBtn);
|
|
||||||
toolbar.appendChild(createBtn);
|
toolbar.appendChild(createBtn);
|
||||||
toolbar.appendChild(el('span', { style: { flex: '1' } }));
|
toolbar.appendChild(el('span', { style: { flex: '1' } }));
|
||||||
toolbar.appendChild(statusEl);
|
toolbar.appendChild(statusEl);
|
||||||
|
|
@ -186,14 +300,9 @@
|
||||||
listContainer.appendChild(el('div', { className: 'notes-empty' }, ['Loading...']));
|
listContainer.appendChild(el('div', { className: 'notes-empty' }, ['Loading...']));
|
||||||
|
|
||||||
var parent = notesParent();
|
var parent = notesParent();
|
||||||
api.backend.call('ListNotes', parent).then(function (result) {
|
listNotes(parent).then(function (result) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
var unpack = unpackResult(result);
|
notes = result || [];
|
||||||
if (unpack.error) {
|
|
||||||
renderEmpty('Could not load notes: ' + unpack.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
notes = unpack.value || [];
|
|
||||||
renderList();
|
renderList();
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
|
|
@ -209,13 +318,13 @@
|
||||||
}
|
}
|
||||||
notes.forEach(function (note) {
|
notes.forEach(function (note) {
|
||||||
var row = el('div', {
|
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-path': note.path,
|
||||||
'data-note-title': note.title,
|
'data-note-title': note.title,
|
||||||
onClick: function () { selectNote(note); },
|
onClick: function () { selectNote(note); },
|
||||||
onDblclick: function () { openNote(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-name', textContent: note.title || fileName(note.path), title: note.title || note.path }),
|
||||||
el('span', { className: 'notes-item-actions' }, [
|
el('span', { className: 'notes-item-actions' }, [
|
||||||
el('button', {
|
el('button', {
|
||||||
|
|
@ -224,12 +333,12 @@
|
||||||
innerHTML: iconSvg('open'),
|
innerHTML: iconSvg('open'),
|
||||||
onClick: function (e) { e.stopPropagation(); openNote(note); }
|
onClick: function (e) { e.stopPropagation(); openNote(note); }
|
||||||
}),
|
}),
|
||||||
!note.isOverview ? el('button', {
|
el('button', {
|
||||||
className: 'notes-item-btn',
|
className: 'notes-item-btn',
|
||||||
title: 'Rename',
|
title: 'Rename',
|
||||||
innerHTML: iconSvg('rename'),
|
innerHTML: iconSvg('rename'),
|
||||||
onClick: function (e) { e.stopPropagation(); beginRename(note); }
|
onClick: function (e) { e.stopPropagation(); beginRename(note); }
|
||||||
}) : null
|
})
|
||||||
])
|
])
|
||||||
]);
|
]);
|
||||||
listContainer.appendChild(row);
|
listContainer.appendChild(row);
|
||||||
|
|
@ -239,7 +348,7 @@
|
||||||
function renderEmpty(msg) {
|
function renderEmpty(msg) {
|
||||||
listContainer.innerHTML = '';
|
listContainer.innerHTML = '';
|
||||||
listContainer.appendChild(el('div', { className: 'notes-empty' }, [
|
listContainer.appendChild(el('div', { className: 'notes-empty' }, [
|
||||||
el('div', {}, [iconSvg('note')]),
|
el('div', { innerHTML: iconSvg('note') }),
|
||||||
el('div', {}, [msg]),
|
el('div', {}, [msg]),
|
||||||
el('div', { className: 'notes-empty-hint' }, ['Click "New Note" to create one'])
|
el('div', { className: 'notes-empty-hint' }, ['Click "New Note" to create one'])
|
||||||
]));
|
]));
|
||||||
|
|
@ -268,40 +377,6 @@
|
||||||
}).catch(function (err) { console.error('[notes] openResource:', err); });
|
}).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 ────────────────────────────────────────────
|
// ─── Create ────────────────────────────────────────────
|
||||||
|
|
||||||
function showCreate() {
|
function showCreate() {
|
||||||
|
|
@ -319,14 +394,9 @@
|
||||||
if (!title) return;
|
if (!title) return;
|
||||||
setStatus('Creating note...', 'loading');
|
setStatus('Creating note...', 'loading');
|
||||||
var parent = notesParent();
|
var parent = notesParent();
|
||||||
api.backend.call('CreateNote', parent, title).then(function (result) {
|
createNote(parent, title).then(function (data) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
var unpack = unpackResult(result);
|
data = data || {};
|
||||||
if (unpack.error) {
|
|
||||||
setStatus('Error: ' + unpack.error, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var data = unpack.value || {};
|
|
||||||
if (data.conflict) {
|
if (data.conflict) {
|
||||||
showConflictModal(title, data.path);
|
showConflictModal(title, data.path);
|
||||||
return;
|
return;
|
||||||
|
|
@ -375,14 +445,9 @@
|
||||||
var newTitle = renameInput.value.trim();
|
var newTitle = renameInput.value.trim();
|
||||||
if (!newTitle) return;
|
if (!newTitle) return;
|
||||||
setStatus('Renaming...', 'loading');
|
setStatus('Renaming...', 'loading');
|
||||||
api.backend.call('RenameNote', renameTarget.path, newTitle).then(function (result) {
|
renameNote(renameTarget.path, newTitle).then(function (data) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
var unpack = unpackResult(result);
|
data = data || {};
|
||||||
if (unpack.error) {
|
|
||||||
setStatus('Error: ' + unpack.error, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var data = unpack.value || {};
|
|
||||||
if (data.conflict) {
|
if (data.conflict) {
|
||||||
showConflictModal(newTitle, data.path);
|
showConflictModal(newTitle, data.path);
|
||||||
return;
|
return;
|
||||||
|
|
@ -415,7 +480,6 @@
|
||||||
|
|
||||||
// ─── Event Wiring ───────────────────────────────────────
|
// ─── Event Wiring ───────────────────────────────────────
|
||||||
|
|
||||||
overviewBtn.addEventListener('click', openOverview);
|
|
||||||
createBtn.addEventListener('click', showCreate);
|
createBtn.addEventListener('click', showCreate);
|
||||||
createConfirm.addEventListener('click', confirmCreate);
|
createConfirm.addEventListener('click', confirmCreate);
|
||||||
createCancel.addEventListener('click', hideCreate);
|
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', {
|
window.VerstakPluginRegister('verstak.notes', {
|
||||||
components: { NotesView: NotesView }
|
components: { NotesView: NotesView }
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"apiVersion": "0.1.0",
|
"apiVersion": "0.1.0",
|
||||||
"description": "Workspace-scoped notes manager with create, rename, overview, and workbench integration.",
|
"description": "Workspace-scoped notes manager with create, rename, overview, and workbench integration.",
|
||||||
"source": "official",
|
"source": "official",
|
||||||
"icon": "file-text",
|
"icon": "edit",
|
||||||
"provides": [
|
"provides": [
|
||||||
"verstak/notes/v1"
|
"verstak/notes/v1"
|
||||||
],
|
],
|
||||||
|
|
@ -14,9 +14,6 @@
|
||||||
"verstak/core/files/v1",
|
"verstak/core/files/v1",
|
||||||
"verstak/core/workbench/v1"
|
"verstak/core/workbench/v1"
|
||||||
],
|
],
|
||||||
"optionalRequires": [
|
|
||||||
"verstak/core/notes/v1"
|
|
||||||
],
|
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"files.read",
|
"files.read",
|
||||||
"files.write",
|
"files.write",
|
||||||
|
|
@ -31,7 +28,7 @@
|
||||||
{
|
{
|
||||||
"id": "verstak.notes.workspace",
|
"id": "verstak.notes.workspace",
|
||||||
"title": "Notes",
|
"title": "Notes",
|
||||||
"icon": "file-text",
|
"icon": "edit",
|
||||||
"component": "NotesView"
|
"component": "NotesView"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
"workbench.open"
|
"workbench.open"
|
||||||
],
|
],
|
||||||
"frontend": {
|
"frontend": {
|
||||||
"entry": "frontend/dist/index.js"
|
"entry": "frontend/src/index.js"
|
||||||
},
|
},
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"views": [
|
"views": [
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,9 @@
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
async function backendCall(method, ...args) {
|
function syncAPI() {
|
||||||
if (api?.backend && typeof api.backend.call === 'function') {
|
if (!api?.sync) throw new Error('Plugin API sync namespace not available')
|
||||||
return await api.backend.call(method, ...args)
|
return api.sync
|
||||||
}
|
|
||||||
throw new Error('Plugin API backend.call not available')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
|
@ -45,7 +43,7 @@
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
try {
|
try {
|
||||||
settings = await backendCall('SyncStatus')
|
settings = await syncAPI().status()
|
||||||
if (settings) {
|
if (settings) {
|
||||||
if (settings.serverUrl) serverUrl = settings.serverUrl
|
if (settings.serverUrl) serverUrl = settings.serverUrl
|
||||||
if (settings.syncInterval != null) syncInterval = settings.syncInterval
|
if (settings.syncInterval != null) syncInterval = settings.syncInterval
|
||||||
|
|
@ -65,9 +63,10 @@
|
||||||
errorMsg = ''
|
errorMsg = ''
|
||||||
resultMsg = ''
|
resultMsg = ''
|
||||||
try {
|
try {
|
||||||
if (api?.settings?.write) {
|
if (api?.settings?.writeAll) {
|
||||||
await api.settings.write({ serverUrl, username, autoSync, syncInterval })
|
await api.settings.writeAll({ serverUrl, username, autoSync, syncInterval })
|
||||||
}
|
}
|
||||||
|
await syncAPI().setInterval(autoSync ? syncInterval : 0)
|
||||||
resultMsg = 'Settings saved.'
|
resultMsg = 'Settings saved.'
|
||||||
resultKind = ''
|
resultKind = ''
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -83,7 +82,7 @@
|
||||||
connectionOk = null
|
connectionOk = null
|
||||||
errorMsg = ''
|
errorMsg = ''
|
||||||
try {
|
try {
|
||||||
await backendCall('SyncTestConnection', serverUrl, username, password)
|
await syncAPI().testConnection(serverUrl, username, password)
|
||||||
connectionOk = true
|
connectionOk = true
|
||||||
connectionResult = 'Connection successful.'
|
connectionResult = 'Connection successful.'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -99,7 +98,7 @@
|
||||||
errorMsg = ''
|
errorMsg = ''
|
||||||
connectionResult = ''
|
connectionResult = ''
|
||||||
try {
|
try {
|
||||||
await backendCall('SyncConfigure', serverUrl, username, password)
|
await syncAPI().configure(serverUrl, username, password)
|
||||||
connectionResult = 'Connected successfully.'
|
connectionResult = 'Connected successfully.'
|
||||||
connectionOk = true
|
connectionOk = true
|
||||||
username = ''
|
username = ''
|
||||||
|
|
@ -125,7 +124,7 @@
|
||||||
errorMsg = ''
|
errorMsg = ''
|
||||||
resultMsg = ''
|
resultMsg = ''
|
||||||
try {
|
try {
|
||||||
const r = await backendCall('SyncNow')
|
const r = await syncAPI().now()
|
||||||
const summary = 'Pushed ' + (r?.pushed || 0) + ', pulled ' + (r?.pulled || 0)
|
const summary = 'Pushed ' + (r?.pushed || 0) + ', pulled ' + (r?.pulled || 0)
|
||||||
const warning = syncResultWarning(r)
|
const warning = syncResultWarning(r)
|
||||||
resultMsg = warning ? summary + ' · ' + warning : summary
|
resultMsg = warning ? summary + ' · ' + warning : summary
|
||||||
|
|
@ -157,7 +156,7 @@
|
||||||
errorMsg = ''
|
errorMsg = ''
|
||||||
resultMsg = ''
|
resultMsg = ''
|
||||||
try {
|
try {
|
||||||
await backendCall('SyncDisconnect')
|
await syncAPI().disconnect()
|
||||||
resultMsg = 'Disconnected from server.'
|
resultMsg = 'Disconnected from server.'
|
||||||
resultKind = ''
|
resultKind = ''
|
||||||
settings = null
|
settings = null
|
||||||
|
|
@ -184,18 +183,18 @@
|
||||||
<h3 style="margin:0 0 0.75rem;color:#e0e0f0;font-size:0.95rem;">Server</h3>
|
<h3 style="margin:0 0 0.75rem;color:#e0e0f0;font-size:0.95rem;">Server</h3>
|
||||||
|
|
||||||
<div style="margin-bottom:0.75rem;">
|
<div style="margin-bottom:0.75rem;">
|
||||||
<label style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">Server URL</label>
|
<label for="sync-server-url" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">Server URL</label>
|
||||||
<input type="text" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={serverUrl} placeholder="https://example.com" />
|
<input id="sync-server-url" type="text" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={serverUrl} placeholder="https://example.com" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom:0.75rem;">
|
<div style="margin-bottom:0.75rem;">
|
||||||
<label style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">Username</label>
|
<label for="sync-username" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">Username</label>
|
||||||
<input type="text" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={username} />
|
<input id="sync-username" type="text" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={username} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom:0.75rem;">
|
<div style="margin-bottom:0.75rem;">
|
||||||
<label style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">Password</label>
|
<label for="sync-password" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">Password</label>
|
||||||
<input type="password" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={password} />
|
<input id="sync-password" type="password" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={password} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;gap:0.5rem;margin-top:1rem;">
|
<div style="display:flex;gap:0.5rem;margin-top:1rem;">
|
||||||
|
|
@ -217,9 +216,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom:0.75rem;">
|
<div style="margin-bottom:0.75rem;">
|
||||||
<label style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">Sync interval</label>
|
<label for="sync-interval" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">Sync interval</label>
|
||||||
<div style="display:flex;align-items:center;gap:0.5rem;">
|
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||||
<input type="number" min="1" max="1440" bind:value={syncInterval} on:change={saveInterval} style="width:100px;background:#0f3460;border:1px solid #1a3a5c;color:#e0e0f0;padding:8px 10px;border-radius:4px;font-size:0.85rem;height:36px;" />
|
<input id="sync-interval" type="number" min="1" max="1440" bind:value={syncInterval} on:change={saveInterval} style="width:100px;background:#0f3460;border:1px solid #1a3a5c;color:#e0e0f0;padding:8px 10px;border-radius:4px;font-size:0.85rem;height:36px;" />
|
||||||
<span style="color:#a0a0b8;font-size:0.85rem;">minutes</span>
|
<span style="color:#a0a0b8;font-size:0.85rem;">minutes</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,6 @@
|
||||||
let status = 'disabled'
|
let status = 'disabled'
|
||||||
let interval
|
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() {
|
async function loadStatus() {
|
||||||
try {
|
try {
|
||||||
const s = await api?.settings?.read?.()
|
const s = await api?.settings?.read?.()
|
||||||
|
|
@ -25,7 +15,7 @@
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
try {
|
try {
|
||||||
const s = await wailsCall('SyncStatus')
|
const s = await api?.sync?.status?.()
|
||||||
if (s?.statusLabel) {
|
if (s?.statusLabel) {
|
||||||
status = s.statusLabel
|
status = s.statusLabel
|
||||||
} else if (s?.configured === false) {
|
} else if (s?.configured === false) {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
"source": "official",
|
"source": "official",
|
||||||
"provides": ["verstak/sync/v1", "verstak/sync.status/v1"],
|
"provides": ["verstak/sync/v1", "verstak/sync.status/v1"],
|
||||||
"requires": ["verstak/core/files/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" },
|
"frontend": { "entry": "frontend/dist/index.js" },
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"settingsPanels": [{ "id": "verstak.sync.settings", "title": "Sync", "component": "SyncSettings" }],
|
"settingsPanels": [{ "id": "verstak.sync.settings", "title": "Sync", "component": "SyncSettings" }],
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ package_plugin() {
|
||||||
local plugin_dir="$1"
|
local plugin_dir="$1"
|
||||||
local plugin_name="$2"
|
local plugin_name="$2"
|
||||||
local dist_dir="$ROOT/dist/$plugin_name"
|
local dist_dir="$ROOT/dist/$plugin_name"
|
||||||
|
local copied_plain_source=0
|
||||||
|
|
||||||
echo " → packaging dist/$plugin_name"
|
echo " → packaging dist/$plugin_name"
|
||||||
rm -rf "$dist_dir"
|
rm -rf "$dist_dir"
|
||||||
|
|
@ -47,14 +48,35 @@ package_plugin() {
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. frontend/dist/
|
# 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"
|
mkdir -p "$dist_dir/frontend/dist"
|
||||||
cp -r "$plugin_dir/frontend/dist/." "$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))"
|
echo " └─ frontend/dist ($(find "$dist_dir/frontend/dist" -type f | wc -l) file(s))"
|
||||||
elif [ -f "$plugin_dir/frontend/src/index.js" ]; then
|
fi
|
||||||
mkdir -p "$dist_dir/frontend/dist"
|
|
||||||
cp "$plugin_dir/frontend/src/index.js" "$dist_dir/frontend/dist/index.js"
|
if [ "$copied_plain_source" -eq 1 ] && [ -f "$dist_dir/plugin.json" ]; then
|
||||||
echo " └─ frontend/dist/index.js (from frontend/src/index.js)"
|
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
|
fi
|
||||||
|
|
||||||
# 3. backend binary
|
# 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 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 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 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
|
if [ "$HAS_DEPS" -eq 0 ]; then
|
||||||
echo " ⚠️ some deps missing — will skip matching plugin parts"
|
echo " ⚠️ some deps missing — will skip matching plugin parts"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
135
scripts/check.sh
135
scripts/check.sh
|
|
@ -24,44 +24,51 @@ if [ "$HAS_PYTHON" -eq 1 ]; then
|
||||||
echo "[manifest validation]"
|
echo "[manifest validation]"
|
||||||
SDK_SCHEMA="$ROOT/../verstak-sdk/schemas/manifest.json"
|
SDK_SCHEMA="$ROOT/../verstak-sdk/schemas/manifest.json"
|
||||||
if [ -f "$SDK_SCHEMA" ]; then
|
if [ -f "$SDK_SCHEMA" ]; then
|
||||||
|
set +e
|
||||||
python3 -c "
|
python3 -c "
|
||||||
import json, glob
|
import json, glob, os, sys
|
||||||
|
from jsonschema import Draft202012Validator
|
||||||
|
|
||||||
skipped = []
|
skipped = []
|
||||||
problems = []
|
problems = []
|
||||||
|
|
||||||
|
with open('$SDK_SCHEMA') as f:
|
||||||
|
schema = json.load(f)
|
||||||
|
|
||||||
|
validator = Draft202012Validator(schema)
|
||||||
|
|
||||||
for plugin_dir in glob.glob('$ROOT/plugins/*/'):
|
for plugin_dir in glob.glob('$ROOT/plugins/*/'):
|
||||||
manifest_path = plugin_dir + 'plugin.json'
|
manifest_path = plugin_dir + 'plugin.json'
|
||||||
|
plugin_name = os.path.basename(os.path.dirname(manifest_path))
|
||||||
try:
|
try:
|
||||||
with open(manifest_path) as f:
|
with open(manifest_path) as f:
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
skipped.append(plugin_dir.split('/')[-2])
|
skipped.append(plugin_name)
|
||||||
continue
|
continue
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
problems.append(plugin_dir.split('/')[-2] + ': invalid JSON — ' + str(e))
|
problems.append(plugin_name + ': invalid JSON - ' + str(e))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
checks = {
|
for err in sorted(validator.iter_errors(manifest), key=lambda e: list(e.path)):
|
||||||
'id': isinstance(manifest.get('id'), str) and '.' in manifest['id'],
|
where = '.'.join(str(part) for part in err.path) or '<root>'
|
||||||
'version': isinstance(manifest.get('version'), str),
|
problems.append(manifest.get('id', plugin_name) + ': ' + where + ': ' + err.message)
|
||||||
'schemaVersion': manifest.get('schemaVersion') == 1,
|
for field in ('requires', 'optionalRequires'):
|
||||||
'provides': isinstance(manifest.get('provides'), list),
|
if 'verstak/core/notes/v1' in manifest.get(field, []):
|
||||||
'requires': isinstance(manifest.get('requires'), list),
|
problems.append(manifest.get('id', plugin_name) + ': ' + field + ': core notes capability is not part of v2 platform contract')
|
||||||
}
|
|
||||||
for check, ok in checks.items():
|
|
||||||
if not ok:
|
|
||||||
problems.append(manifest.get('id', plugin_dir.split('/')[-2]) + ': missing/empty \"' + check + '\"')
|
|
||||||
|
|
||||||
if skipped:
|
if skipped:
|
||||||
print(' \u26a0\ufe0f skipped (no plugin.json): ' + ', '.join(skipped))
|
print(' warning: skipped (no plugin.json): ' + ', '.join(skipped))
|
||||||
if problems:
|
if problems:
|
||||||
for p in problems:
|
for p in problems:
|
||||||
print(' \u274c ' + p)
|
print(' FAIL ' + p)
|
||||||
|
sys.exit(1)
|
||||||
else:
|
else:
|
||||||
print(' \u2705 all manifests valid')
|
print(' OK all manifests valid')
|
||||||
"
|
"
|
||||||
report "manifests valid" $?
|
STATUS=$?
|
||||||
|
set -e
|
||||||
|
report "manifests valid" "$STATUS"
|
||||||
else
|
else
|
||||||
echo " ℹ️ SDK schema not found at $SDK_SCHEMA — run build.sh in verstak-sdk first"
|
echo " ℹ️ SDK schema not found at $SDK_SCHEMA — run build.sh in verstak-sdk first"
|
||||||
fi
|
fi
|
||||||
|
|
@ -69,6 +76,91 @@ else
|
||||||
echo " ℹ️ python3 not available — skipping manifest validation"
|
echo " ℹ️ python3 not available — skipping manifest validation"
|
||||||
fi
|
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 ""
|
echo ""
|
||||||
# Check all scripts in plugins are executable
|
# Check all scripts in plugins are executable
|
||||||
echo "[script permissions]"
|
echo "[script permissions]"
|
||||||
|
|
@ -88,6 +180,8 @@ echo "[frontend smoke]"
|
||||||
if command -v node &>/dev/null; then
|
if command -v node &>/dev/null; then
|
||||||
node "$ROOT/scripts/smoke-platform-frontend.js"
|
node "$ROOT/scripts/smoke-platform-frontend.js"
|
||||||
report "platform-test frontend components mount" $?
|
report "platform-test frontend components mount" $?
|
||||||
|
node "$ROOT/scripts/smoke-notes-plugin.js"
|
||||||
|
report "notes frontend behavior" $?
|
||||||
else
|
else
|
||||||
echo " ⚠️ node not available — skipping frontend smoke"
|
echo " ⚠️ node not available — skipping frontend smoke"
|
||||||
fi
|
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)
|
entry=$(node -e "const m=require('$manifest');console.log(m.frontend&&m.frontend.entry||'')" 2>/dev/null)
|
||||||
if [ -z "$entry" ]; then continue; fi
|
if [ -z "$entry" ]; then continue; fi
|
||||||
bundle="$plugin_dir$entry"
|
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 "$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"
|
echo " ❌ $plugin_id: bundle not found at $entry"
|
||||||
BUNDLE_FAILED=1
|
BUNDLE_FAILED=1
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -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('<svg')) {
|
||||||
|
throw new Error('NotesView empty state renders raw SVG text');
|
||||||
|
}
|
||||||
|
|
||||||
|
const createApi = makeApi({ metadataAlwaysExists: true });
|
||||||
|
const { container, document } = await mountNotes(createApi);
|
||||||
|
const createButton = walk(container, (node) => 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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue