friday-mcp-v2 2.0.6 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridge-client.js +210 -0
- package/dist/bridge-common.js +92 -0
- package/dist/editor-session-store.js +164 -0
- package/dist/friday-bridge.js +883 -0
- package/dist/mcp-server.js +2790 -1827
- package/dist/wordpress-api.js +514 -474
- package/dist/ws-server.js +407 -0
- package/package.json +29 -28
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { dirname, resolve as resolvePath } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
|
+
import {
|
|
8
|
+
BRIDGE_TIMEOUTS,
|
|
9
|
+
createBridgeError,
|
|
10
|
+
getBridgeEndpoint,
|
|
11
|
+
isRecoverableBridgeError,
|
|
12
|
+
normalizeSiteUrl,
|
|
13
|
+
} from './bridge-common.js';
|
|
14
|
+
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
export class BridgeClient {
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
this.endpoint = options.endpoint || getBridgeEndpoint();
|
|
20
|
+
this.bridgeScriptPath = options.bridgeScriptPath || resolvePath(__dirname, 'friday-bridge.js');
|
|
21
|
+
this.startPromise = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async ping(timeoutMs = 1000, autoStart = false) {
|
|
25
|
+
return this.request('ping', {}, { timeoutMs, autoStart });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async ensureBridgeRunning() {
|
|
29
|
+
if (this.startPromise) return this.startPromise;
|
|
30
|
+
this.startPromise = this._ensureBridgeRunning();
|
|
31
|
+
try {
|
|
32
|
+
return await this.startPromise;
|
|
33
|
+
} finally {
|
|
34
|
+
this.startPromise = null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async _ensureBridgeRunning() {
|
|
39
|
+
try {
|
|
40
|
+
await this.ping(1000, false);
|
|
41
|
+
return;
|
|
42
|
+
} catch {
|
|
43
|
+
// bridge 未起動
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const child = spawn(process.execPath, [this.bridgeScriptPath], {
|
|
47
|
+
detached: true,
|
|
48
|
+
stdio: 'ignore',
|
|
49
|
+
windowsHide: true,
|
|
50
|
+
env: process.env,
|
|
51
|
+
});
|
|
52
|
+
child.unref();
|
|
53
|
+
|
|
54
|
+
const deadline = Date.now() + BRIDGE_TIMEOUTS.STARTUP;
|
|
55
|
+
let lastError = null;
|
|
56
|
+
while (Date.now() < deadline) {
|
|
57
|
+
await delay(BRIDGE_TIMEOUTS.STARTUP_POLL);
|
|
58
|
+
try {
|
|
59
|
+
await this.ping(1000, false);
|
|
60
|
+
return;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
lastError = error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
throw createBridgeError(
|
|
67
|
+
'BRIDGE_UNAVAILABLE',
|
|
68
|
+
`Bridge failed to start: ${lastError?.message || 'unknown error'}`,
|
|
69
|
+
true
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async request(method, params = {}, options = {}) {
|
|
74
|
+
const timeoutMs = options.timeoutMs || BRIDGE_TIMEOUTS.READ;
|
|
75
|
+
const autoStart = options.autoStart !== false;
|
|
76
|
+
try {
|
|
77
|
+
return await this._requestOnce(method, params, timeoutMs);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (autoStart && isRecoverableBridgeError(error)) {
|
|
80
|
+
await this.ensureBridgeRunning();
|
|
81
|
+
return this._requestOnce(method, params, timeoutMs);
|
|
82
|
+
}
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
_requestOnce(method, params, timeoutMs) {
|
|
88
|
+
const requestId = crypto.randomUUID();
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const socket = process.platform === 'win32'
|
|
91
|
+
? net.createConnection(this.endpoint)
|
|
92
|
+
: net.createConnection({ path: this.endpoint });
|
|
93
|
+
|
|
94
|
+
let settled = false;
|
|
95
|
+
let buffer = '';
|
|
96
|
+
|
|
97
|
+
const finish = (fn, value) => {
|
|
98
|
+
if (settled) return;
|
|
99
|
+
settled = true;
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
socket.destroy();
|
|
102
|
+
fn(value);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
finish(reject, createBridgeError('BRIDGE_TIMEOUT', `Bridge RPC timeout: ${method}`, true));
|
|
107
|
+
}, timeoutMs);
|
|
108
|
+
|
|
109
|
+
socket.setEncoding('utf8');
|
|
110
|
+
|
|
111
|
+
socket.on('connect', () => {
|
|
112
|
+
socket.write(JSON.stringify({
|
|
113
|
+
type: 'request',
|
|
114
|
+
id: requestId,
|
|
115
|
+
method,
|
|
116
|
+
params,
|
|
117
|
+
}) + '\n');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
socket.on('data', (chunk) => {
|
|
121
|
+
buffer += chunk;
|
|
122
|
+
while (true) {
|
|
123
|
+
const newline = buffer.indexOf('\n');
|
|
124
|
+
if (newline === -1) break;
|
|
125
|
+
const line = buffer.slice(0, newline).trim();
|
|
126
|
+
buffer = buffer.slice(newline + 1);
|
|
127
|
+
if (!line) continue;
|
|
128
|
+
let message;
|
|
129
|
+
try {
|
|
130
|
+
message = JSON.parse(line);
|
|
131
|
+
} catch {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (message.type !== 'response' || message.id !== requestId) continue;
|
|
135
|
+
if (message.ok) {
|
|
136
|
+
finish(resolve, message.result);
|
|
137
|
+
} else {
|
|
138
|
+
finish(reject, createBridgeError(
|
|
139
|
+
message.error?.code || 'BRIDGE_UNAVAILABLE',
|
|
140
|
+
message.error?.message || `Bridge RPC failed: ${method}`,
|
|
141
|
+
!!message.error?.retryable
|
|
142
|
+
));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
socket.on('error', (error) => {
|
|
148
|
+
finish(reject, error);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
socket.on('close', () => {
|
|
152
|
+
if (!settled) {
|
|
153
|
+
finish(reject, createBridgeError('BRIDGE_UNAVAILABLE', `Bridge connection closed: ${method}`, true));
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async getBridgeStatus(options = {}) {
|
|
160
|
+
return this.request('getBridgeStatus', {}, { timeoutMs: BRIDGE_TIMEOUTS.READ, ...options });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async listConnections(options = {}) {
|
|
164
|
+
return this.request('listConnections', {}, { timeoutMs: BRIDGE_TIMEOUTS.READ, ...options });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async getConnectedEditors(siteUrl, options = {}) {
|
|
168
|
+
const result = await this.listConnections(options);
|
|
169
|
+
return (result.sessions || []).filter((session) => session.siteUrl === normalizeSiteUrl(siteUrl));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async isPostEditing(postId, siteUrl, options = {}) {
|
|
173
|
+
const result = await this.listConnections(options);
|
|
174
|
+
return (result.sessions || []).some((session) =>
|
|
175
|
+
session.siteUrl === normalizeSiteUrl(siteUrl) && Number(session.postId) === Number(postId)
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async getEditorState(params, options = {}) {
|
|
180
|
+
return this.request('getEditorState', params, { timeoutMs: BRIDGE_TIMEOUTS.READ, ...options });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async getSelection(params, options = {}) {
|
|
184
|
+
return this.request('getSelection', params, { timeoutMs: BRIDGE_TIMEOUTS.READ, ...options });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async getArticleStructure(params, options = {}) {
|
|
188
|
+
return this.request('getArticleStructure', params, { timeoutMs: BRIDGE_TIMEOUTS.READ, ...options });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async sendEditorCommand(params, options = {}) {
|
|
192
|
+
return this.request('sendEditorCommand', params, { timeoutMs: BRIDGE_TIMEOUTS.MUTATION, ...options });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async selectBlock(params, options = {}) {
|
|
196
|
+
return this.request('selectBlock', params, { timeoutMs: BRIDGE_TIMEOUTS.MUTATION, ...options });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async searchBlocks(params, options = {}) {
|
|
200
|
+
return this.request('searchBlocks', params, { timeoutMs: BRIDGE_TIMEOUTS.COMMAND, ...options });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async sendTabCommand(params, options = {}) {
|
|
204
|
+
return this.request('sendTabCommand', params, { timeoutMs: BRIDGE_TIMEOUTS.MUTATION, ...options });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async shutdown(options = {}) {
|
|
208
|
+
return this.request('shutdown', {}, { timeoutMs: BRIDGE_TIMEOUTS.READ, autoStart: false, ...options });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const BRIDGE_TIMEOUTS = {
|
|
5
|
+
READ: 5000,
|
|
6
|
+
COMMAND: 30000,
|
|
7
|
+
MUTATION: 120000,
|
|
8
|
+
STARTUP: 5000,
|
|
9
|
+
STARTUP_POLL: 250,
|
|
10
|
+
STALE_SESSION_MS: 60000,
|
|
11
|
+
OWNER_WAIT: 10000,
|
|
12
|
+
OWNER_WAIT_INTERVAL: 1000,
|
|
13
|
+
PER_ARTICLE_QUEUE_LIMIT: 20,
|
|
14
|
+
GLOBAL_QUEUE_LIMIT: 200,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function normalizeSiteUrl(url) {
|
|
18
|
+
return url?.replace(/\/+$/, '') || '';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function sanitizeIdentifier(value) {
|
|
22
|
+
const sanitized = String(value || 'user').replace(/[^a-zA-Z0-9_.-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
23
|
+
return sanitized || 'user';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getCurrentUserTag() {
|
|
27
|
+
try {
|
|
28
|
+
return sanitizeIdentifier(os.userInfo().username);
|
|
29
|
+
} catch {
|
|
30
|
+
return sanitizeIdentifier(process.env.USERNAME || process.env.USER || 'user');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getBridgeEndpoint() {
|
|
35
|
+
const userTag = getCurrentUserTag();
|
|
36
|
+
if (process.platform === 'win32') {
|
|
37
|
+
return `\\\\.\\pipe\\friday-mcp-v2-${userTag}`;
|
|
38
|
+
}
|
|
39
|
+
const runtimeDir = process.env.XDG_RUNTIME_DIR || join(os.tmpdir(), `friday-mcp-${userTag}`);
|
|
40
|
+
return join(runtimeDir, 'bridge.sock');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getBridgeLogPath() {
|
|
44
|
+
if (process.platform === 'win32') {
|
|
45
|
+
const localAppData = process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local');
|
|
46
|
+
return join(localAppData, 'FridayMCP', 'logs', 'bridge.log');
|
|
47
|
+
}
|
|
48
|
+
return join(os.homedir(), '.local', 'share', 'friday-mcp', 'logs', 'bridge.log');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getBridgePidPath() {
|
|
52
|
+
if (process.platform === 'win32') {
|
|
53
|
+
const localAppData = process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local');
|
|
54
|
+
return join(localAppData, 'FridayMCP', 'bridge.pid');
|
|
55
|
+
}
|
|
56
|
+
return join(os.homedir(), '.local', 'share', 'friday-mcp', 'bridge.pid');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildArticleKey(siteUrl, postId) {
|
|
60
|
+
return `${normalizeSiteUrl(siteUrl)}::${Number(postId)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function parseArticleKey(articleKey) {
|
|
64
|
+
const idx = articleKey.lastIndexOf('::');
|
|
65
|
+
if (idx === -1) return { siteUrl: '', postId: NaN };
|
|
66
|
+
return {
|
|
67
|
+
siteUrl: articleKey.slice(0, idx),
|
|
68
|
+
postId: Number(articleKey.slice(idx + 2)),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function isRecoverableBridgeError(error) {
|
|
73
|
+
const message = error?.message || '';
|
|
74
|
+
const code = error?.code || error?.bridgeCode || '';
|
|
75
|
+
return [
|
|
76
|
+
'ENOENT',
|
|
77
|
+
'ECONNREFUSED',
|
|
78
|
+
'ECONNRESET',
|
|
79
|
+
'EPIPE',
|
|
80
|
+
'ETIMEDOUT',
|
|
81
|
+
'BRIDGE_UNAVAILABLE',
|
|
82
|
+
'BRIDGE_TIMEOUT',
|
|
83
|
+
].includes(code) || /not connected|socket hang up|connect/i.test(message);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createBridgeError(code, message, retryable = false) {
|
|
87
|
+
const error = new Error(message);
|
|
88
|
+
error.code = code;
|
|
89
|
+
error.bridgeCode = code;
|
|
90
|
+
error.retryable = retryable;
|
|
91
|
+
return error;
|
|
92
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F.R.I.D.A.Y Editor Session Store — Phase 2B
|
|
3
|
+
*
|
|
4
|
+
* WS 経由で受信した エディタ状態を Node メモリ内に保持するストア。
|
|
5
|
+
* 外部依存なし。全サイト共通の 1 インスタンスで運用。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class EditorSessionStore {
|
|
9
|
+
constructor() {
|
|
10
|
+
/** @type {Map<string, SessionState>} sessionId → SessionState */
|
|
11
|
+
this.sessions = new Map();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// =========================================================================
|
|
15
|
+
// 書き込み API(ws-server.js から呼ばれる)
|
|
16
|
+
// =========================================================================
|
|
17
|
+
|
|
18
|
+
register(sessionId, { postId, siteUrl, tabId, postTitle }) {
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
const existing = this.sessions.get(sessionId);
|
|
21
|
+
this.sessions.set(sessionId, {
|
|
22
|
+
postId: Number(postId),
|
|
23
|
+
siteUrl: this._normUrl(siteUrl),
|
|
24
|
+
tabId,
|
|
25
|
+
postTitle: postTitle || '',
|
|
26
|
+
registeredAt: existing?.registeredAt || now,
|
|
27
|
+
lastRegisterAt: now,
|
|
28
|
+
lastTouchedAt: now,
|
|
29
|
+
lastTouchSource: 'register',
|
|
30
|
+
touchCount: (existing?.touchCount || 0) + 1,
|
|
31
|
+
lastStateSyncAt: existing?.lastStateSyncAt || 0,
|
|
32
|
+
lastSelectionSyncAt: existing?.lastSelectionSyncAt || 0,
|
|
33
|
+
lastHeartbeatAt: existing?.lastHeartbeatAt || 0,
|
|
34
|
+
// 状態データ(既存があれば引き継ぎ — WS 再接続時の register 再送で消さないため)
|
|
35
|
+
selectedBlock: existing?.selectedBlock ?? null,
|
|
36
|
+
allBlocks: existing?.allBlocks ?? null,
|
|
37
|
+
headings: existing?.headings ?? null,
|
|
38
|
+
blockSummary: existing?.blockSummary ?? null,
|
|
39
|
+
selectionSynced: existing?.selectionSynced ?? false,
|
|
40
|
+
stateSynced: existing?.stateSynced ?? false,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
unregister(sessionId) {
|
|
45
|
+
this.sessions.delete(sessionId);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
clear() {
|
|
49
|
+
this.sessions.clear();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
updateState(sessionId, { blockSummary, allBlocks, headings }) {
|
|
53
|
+
const session = this.sessions.get(sessionId);
|
|
54
|
+
if (!session) return;
|
|
55
|
+
session.blockSummary = blockSummary ?? session.blockSummary;
|
|
56
|
+
session.allBlocks = allBlocks ?? session.allBlocks;
|
|
57
|
+
session.headings = headings ?? session.headings;
|
|
58
|
+
session.stateSynced = true;
|
|
59
|
+
session.lastTouchedAt = Date.now();
|
|
60
|
+
session.lastTouchSource = 'state-sync';
|
|
61
|
+
session.lastStateSyncAt = session.lastTouchedAt;
|
|
62
|
+
session.touchCount++;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
updateSelection(sessionId, selectedBlock) {
|
|
66
|
+
const session = this.sessions.get(sessionId);
|
|
67
|
+
if (!session) return;
|
|
68
|
+
session.selectedBlock = selectedBlock;
|
|
69
|
+
session.selectionSynced = true;
|
|
70
|
+
session.lastTouchedAt = Date.now();
|
|
71
|
+
session.lastTouchSource = 'selection-sync';
|
|
72
|
+
session.lastSelectionSyncAt = session.lastTouchedAt;
|
|
73
|
+
session.touchCount++;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
touch(sessionId, source) {
|
|
77
|
+
const session = this.sessions.get(sessionId);
|
|
78
|
+
if (!session) return;
|
|
79
|
+
session.lastTouchedAt = Date.now();
|
|
80
|
+
session.lastTouchSource = source;
|
|
81
|
+
if (source === 'heartbeat') {
|
|
82
|
+
session.lastHeartbeatAt = session.lastTouchedAt;
|
|
83
|
+
}
|
|
84
|
+
session.touchCount++;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// =========================================================================
|
|
88
|
+
// 読み取り API(mcp-server.js / wordpress-api.js から呼ばれる)
|
|
89
|
+
// =========================================================================
|
|
90
|
+
|
|
91
|
+
get(sessionId) {
|
|
92
|
+
return this.sessions.get(sessionId) || null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @param {number} postId @param {string} siteUrl — 必須 */
|
|
96
|
+
getByPostId(postId, siteUrl) {
|
|
97
|
+
const normSite = this._normUrl(siteUrl);
|
|
98
|
+
const results = [];
|
|
99
|
+
for (const [sessionId, s] of this.sessions) {
|
|
100
|
+
if (s.postId === Number(postId) && s.siteUrl === normSite) {
|
|
101
|
+
results.push({ sessionId, ...s });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return results;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
getBySiteUrl(siteUrl) {
|
|
108
|
+
const normSite = this._normUrl(siteUrl);
|
|
109
|
+
const results = [];
|
|
110
|
+
for (const [sessionId, s] of this.sessions) {
|
|
111
|
+
if (s.siteUrl === normSite) {
|
|
112
|
+
results.push({ sessionId, ...s });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return results;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** PHP `/status` 互換の connectedEditors[] を返す */
|
|
119
|
+
getConnectedEditors(siteUrl) {
|
|
120
|
+
const normSite = siteUrl ? this._normUrl(siteUrl) : null;
|
|
121
|
+
const results = [];
|
|
122
|
+
for (const [sessionId, s] of this.sessions) {
|
|
123
|
+
if (normSite && s.siteUrl !== normSite) continue;
|
|
124
|
+
results.push({
|
|
125
|
+
sessionId,
|
|
126
|
+
postId: s.postId,
|
|
127
|
+
postTitle: s.postTitle,
|
|
128
|
+
siteUrl: s.siteUrl,
|
|
129
|
+
tabId: s.tabId,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return results;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @param {number} postId @param {string} siteUrl — 必須 */
|
|
136
|
+
isPostEditing(postId, siteUrl) {
|
|
137
|
+
return this.getByPostId(postId, siteUrl).length > 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
hasStructure(sessionId) {
|
|
141
|
+
const session = this.sessions.get(sessionId);
|
|
142
|
+
return session?.stateSynced === true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
hasSelection(sessionId) {
|
|
146
|
+
const session = this.sessions.get(sessionId);
|
|
147
|
+
return session?.selectionSynced === true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
getAll() {
|
|
151
|
+
return Array.from(this.sessions.entries()).map(([sessionId, s]) => ({
|
|
152
|
+
sessionId,
|
|
153
|
+
...s,
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// =========================================================================
|
|
158
|
+
// Internal
|
|
159
|
+
// =========================================================================
|
|
160
|
+
|
|
161
|
+
_normUrl(url) {
|
|
162
|
+
return url?.replace(/\/+$/, '') || '';
|
|
163
|
+
}
|
|
164
|
+
}
|