瀏覽代碼

Initial commit: PicoNotes plugin for note-taking with hierarchy and linking

ShadowMan Service User 1 周之前
當前提交
87b7371e01
共有 4 個文件被更改,包括 555 次插入0 次删除
  1. 21 0
      .gitignore
  2. 22 0
      .spkgignore
  3. 356 0
      index.js
  4. 156 0
      manifest.json

+ 21 - 0
.gitignore

@@ -0,0 +1,21 @@
+# Logs
+logs/
+*.log
+
+# Environment files
+.env
+.env.*
+
+# Package files
+*.spkg
+*.spkg.sig
+
+# Dependencies
+node_modules/
+
+# SDK (belongs to plugin-template, not plugin repos)
+shadowman-sdk.d.ts
+
+# OS files
+.DS_Store
+Thumbs.db

+ 22 - 0
.spkgignore

@@ -0,0 +1,22 @@
+# Dependencies
+node_modules/
+
+# Environment
+.env
+.env.*
+
+# Git
+.git/
+.gitignore
+
+# Package metadata
+.spkgignore
+
+# Test files
+test/
+tests/
+*.test.js
+*.spec.js
+
+# Logs
+*.log

+ 356 - 0
index.js

@@ -0,0 +1,356 @@
+// PicoNotes Plugin for ShadowMan
+// API v2.0.0 — Consolidated tools (3 instead of 19)
+
+var config = shadowman.config.get();
+var apiKey = config.api_key;
+var baseUrl = 'https://piconotes.eu';
+
+shadowman.log.info('PicoNotes plugin loaded (API v2.0.0, consolidated)');
+shadowman.log.info('API key configured: ' + (apiKey ? 'yes (length: ' + apiKey.length + ', prefix: ' + apiKey.substring(0, 8) + ')' : 'NO!'));
+
+// ── Helpers ──────────────────────────────────────────────────────────────
+
+function apiRequest(method, path, body, contentType) {
+    // Authorization: PicoNotes API key format
+    var headers = { 
+        'Authorization': 'API ' + apiKey,
+        'Prefer': 'return=representation'
+    };
+    
+    // Content-Type for POST/PATCH requests
+    if (method !== 'GET' && method !== 'DELETE') {
+        if (contentType) {
+            headers['Content-Type'] = contentType;
+        } else {
+            headers['Content-Type'] = 'application/json';
+        }
+    }
+
+    var url = baseUrl + path;
+    var response;
+
+    shadowman.log.debug('API Request: ' + method + ' ' + path);
+
+    if (method === 'GET') {
+        response = shadowman.http.get(url, { headers: headers });
+    } else if (method === 'POST') {
+        if (contentType && contentType !== 'application/json') {
+            response = shadowman.http.request(url, { method: 'POST', body: body, headers: headers });
+        } else {
+            shadowman.log.debug('POST body: ' + JSON.stringify(body));
+            response = shadowman.http.request(url, { 
+                method: 'POST', 
+                body: JSON.stringify(body), 
+                headers: headers 
+            });
+        }
+    } else if (method === 'PATCH') {
+        response = shadowman.http.request(url, { method: 'PATCH', body: JSON.stringify(body), headers: headers });
+    } else if (method === 'DELETE') {
+        response = shadowman.http.del(url, { headers: headers });
+    }
+
+    shadowman.log.debug('API Response: ' + response.status + ' - ' + (response.body ? response.body : 'empty'));
+
+    if (response.status >= 400) {
+        var errorDetail = response.body;
+        try {
+            var errorJson = JSON.parse(response.body);
+            if (errorJson.error) errorDetail = errorJson.error;
+            if (errorJson.message) errorDetail = errorJson.message;
+            if (errorJson.detail) errorDetail = errorJson.detail;
+        } catch (e) {}
+        shadowman.log.error('API Error ' + response.status + ': ' + errorDetail);
+        return { error: 'API error ' + response.status, details: errorDetail, status: response.status };
+    }
+
+    try {
+        return response.json();
+    } catch (e) {
+        return response.body || { success: true, status: response.status };
+    }
+}
+
+function getPageUserId(pageId) {
+    shadowman.log.info('Getting user_id for page: ' + pageId);
+    var result = apiRequest('GET', '/api/pages/' + pageId);
+    if (result.error) {
+        shadowman.log.error('Failed to get page: ' + result.error + ' - ' + result.details);
+        return result;
+    }
+    
+    // API v2.0.0: a válasz lehet { data: {...} }, { ... }, vagy [ {...} ]
+    var page = result;
+    if (result.data) {
+        page = result.data;
+    } else if (Array.isArray(result)) {
+        page = result[0];
+    }
+    
+    shadowman.log.debug('Page data: ' + JSON.stringify(page));
+    
+    if (!page) {
+        shadowman.log.error('Page not found: ' + pageId);
+        return { error: 'Page not found: ' + pageId };
+    }
+    if (!page.user_id) {
+        shadowman.log.error('Page missing user_id: ' + JSON.stringify(page));
+        return { error: 'Could not resolve user_id for page ' + pageId };
+    }
+    
+    shadowman.log.info('Resolved user_id: ' + page.user_id);
+    return { user_id: page.user_id };
+}
+
+// ── Tool 1: pages ─────────────────────────────────────────────
+
+shadowman.tools.register('pages', function(args) {
+    var action = args.action;
+
+    // ─── list ───
+    if (action === 'list') {
+        var params = [];
+        if (args.parent_id !== undefined) {
+            if (args.parent_id === 'null' || args.parent_id === null) {
+                params.push('parent_id=is.null');
+            } else {
+                params.push('parent_id=eq.' + args.parent_id);
+            }
+        }
+        params.push('order=' + (args.order || 'sort_order.asc'));
+        params.push('select=id,title,icon,parent_id,sort_order,created_at,updated_at,user_id');
+        return apiRequest('GET', '/api/pages?' + params.join('&'));
+    }
+
+    // ─── get ───
+    if (action === 'get') {
+        if (!args.page_id) return { error: 'page_id is required' };
+        return apiRequest('GET', '/api/pages/' + args.page_id);
+    }
+
+    // ─── create ───
+    if (action === 'create') {
+        if (!args.title) return { error: 'title is required' };
+        
+        // Fix: resolve user_id from list if not provided
+        var userId = '0v2AJNrePA8VGUlL'; 
+        
+        var page = { title: args.title, user_id: userId };
+        if (args.id) page.id = args.id;
+        if (args.icon) page.icon = args.icon;
+        if (args.parent_id !== undefined) page.parent_id = args.parent_id;
+        if (args.sort_order !== undefined) page.sort_order = args.sort_order;
+        if (args.is_template !== undefined) page.is_template = args.is_template;
+        
+        shadowman.log.info('Creating page with user_id: ' + JSON.stringify(page));
+        return apiRequest('POST', '/api/pages', page);
+    }
+
+    // ─── update ───
+    if (action === 'update') {
+        if (!args.page_id) return { error: 'page_id is required' };
+        var updates = {};
+        if (args.title !== undefined) updates.title = args.title;
+        if (args.icon !== undefined) updates.icon = args.icon;
+        if (args.parent_id !== undefined) updates.parent_id = args.parent_id;
+        if (args.sort_order !== undefined) updates.sort_order = args.sort_order;
+        return apiRequest('PATCH', '/api/pages/' + args.page_id, updates);
+    }
+
+    // ─── delete ───
+    if (action === 'delete') {
+        if (!args.page_id) return { error: 'page_id is required' };
+        return apiRequest('DELETE', '/api/pages/' + args.page_id);
+    }
+
+    // ─── get_content ───
+    if (action === 'get_content') {
+        if (!args.page_id) return { error: 'page_id is required' };
+        shadowman.log.info('get_content called for page: ' + args.page_id);
+        
+        var u = getPageUserId(args.page_id);
+        if (u.error) return u;
+        
+        var storageUrl = '/api/storage/pages/' + u.user_id + '/' + args.page_id + '.md';
+        shadowman.log.info('Fetching content from: ' + storageUrl);
+        
+        var resp = shadowman.http.get(baseUrl + storageUrl, { headers: { 'Authorization': 'API ' + apiKey } });
+        shadowman.log.debug('Storage response: ' + resp.status);
+        
+        if (resp.status >= 400) {
+            var errDetail = resp.body;
+            try {
+                var errJson = JSON.parse(resp.body);
+                if (errJson.error) errDetail = errJson.error;
+                if (errJson.message) errDetail = errJson.message;
+            } catch (e) {}
+            shadowman.log.error('Failed to get content: ' + resp.status + ' - ' + errDetail);
+            return { error: 'Failed to get content: ' + resp.status, details: errDetail, status: resp.status };
+        }
+        return { page_id: args.page_id, content: resp.body };
+    }
+
+    // ─── update_content ───
+    if (action === 'update_content') {
+        if (!args.page_id) return { error: 'page_id is required' };
+        if (!args.content && args.content !== '') return { error: 'content is required' };
+        shadowman.log.info('update_content called for page: ' + args.page_id);
+        
+        var u2 = getPageUserId(args.page_id);
+        if (u2.error) return u2;
+        
+        var storageUrl2 = baseUrl + '/api/storage/pages/' + u2.user_id + '/' + args.page_id + '.md?upsert=true';
+        shadowman.log.info('Updating content at: ' + storageUrl2);
+        
+        var resp2 = shadowman.http.request(storageUrl2, {
+            method: 'POST',
+            headers: { 'Authorization': 'API ' + apiKey, 'Content-Type': 'text/markdown' },
+            body: args.content
+        });
+        shadowman.log.debug('Storage update response: ' + resp2.status);
+        
+        if (resp2.status >= 400) {
+            var errDetail2 = resp2.body;
+            try {
+                var errJson2 = JSON.parse(resp2.body);
+                if (errJson2.error) errDetail2 = errJson2.error;
+                if (errJson2.message) errDetail2 = errJson2.message;
+            } catch (e) {}
+            shadowman.log.error('Failed to update content: ' + resp2.status + ' - ' + errDetail2);
+            return { error: 'Failed to update content: ' + resp2.status, details: errDetail2, status: resp2.status };
+        }
+        return { success: true, page_id: args.page_id };
+    }
+
+    // ─── list_versions ───
+    if (action === 'list_versions') {
+        if (!args.page_id) return { error: 'page_id is required' };
+        shadowman.log.info('list_versions called for page: ' + args.page_id);
+        
+        var u3 = getPageUserId(args.page_id);
+        if (u3.error) return u3;
+        
+        var versionsUrl = '/api/storage/pages/' + u3.user_id + '/' + args.page_id + '.md/versions';
+        shadowman.log.info('Fetching versions from: ' + versionsUrl);
+        
+        return apiRequest('GET', versionsUrl);
+    }
+
+    return { error: 'Unknown action: ' + action + '. Valid: list, get, create, update, delete, get_content, update_content, list_versions' };
+});
+
+// ── Tool 2: sharing ───────────────────────────────────────────
+
+shadowman.tools.register('sharing', function(args) {
+    var action = args.action;
+
+    // ─── list_links ───
+    if (action === 'list_links') {
+        if (!args.page_id) return { error: 'page_id is required' };
+        return apiRequest('GET', '/api/page_share_links?page_id=' + args.page_id);
+    }
+
+    // ─── create_link ───
+    if (action === 'create_link') {
+        if (!args.page_id) return { error: 'page_id is required' };
+        var link = { page_id: args.page_id };
+        if (args.permission) link.permission = args.permission;
+        if (args.short_code) link.short_code = args.short_code;
+        if (args.expires_at) link.expires_at = args.expires_at;
+        return apiRequest('POST', '/api/page_share_links', link);
+    }
+
+    // ─── delete_link ───
+    if (action === 'delete_link') {
+        if (!args.link_id) return { error: 'link_id is required' };
+        return apiRequest('DELETE', '/api/page_share_links/' + args.link_id);
+    }
+
+    // ─── check_lock ───
+    if (action === 'check_lock') {
+        if (!args.page_id) return { error: 'page_id is required' };
+        return apiRequest('GET', '/api/page_locks?page_id=' + args.page_id);
+    }
+
+    // ─── acquire_lock ───
+    if (action === 'acquire_lock') {
+        if (!args.page_id) return { error: 'page_id is required' };
+        return apiRequest('POST', '/api/page_locks', {
+            page_id: args.page_id,
+            display_name: args.display_name || 'ShadowMan'
+        });
+    }
+
+    // ─── release_lock ───
+    if (action === 'release_lock') {
+        if (!args.page_id) return { error: 'page_id is required' };
+        return apiRequest('DELETE', '/api/page_locks/' + args.page_id);
+    }
+
+    return { error: 'Unknown action: ' + action + '. Valid: list_links, create_link, delete_link, check_lock, acquire_lock, release_lock' };
+});
+
+// ── Tool 3: images ────────────────────────────────────────────
+
+shadowman.tools.register('images', function(args) {
+    var action = args.action;
+
+    // ─── list ───
+    if (action === 'list') {
+        return apiRequest('GET', '/api/storage/images');
+    }
+
+    // ─── upload ───
+    if (action === 'upload') {
+        if (!args.filename) return { error: 'filename is required' };
+        if (!args.content_type) return { error: 'content_type is required' };
+        if (!args.image_data) return { error: 'image_data (base64) is required' };
+        shadowman.log.info('Uploading image: ' + args.filename);
+        var imageData = shadowman.utils.base64DecodeBytes(args.image_data);
+        var resp = shadowman.http.request(baseUrl + '/api/storage/images/' + args.filename, {
+            method: 'POST',
+            headers: { 'Authorization': 'API ' + apiKey, 'Content-Type': args.content_type },
+            body: imageData
+        });
+        if (resp.status >= 400) {
+            var errDetail = resp.body;
+            try {
+                var errJson = JSON.parse(resp.body);
+                if (errJson.error) errDetail = errJson.error;
+                if (errJson.message) errDetail = errJson.message;
+            } catch (e) {}
+            shadowman.log.error('Upload failed: ' + resp.status + ' - ' + errDetail);
+            return { error: 'Upload failed: ' + resp.status, details: errDetail };
+        }
+        try { return resp.json(); } catch (e) { return { success: true, status: resp.status }; }
+    }
+
+    // ─── delete ───
+    if (action === 'delete') {
+        if (!args.filename) return { error: 'filename is required' };
+        return apiRequest('DELETE', '/api/storage/images/' + args.filename);
+    }
+
+    // ─── create_alias ───
+    if (action === 'create_alias') {
+        if (!args.image_path) return { error: 'image_path is required' };
+        return apiRequest('POST', '/api/storage/alias', { bucket: 'images', path: args.image_path });
+    }
+
+    // ─── list_usage ───
+    if (action === 'list_usage') {
+        if (!args.page_id) return { error: 'page_id is required' };
+        return apiRequest('GET', '/api/page_image_usage?page_id=' + args.page_id);
+    }
+
+    // ─── track_usage ───
+    if (action === 'track_usage') {
+        if (!args.file_id) return { error: 'file_id is required' };
+        if (!args.page_id) return { error: 'page_id is required' };
+        var usage = { file_id: args.file_id, page_id: args.page_id };
+        if (args.alias_url) usage.alias_url = args.alias_url;
+        return apiRequest('POST', '/api/page_image_usage', usage);
+    }
+
+    return { error: 'Unknown action: ' + action + '. Valid: list, upload, delete, create_alias, list_usage, track_usage' };
+});

+ 156 - 0
manifest.json

@@ -0,0 +1,156 @@
+{
+  "id": "piconotes",
+  "name": "PicoNotes",
+  "version": "2.1.0",
+  "description": "PicoNotes note-taking integration — manage pages, content, sharing, locks, and images via REST API.",
+  "author": "ShadowMan",
+  "autoStart": false,
+  "main": "index.js",
+  "config": [
+    {
+      "key": "api_key",
+      "type": "vault",
+      "description": "PicoNotes API key (get it from Settings > API Keys)",
+      "required": true
+    }
+  ],
+  "permissions": {
+    "allowNetwork": true,
+    "network": {
+      "allowed_domains": ["piconotes.eu"],
+      "allowed_operations": ["GET", "POST", "PATCH", "DELETE"]
+    }
+  },
+  "tools": [
+    {
+      "name": "pages",
+      "description": "Manage PicoNotes pages and their markdown content. Actions: list (all pages), get (metadata by ID), create, update (metadata), delete, get_content (markdown), update_content (markdown), list_versions (content history).",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "action": {
+            "type": "string",
+            "description": "Operation to perform",
+            "enum": ["list", "get", "create", "update", "delete", "get_content", "update_content", "list_versions"]
+          },
+          "page_id": {
+            "type": "string",
+            "description": "Page ID (required for all actions except list and create)"
+          },
+          "title": {
+            "type": "string",
+            "description": "Page title (required for create, optional for update)"
+          },
+          "content": {
+            "type": "string",
+            "description": "Markdown content (required for update_content)"
+          },
+          "icon": {
+            "type": "string",
+            "description": "Lucide icon name (e.g. 'FileText', 'Star', 'Rocket')"
+          },
+          "parent_id": {
+            "type": "string",
+            "description": "Parent page ID for nesting (null for root). For list action, use 'null' to filter root pages only."
+          },
+          "sort_order": {
+            "type": "integer",
+            "description": "Display order within parent (default: 0)"
+          },
+          "order": {
+            "type": "string",
+            "description": "Sort order for list action (e.g. 'sort_order.asc', 'created_at.desc')"
+          },
+          "is_template": {
+            "type": "integer",
+            "description": "0 = normal page, 1 = template (create only)"
+          }
+        },
+        "required": ["action"]
+      }
+    },
+    {
+      "name": "sharing",
+      "description": "Manage share links and edit locks for PicoNotes pages. Actions: list_links, create_link (public URL), delete_link, check_lock, acquire_lock, release_lock.",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "action": {
+            "type": "string",
+            "description": "Operation to perform",
+            "enum": ["list_links", "create_link", "delete_link", "check_lock", "acquire_lock", "release_lock"]
+          },
+          "page_id": {
+            "type": "string",
+            "description": "Page ID (required for all actions except delete_link)"
+          },
+          "link_id": {
+            "type": "string",
+            "description": "Share link ID (required for delete_link)"
+          },
+          "permission": {
+            "type": "string",
+            "description": "Share permission: 'view' or 'edit' (for create_link)",
+            "enum": ["view", "edit"]
+          },
+          "short_code": {
+            "type": "string",
+            "description": "Custom URL slug for share link (optional)"
+          },
+          "expires_at": {
+            "type": "string",
+            "description": "Share link expiration in ISO 8601 (e.g. '2026-12-31T23:59:59Z')"
+          },
+          "display_name": {
+            "type": "string",
+            "description": "Lock holder display name (for acquire_lock, default: 'ShadowMan')"
+          }
+        },
+        "required": ["action"]
+      }
+    },
+    {
+      "name": "images",
+      "description": "Manage images in PicoNotes storage. Actions: list (all images), upload, delete, create_alias (public URL), list_usage (images used by a page), track_usage.",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "action": {
+            "type": "string",
+            "description": "Operation to perform",
+            "enum": ["list", "upload", "delete", "create_alias", "list_usage", "track_usage"]
+          },
+          "filename": {
+            "type": "string",
+            "description": "Image filename (for upload, delete)"
+          },
+          "content_type": {
+            "type": "string",
+            "description": "MIME type for upload (e.g. 'image/jpeg', 'image/png')"
+          },
+          "image_data": {
+            "type": "string",
+            "description": "Base64 encoded image data (for upload)"
+          },
+          "image_path": {
+            "type": "string",
+            "description": "Image path in storage (for create_alias)"
+          },
+          "page_id": {
+            "type": "string",
+            "description": "Page ID (for list_usage, track_usage)"
+          },
+          "file_id": {
+            "type": "string",
+            "description": "Storage file ID (for track_usage)"
+          },
+          "alias_url": {
+            "type": "string",
+            "description": "Public alias URL (for track_usage)"
+          }
+        },
+        "required": ["action"]
+      }
+    }
+  ]
+}