-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.js
More file actions
1 lines (1 loc) · 35.6 KB
/
Copy pathindex.js
File metadata and controls
1 lines (1 loc) · 35.6 KB
1
const{Plugin:Plugin,Dialog:Dialog}=require("siyuan"),RSA_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8xxorUVtrJzLR+CBfOOh\nnpjVnJwJ7EtYiqGESZOLYoSD/GT7y+cq/K3JdcmZZUYuYosbSpyebNFJmrtGT0Ca\nT5hiinL5HtdO12U5keTYCN8oB+mLevMOW02+kszFlU0RFzUzkrs46pyxm11zIB8u\niNJt2jxUWPIGgZ8pao3RtkxAXT5GYz8AdkoYRAxqMoVoLCv10GQ/O7qZMWZWJ4+3\nuiitbCtYQPLvQWh0bbe+hBELf3QssyzPu+yk9XwgOSyNN4FwB5g25FkXTVe/2V5K\n93Fp2RDep6QD1OcMbffpZOhZmR0mgjftH7zMpJAEbVvEeTRNVntH7GnQkyY5htcE\nMQIDAQAB\n-----END PUBLIC KEY-----";async function hmacSha256(e,t){const i=new TextEncoder,s=i.encode(t),a=i.encode(e),n=await crypto.subtle.importKey("raw",s,{name:"HMAC",hash:"SHA-256"},!1,["sign"]),c=await crypto.subtle.sign("HMAC",n,a);return Array.from(new Uint8Array(c)).map((e=>e.toString(16).padStart(2,"0"))).join("")}class LicenseManager{constructor(e){this.plugin=e,this.isPaid=!1,this.boundUserId=null,this.expireTimestamp=0,this.trialUsed=0,this.maxTrial=10,this.trialCheckKey="siyuan-duanduan-trial-salt-2025"}getLicensePaths(){return["data/.siyuan/.userkey","data/storage/.v"]}getTrialPaths(){return["data/.siyuan/.trial","data/storage/.counter"]}async writeFileWithComment(e,t){const i=JSON.stringify(t),s=`# SiYuan internal cache\n# Generated at ${Date.now()}\n${i}`,a=new Blob([s],{type:"application/octet-stream"});await this.plugin.writeLocalFile(e,a)}async readFileWithComment(e){try{const t=await this.plugin.getLocalFileBlob(e);if(!t)return null;const i=await t.text(),s=i.indexOf("{");if(-1===s)return null;const a=i.substring(s);return JSON.parse(a)}catch(e){return null}}async deleteFileIfExists(e){try{await this.plugin.deleteFile(e)}catch(e){}}encodeNumber(e){return btoa(e.toString())}decodeNumber(e){return parseInt(atob(e),10)}async loadTrial(){const e=this.getTrialPaths();let t=!1;for(const i of e){const e=await this.readFileWithComment(i);if(e&&e.encodedUsed&&e.check){const i=await hmacSha256(e.encodedUsed,this.trialCheckKey);if(e.check===i)try{const i=this.decodeNumber(e.encodedUsed);this.trialUsed=i,t=!0;break}catch(e){}}}t||(this.trialUsed=0),await this.saveTrial()}async saveTrial(){const e=this.getTrialPaths(),t=this.encodeNumber(this.trialUsed),i={encodedUsed:t,check:await hmacSha256(t,this.trialCheckKey)};for(const t of e)await this.writeFileWithComment(t,i)}async loadLicense(){const e=this.getLicensePaths();let t=null;for(const i of e){const e=await this.readFileWithComment(i);if(e&&e.userId&&void 0!==e.expireTimestamp&&e.check){const i=await hmacSha256(`${e.userId}|${e.expireTimestamp}`,this.trialCheckKey);if(e.check===i&&(!e.expireTimestamp||Date.now()<=e.expireTimestamp)){t=e;break}}}t?(this.isPaid=!0,this.boundUserId=t.userId,this.expireTimestamp=t.expireTimestamp):(this.isPaid=!1,this.boundUserId=null,this.expireTimestamp=0),await this.saveLicense()}async saveLicense(){if(!this.isPaid){const e=this.getLicensePaths();for(const t of e)await this.deleteFileIfExists(t);return}const e=await hmacSha256(`${this.boundUserId}|${this.expireTimestamp}`,this.trialCheckKey),t={userId:this.boundUserId,expireTimestamp:this.expireTimestamp,check:e},i=this.getLicensePaths();for(const e of i)await this.writeFileWithComment(e,t)}async load(){return await this.loadLicense(),await this.loadTrial(),this.isPaid}async save(){await this.saveLicense(),await this.saveTrial()}async clear(){this.isPaid=!1,this.boundUserId=null,this.expireTimestamp=0,await this.save()}getCurrentUserId(){return window.siyuan?.user?.userId||null}getRemainingTrials(){return this.isPaid?1/0:Math.max(0,this.maxTrial-this.trialUsed)}canSync(){return!!this.isPaid||this.trialUsed<this.maxTrial}async recordSync(){this.isPaid||(this.trialUsed++,await this.saveTrial())}async verifyRSA(e,t){const i=new TextEncoder,s=await crypto.subtle.importKey("spki",this.pemToBinary(RSA_PUBLIC_KEY),{name:"RSASSA-PKCS1-v1_5",hash:"SHA-256"},!1,["verify"]),a=Uint8Array.from(atob(t),(e=>e.charCodeAt(0)));return await crypto.subtle.verify("RSASSA-PKCS1-v1_5",s,a,i.encode(e))}pemToBinary(e){const t=e.replace(/-----BEGIN PUBLIC KEY-----/,"").replace(/-----END PUBLIC KEY-----/,"").replace(/\s/g,""),i=atob(t),s=i.length,a=new Uint8Array(s);for(let e=0;e<s;e++)a[e]=i.charCodeAt(e);return a.buffer}async activate(e){const t=this.getCurrentUserId();if(!t)return{success:!1,message:this.plugin.t("notLoggedIn")};let i;try{i=atob(e)}catch(e){return{success:!1,message:this.plugin.t("invalidLicenseFormat")}}const s=i.split("|");if(3!==s.length)return{success:!1,message:this.plugin.t("invalidLicenseFormat")};const[a,n,c]=s,o=a,l=parseInt(n);if(o!==t)return{success:!1,message:this.plugin.t("licenseNotMatchUser")};if(l&&Date.now()>l)return{success:!1,message:this.plugin.t("licenseExpired")};const r=`${o}|${l}`;return await this.verifyRSA(r,c)?(this.isPaid=!0,this.boundUserId=o,this.expireTimestamp=l,await this.save(),{success:!0,message:this.plugin.t("licenseActivated")}):{success:!1,message:this.plugin.t("invalidLicenseCode")}}isPaidUser(){return this.isPaid}getMaxDevices(){return this.isPaid?1/0:1}}class SiYuanPluginDuanduanSync extends Plugin{constructor(e){super(e),this.devices=[],this.currentDeviceId=null,this.settings={syncAttachments:!0,syncPlugins:!1,syncDirection:"bidirectional",syncEngine:"fs",postSyncAction:"fast",syncDirectories:{assets:!0,plugins:!1,templates:!1,widgets:!1,emojis:!1,storage:!1,public:!1,snippets:!1,dotSiYuan:!1}},this.licenseManager=null,this.currentDialog=null}async deleteFile(e){await this.callLocalAPI("/api/file/removeFile",{path:e})}async onload(){this.licenseManager=new LicenseManager(this),await this.licenseManager.load(),await this.loadPluginData(),this.addTopBar({icon:"iconSettings",title:"端端同步",position:"right",callback:()=>this.openSettingsDialog()})}onunload(){this.currentDialog&&this.currentDialog.destroy()}openSettingsDialog(){this.currentDialog&&(this.currentDialog.destroy(),this.currentDialog=null),this.currentDialog=new Dialog({title:"端端同步设置",content:'<div id="duanduan-settings-container" style="padding: 20px; max-height: 70vh; overflow-y: auto;"></div>',width:"720px",height:"auto",destroyCallback:()=>{this.currentDialog=null}});const e=this.currentDialog.element.querySelector("#duanduan-settings-container");this.renderSettings(e)}async loadPluginData(){this.devices=await this.loadData("devices")||[],this.currentDeviceId=await this.loadData("currentDeviceId")||null;const e=await this.loadData("settings");if(e){this.settings={...this.settings,...e};const t={assets:!0,plugins:!1,templates:!1,widgets:!1,emojis:!1,storage:!1,public:!1,snippets:!1,dotSiYuan:!1};if(this.settings.syncDirectories)for(const e of Object.keys(t))void 0===this.settings.syncDirectories[e]&&(this.settings.syncDirectories[e]=t[e]);else this.settings.syncDirectories=t;await this.saveAllData()}}async saveAllData(){await this.saveData("devices",this.devices),this.currentDeviceId&&await this.saveData("currentDeviceId",this.currentDeviceId),await this.saveData("settings",this.settings)}async renderSettings(e){const t=((await this.callLocalAPI("/api/notebook/lsNotebooks",{}))?.data?.notebooks||[]).length;e.innerHTML=this.buildSettingsHTML(t),this.bindSettingsEvents(e)}buildSettingsHTML(e){const t=this.devices.find((e=>e.id===this.currentDeviceId)),i=this.licenseManager.isPaidUser(),s=this.licenseManager.getCurrentUserId(),a=this.licenseManager.getRemainingTrials(),n=this.licenseManager.getMaxDevices(),c=this.settings.syncDirectories||{};return`\n <div class="lansync-container">\n <div class="lansync-header"><h2>📡 端端同步</h2><p class="subtitle">同局域网笔记同步 · 绑定链滴账号</p></div>\n <div class="lansync-section vip-section">\n <h3>⭐ 会员与试用</h3>\n <div class="vip-status">\n ${s?`<div>当前用户ID:${s}</div>`:'<div class="warning">⚠️ 未登录,可免费试用10次,但无法激活VIP</div>'}\n ${i?`\n <div class="vip-badge active">✅ 已付费无限同步</div>\n <div>设备数量:无限制</div>\n <div>到期时间:${this.licenseManager.expireTimestamp?new Date(this.licenseManager.expireTimestamp).toLocaleDateString():"永久"}</div>\n`:`\n <div class="vip-badge inactive">🔓 免费试用中</div>\n <div>剩余同步次数:${a===1/0?"∞":a} / ${this.licenseManager.maxTrial}</div>\n <div>最多设备:${n===1/0?"无限制":n}</div>\n ${0===a?'<div class="trial-expired-warning">⚠️ 试用次数已用完,请购买付费版</div>':'<div class="trial-hint">💡 10次内体验全部功能</div>'}\n <button id="vip-upgrade" class="btn btn-primary">💰 购买付费版</button>\n <div style="margin-top: 12px; display: flex; gap: 8px; align-items: center;">\n <input type="text" id="vip-license-input" placeholder="输入激活码" style="flex: 1; padding: 6px 10px; border-radius: 4px; border: 1px solid var(--b3-theme-surface-light); background: var(--b3-theme-background);">\n <button id="vip-activate-btn" class="btn btn-secondary">🔑 激活</button>\n </div>\n`}\n </div>\n </div>\n <div class="lansync-section">\n <div class="section-header"><h3>🌐 远端设备</h3><button id="lansync-add-device" class="btn btn-primary">+ 添加设备</button></div>\n <div class="devices-list" id="lansync-devices-list">${this.renderDeviceList()}</div>\n </div>\n <div class="lansync-section">\n <h3>📱 当前连接</h3>\n <div class="current-device-info">\n <select id="lansync-device-select"><option value="">选择设备</option>${this.devices.map((e=>`<option value="${e.id}" ${e.id===this.currentDeviceId?"selected":""}>${this.escapeHtml(e.name)} (${e.ip}:${e.port})</option>`)).join("")}</select>\n <div class="device-actions"><button id="lansync-test" class="btn btn-secondary">🔌 测试连接</button><button id="lansync-sync" class="btn btn-success">🔄 开始同步</button></div>\n <div id="lansync-status" class="status-indicator status-idle">${t?"就绪":"未选择设备"}</div>\n </div>\n </div>\n \x3c!-- 同步目录与方向 --\x3e\n <div class="lansync-section">\n <h3>📂 同步目录与方向</h3>\n <div class="settings-options" style="max-height: 350px; overflow-y: auto;">\n <div class="sync-dir-group">\n <strong>📓 笔记本数据(始终同步)</strong>\n <div class="dir-note">共 ${e} 个笔记本,其数据目录 <code>data/笔记本ID/</code> 将始终被同步。</div>\n </div>\n <div class="sync-dir-group">\n <strong>📎 其他数据目录</strong>\n <label>\n <input type="checkbox" class="sync-dir-checkbox" data-dir="assets" ${c.assets?"checked":""}>\n 📁 附件目录 <code>data/assets</code>(始终可同步)\n </label>\n <label class="${i?"":"vip-disabled"}">\n <input type="checkbox" class="sync-dir-checkbox" data-dir="plugins" ${c.plugins?"checked":""} ${i?"":"disabled"}>\n 📁 插件目录 <code>data/plugins</code>(VIP)\n </label>\n <label class="${i?"":"vip-disabled"}">\n <input type="checkbox" class="sync-dir-checkbox" data-dir="templates" ${c.templates?"checked":""} ${i?"":"disabled"}>\n 📁 自定义模板 <code>data/templates</code>(VIP)\n </label>\n <label class="${i?"":"vip-disabled"}">\n <input type="checkbox" class="sync-dir-checkbox" data-dir="widgets" ${c.widgets?"checked":""} ${i?"":"disabled"}>\n 📁 挂件目录 <code>data/widgets</code>(VIP)\n </label>\n <label class="${i?"":"vip-disabled"}">\n <input type="checkbox" class="sync-dir-checkbox" data-dir="emojis" ${c.emojis?"checked":""} ${i?"":"disabled"}>\n 😀 表情符号目录 <code>data/emojis</code>(VIP)\n </label>\n </div>\n <div class="sync-dir-group">\n <strong>⚠️ 谨慎选择(VIP)</strong>\n <label class="${i?"":"vip-disabled"}">\n <input type="checkbox" class="sync-dir-checkbox" data-dir="storage" ${c.storage?"checked":""} ${i?"":"disabled"}>\n 📁 存储目录 <code>data/storage</code>(插件数据、状态等)\n </label>\n <label class="${i?"":"vip-disabled"}">\n <input type="checkbox" class="sync-dir-checkbox" data-dir="public" ${c.public?"checked":""} ${i?"":"disabled"}>\n 📁 公共目录 <code>data/public</code>\n </label>\n <label class="${i?"":"vip-disabled"}">\n <input type="checkbox" class="sync-dir-checkbox" data-dir="snippets" ${c.snippets?"checked":""} ${i?"":"disabled"}>\n 📁 代码片段 <code>data/snippets</code>\n </label>\n <label class="${i?"":"vip-disabled"}">\n <input type="checkbox" class="sync-dir-checkbox" data-dir="dotSiYuan" ${c.dotSiYuan?"checked":""} ${i?"":"disabled"}>\n ⚙️ 工作空间配置 <code>.siyuan</code>(主题、布局等)\n </label>\n </div>\n </div>\n <div class="sync-direction" style="margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--b3-theme-surface-light);">\n <span>同步方向:</span>\n <label><input type="radio" name="direction" value="bidirectional" ${"bidirectional"===this.settings.syncDirection?"checked":""}> 双向</label>\n <label><input type="radio" name="direction" value="send" ${"send"===this.settings.syncDirection?"checked":""}> 仅发送</label>\n <label><input type="radio" name="direction" value="receive" ${"receive"===this.settings.syncDirection?"checked":""}> 仅接收</label>\n </div>\n </div>\n \x3c!-- 高级设置 --\x3e\n <div class="lansync-section">\n <h3>⚙️ 高级设置</h3>\n <div class="settings-options">\n <div class="sync-direction">\n <span>同步引擎:</span>\n <select id="sync-engine-select">\n <option value="api" ${"api"===this.settings.syncEngine?"selected":""}>标准 API 同步(基于笔记 API)</option>\n <option value="fs" ${"fs"===this.settings.syncEngine?"selected":""}>文件系统同步(基于哈希对比)</option>\n </select>\n </div>\n <div class="engine-note" style="font-size:12px; margin-top:8px;">\n ⚡ 标准 API 同步速度快,但要求笔记本已打开;<br>\n 📁 文件系统同步更稳健(无需打开笔记本),但首次同步较慢。\n </div>\n </div>\n <div class="settings-options" style="margin-top:16px;">\n <div class="sync-direction">\n <span>索引更新模式:</span>\n <label><input type="radio" name="postSyncAction" value="fast" ${"fast"===this.settings.postSyncAction?"checked":""}> 快速模式(仅刷新文件树)</label>\n <label><input type="radio" name="postSyncAction" value="precise" ${"precise"===this.settings.postSyncAction?"checked":""}> 精确模式(重建索引)</label>\n </div>\n <div class="engine-note" style="font-size:12px; margin-top:8px;">\n 💡 快速模式:立即显示文档树变化,但修改的文档内容可能需要手动刷新。<br>\n 🔧 精确模式:同步后自动重建索引(耗时数秒),保证所有笔记内容立即更新。\n </div>\n </div>\n </div>\n <div class="lansync-footer"><button id="lansync-save" class="btn btn-save">💾 保存设置</button><span class="version">v1.0.0</span></div>\n </div>\n <div id="lansync-device-modal" class="modal" style="display:none;"><div class="modal-content"><div class="modal-header"><h3 id="modal-title">添加设备</h3><button class="modal-close">×</button></div><div class="modal-body"><input type="hidden" id="modal-device-id"><div class="form-group"><label>设备名称</label><input type="text" id="modal-device-name" placeholder="工作电脑"></div><div class="form-group"><label>IP地址</label><input type="text" id="modal-device-ip" placeholder="192.168.1.100"></div><div class="form-group"><label>端口</label><input type="number" id="modal-device-port" value="6806"></div><div class="form-group"><label>API Token</label><input type="password" id="modal-device-token" placeholder="从远端思源获取"><small>设置→关于→API Token</small></div></div><div class="modal-footer"><button id="modal-cancel" class="btn btn-secondary">取消</button><button id="modal-save" class="btn btn-primary">保存</button></div></div></div>\n `}renderDeviceList(){return this.devices.length?`<ul class="device-items">${this.devices.map((e=>`<li class="device-item ${e.id===this.currentDeviceId?"active":""}" data-id="${e.id}"><div class="device-info"><span class="device-name">${this.escapeHtml(e.name)}</span><span class="device-addr">${e.ip}:${e.port}</span></div><div class="device-actions-mini"><button class="btn-icon btn-edit" data-id="${e.id}">✏️</button><button class="btn-icon btn-delete" data-id="${e.id}">🗑️</button></div></li>`)).join("")}</ul>`:'<div class="empty-state">🔍 暂无设备,点击添加</div>'}bindSettingsEvents(e){e.querySelector("#lansync-add-device")?.addEventListener("click",(()=>this.openDeviceModal())),e.querySelector("#lansync-device-select")?.addEventListener("change",(t=>{this.currentDeviceId=t.target.value,this.saveAllData(),this.updateStatusDisplay(e)})),e.querySelector("#lansync-test")?.addEventListener("click",(()=>this.testConnection(e))),e.querySelector("#lansync-sync")?.addEventListener("click",(()=>this.startSync(e))),e.querySelector("#lansync-save")?.addEventListener("click",(()=>this.saveSettings(e))),e.querySelector("#vip-upgrade")?.addEventListener("click",(()=>window.open("https://your-payment-link.com","_blank")));const t=e.querySelector("#vip-activate-btn");t&&t.addEventListener("click",(()=>this.activateLicenseFromInput(e)));e.querySelector("#lansync-devices-list")?.addEventListener("click",(t=>{const i=t.target.closest(".btn-edit"),s=t.target.closest(".btn-delete"),a=t.target.closest(".device-item");if(i&&a)this.editDevice(a.dataset.id);else if(s&&a)this.deleteDevice(a.dataset.id,e);else if(a&&!i&&!s){this.currentDeviceId=a.dataset.id,this.saveAllData(),this.updateStatusDisplay(e),this.renderDeviceList();const t=e.querySelector("#lansync-devices-list");t&&(t.innerHTML=this.renderDeviceList())}}));const i=e.querySelector("#lansync-device-modal"),s=()=>i.style.display="none";i?.querySelector(".modal-close")?.addEventListener("click",s),i?.querySelector("#modal-cancel")?.addEventListener("click",s),i?.querySelector("#modal-save")?.addEventListener("click",(()=>this.saveDeviceFromModal(i,e)))}openDeviceModal(e=null){const t=document.querySelector("#lansync-device-modal");if(!t)return;const i=t.querySelector("#modal-title"),s=t.querySelector("#modal-device-id"),a=t.querySelector("#modal-device-name"),n=t.querySelector("#modal-device-ip"),c=t.querySelector("#modal-device-port"),o=t.querySelector("#modal-device-token");if(e){const t=this.devices.find((t=>t.id===e));t&&(i.innerText="编辑设备",s.value=t.id,a.value=t.name,n.value=t.ip,c.value=t.port,o.value=t.token)}else i.innerText="添加设备",s.value="",a.value="",n.value="",c.value="6806",o.value="";t.style.display="flex"}editDevice(e){this.openDeviceModal(e)}async deleteDevice(e,t){if(!confirm("确定删除此设备?"))return;this.devices=this.devices.filter((t=>t.id!==e)),this.currentDeviceId===e&&(this.currentDeviceId=this.devices[0]?.id||null),await this.saveAllData(),t.querySelector("#lansync-devices-list").innerHTML=this.renderDeviceList();const i=t.querySelector("#lansync-device-select");i&&(i.innerHTML=`<option value="">选择设备</option>${this.devices.map((e=>`<option value="${e.id}" ${e.id===this.currentDeviceId?"selected":""}>${e.name} (${e.ip}:${e.port})</option>`)).join("")}`),this.updateStatusDisplay(t)}async saveDeviceFromModal(e,t){const i=e.querySelector("#modal-device-id").value,s=e.querySelector("#modal-device-name").value.trim(),a=e.querySelector("#modal-device-ip").value.trim(),n=parseInt(e.querySelector("#modal-device-port").value),c=e.querySelector("#modal-device-token").value.trim();if(!(s&&a&&n&&c))return void alert("请填写完整信息");const o=this.licenseManager.getMaxDevices();if(!i&&this.devices.length>=o&&o!==1/0)return void alert(`免费版最多${o}台设备,请升级VIP`);if(i){const e=this.devices.find((e=>e.id===i));e&&(e.name=s,e.ip=a,e.port=n,e.token=c)}else{const e=Date.now().toString();this.devices.push({id:e,name:s,ip:a,port:n,token:c}),this.currentDeviceId||(this.currentDeviceId=e)}await this.saveAllData(),e.style.display="none",t.querySelector("#lansync-devices-list").innerHTML=this.renderDeviceList();const l=t.querySelector("#lansync-device-select");l&&(l.innerHTML=`<option value="">选择设备</option>${this.devices.map((e=>`<option value="${e.id}" ${e.id===this.currentDeviceId?"selected":""}>${e.name} (${e.ip}:${e.port})</option>`)).join("")}`),this.updateStatusDisplay(t)}updateStatusDisplay(e){const t=e.querySelector("#lansync-status"),i=this.devices.find((e=>e.id===this.currentDeviceId));i?(t.className="status-indicator status-idle",t.textContent=`就绪 (${i.name})`):(t.className="status-indicator status-inactive",t.textContent="未选择设备")}async saveSettings(e){const t=e.querySelector('input[name="direction"]:checked');t&&(this.settings.syncDirection=t.value);const i=e.querySelector("#sync-engine-select");i&&(this.settings.syncEngine=i.value);const s=e.querySelector('input[name="postSyncAction"]:checked');s&&(this.settings.postSyncAction=s.value);e.querySelectorAll(".sync-dir-checkbox").forEach((e=>{const t=e.getAttribute("data-dir");t&&this.settings.syncDirectories.hasOwnProperty(t)&&(this.settings.syncDirectories[t]=e.checked)})),await this.saveAllData(),alert("设置已保存")}async testConnection(e){const t=this.devices.find((e=>e.id===this.currentDeviceId));if(!t)return void alert("未选择设备");const i=e.querySelector("#lansync-status");i.className="status-indicator status-testing",i.textContent="测试中...";try{const e=await this.callRemoteAPI(t,"/api/system/version",{});if(0!==e?.code)throw new Error(e?.msg||"未知错误");i.className="status-indicator status-connected",i.textContent=`已连接 (v${e.data?.version})`,alert("连接成功")}catch(e){i.className="status-indicator status-error",i.textContent=`连接失败: ${e.message}`,alert("连接失败")}}async callRemoteAPI(e,t,i={}){const s=`http://${e.ip}:${e.port}${t}`;try{const t=await fetch(s,{method:"POST",headers:{Authorization:`Token ${e.token}`,"Content-Type":"application/json"},body:JSON.stringify(i)}),a=await t.text();if(!t.ok)throw new Error(`HTTP ${t.status}: ${a.substring(0,200)}`);if(!a.trim())return{code:0,data:{}};try{return JSON.parse(a)}catch(e){throw new Error(`Non-JSON response: ${a.substring(0,200)}`)}}catch(e){throw e}}async callLocalAPI(e,t={}){const i=await this.getLocalToken(),s=`http://127.0.0.1:6806${e}`;try{const e=await fetch(s,{method:"POST",headers:{Authorization:`Token ${i}`,"Content-Type":"application/json"},body:JSON.stringify(t)}),a=await e.text();if(!a.trim())return{code:0,data:{}};try{return JSON.parse(a)}catch(e){throw new Error(`Invalid JSON: ${a.substring(0,200)}`)}}catch(e){throw e}}async getLocalToken(){try{const e=await fetch("http://127.0.0.1:6806/api/system/getToken",{method:"POST"});return(await e.json())?.data?.token||""}catch{return""}}async startSync(e){const t=this.devices.find((e=>e.id===this.currentDeviceId));if(!t)return void alert("未选择设备");if(!this.licenseManager.canSync())return void alert("试用次数已用完,请购买付费版");const i=e.querySelector("#lansync-sync"),s=e.querySelector("#lansync-status");i.disabled=!0,i.textContent="同步中...",s.className="status-indicator status-syncing",s.textContent="同步中...";try{if("fs"===this.settings.syncEngine)await this.syncWithFileSystem(t),s.textContent="同步完成 (文件系统)",alert("文件系统同步完成");else{const e=await this.packageLocalData();let i=null;"send"!==this.settings.syncDirection&&(i=await this.fetchRemoteData(t));const a=await this.performSync(t,e,i);s.className="status-indicator status-connected",s.textContent=a.message,alert(a.details)}await this.licenseManager.recordSync()}catch(e){s.className="status-indicator status-error",s.textContent=`同步失败: ${e.message}`,alert("同步失败: "+e.message)}finally{i.disabled=!1,i.textContent="开始同步"}}async packageLocalData(){const e=((await this.callLocalAPI("/api/notebook/lsNotebooks",{}))?.data?.notebooks||[]).filter((e=>!e.closed));let t=[];for(const i of e)try{const e=await this.callLocalAPI("/api/filetree/listDocs",{notebook:i.id,path:"/"}),s=e?.data?.files?this.flattenDocsTree(e.data.files,i.id,i.name):[];t.push(...s)}catch(e){}const i={};for(const e of t){const t=await this.callLocalAPI("/api/block/getBlockKramdown",{id:e.id});0===t?.code&&(i[e.id]={id:e.id,title:e.title,hPath:e.hPath,content:t.data.kramdown,updated:e.updated,notebookId:e.notebookId})}let s=[];return this.settings.syncAttachments&&(s=await this.collectAssets()),{timestamp:Date.now(),notes:i,assets:s}}flattenDocsTree(e,t,i,s=""){let a=[];for(const n of e)a.push({id:n.id,title:n.title||n.name,hPath:s+"/"+(n.title||n.name),updated:n.updated,type:n.isDir?"folder":"document",notebookId:t,notebookName:i}),n.children&&a.push(...this.flattenDocsTree(n.children,t,i,s+"/"+n.title));return a}async collectAssets(){try{const e=await this.callLocalAPI("/api/asset/listAssets",{});if(0!==e?.code)return[];const t=[];for(const i of e.data.assets){const e=await fetch("http://127.0.0.1:6806/api/asset/getAssetContent",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({path:i.path})}),s=await e.arrayBuffer();t.push({path:i.path,name:i.name,size:i.size,updated:i.updated,content:this.arrayBufferToBase64(s)})}return t}catch{return[]}}async fetchRemoteData(e){return await this.fetchRemoteDataViaStandardAPI(e)}async fetchRemoteDataViaStandardAPI(e){const t=((await this.callRemoteAPI(e,"/api/notebook/lsNotebooks",{}))?.data?.notebooks||[]).filter((e=>!e.closed));let i=[];for(const s of t)try{const t=await this.callRemoteAPI(e,"/api/filetree/listDocs",{notebook:s.id,path:"/"}),a=t?.data?.files?this.flattenDocsTree(t.data.files,s.id,s.name):[];i.push(...a)}catch(e){}const s={};for(const t of i){const i=await this.callRemoteAPI(e,"/api/block/getBlockKramdown",{id:t.id});0===i?.code&&(s[t.id]={id:t.id,title:t.title,content:i.data.kramdown,updated:t.updated})}return{timestamp:Date.now(),notes:s,assets:[]}}async performSync(e,t,i){let s=[],a=0,n=0,c=0;if("send"===this.settings.syncDirection){for(const i of Object.values(t.notes))await this.sendNoteToRemote(e,i)&&a++;for(const i of t.assets)await this.sendAssetToRemote(e,i)&&c++;s.push(`发送 ${a} 篇笔记, ${c} 个附件`)}else if("receive"===this.settings.syncDirection){if(i?.notes)for(const e of Object.values(i.notes))await this.writeNoteToLocal(e)&&a++;if(i?.assets)for(const e of i.assets)await this.writeAssetToLocal(e)&&c++;s.push(`接收 ${a} 篇笔记, ${c} 个附件`)}else{const a=i?.notes||{},o=t.notes;for(const[e,t]of Object.entries(a)){const i=o[e];(!i||t.updated>i.updated)&&(await this.writeNoteToLocal(t),n++)}for(const[t,i]of Object.entries(o)){const s=a[t];(!s||i.updated>s.updated)&&(await this.sendNoteToRemote(e,i),n++)}const l=i?.assets||[],r=t.assets;for(const e of l){const t=r.find((t=>t.path===e.path));(!t||e.updated>t.updated)&&(await this.writeAssetToLocal(e),c++)}for(const t of r){const i=l.find((e=>e.path===t.path));(!i||t.updated>i.updated)&&(await this.sendAssetToRemote(e,t),c++)}s.push(`同步 ${n} 篇笔记, ${c} 个附件`)}return{success:!0,message:"同步完成",details:s.join("\n")}}async sendNoteToRemote(e,t){try{return 0===(await this.callRemoteAPI(e,"/api/filetree/createDocWithMd",{notebook:t.notebookId,path:t.hPath||`/${t.title}`,markdown:t.content||""}))?.code}catch{return!1}}async sendAssetToRemote(e,t){try{return 0===(await this.callRemoteAPI(e,"/api/asset/uploadAsset",{path:t.path,content:t.content,name:t.name}))?.code}catch{return!1}}async writeNoteToLocal(e){try{return 0===(await this.callLocalAPI("/api/filetree/createDocWithMd",{notebook:e.notebookId,path:e.hPath||`/${e.title}`,markdown:e.content||""}))?.code}catch{return!1}}async writeAssetToLocal(e){try{const t=atob(e.content),i=t.length,s=new Uint8Array(i);for(let e=0;e<i;e++)s[e]=t.charCodeAt(e);const a=new FormData;a.append("file",new Blob([s]),e.name);const n=await fetch("http://127.0.0.1:6806/api/asset/upload",{method:"POST",headers:{Authorization:`Token ${await this.getLocalToken()}`},body:a});return 0===(await n.json())?.code}catch{return!1}}async computeFileHash(e){const t=await e.arrayBuffer(),i=await crypto.subtle.digest("SHA-256",t);return Array.from(new Uint8Array(i)).map((e=>e.toString(16).padStart(2,"0"))).join("")}async getLocalFileBlob(e){const t=await this.getLocalToken(),i=await fetch("http://127.0.0.1:6806/api/file/getFile",{method:"POST",headers:{Authorization:`Token ${t}`,"Content-Type":"application/json"},body:JSON.stringify({path:e})});return i.ok?await i.blob():null}async getRemoteFileBlob(e,t){const i=`http://${e.ip}:${e.port}/api/file/getFile`,s=await fetch(i,{method:"POST",headers:{Authorization:`Token ${e.token}`,"Content-Type":"application/json"},body:JSON.stringify({path:t})});return s.ok?await s.blob():null}async uploadFileToRemote(e,t,i){const s=new FormData;s.append("path",t),s.append("file",i);const a=`http://${e.ip}:${e.port}/api/file/putFile`;return(await fetch(a,{method:"POST",headers:{Authorization:`Token ${e.token}`},body:s})).ok}async writeLocalFile(e,t){const i=new FormData;i.append("path",e),i.append("file",t);const s=await this.getLocalToken();return(await fetch("http://127.0.0.1:6806/api/file/putFile",{method:"POST",headers:{Authorization:`Token ${s}`},body:i})).ok}async scanLocalDirectory(e){const t={path:e,isDir:!0,files:[]},i=await this.callLocalAPI("/api/file/readDir",{path:e});if(0!==i.code)return t;for(const s of i.data){const i=`${e}/${s.name}`;if(s.isDir){const e=await this.scanLocalDirectory(i);t.files.push(e)}else t.files.push({path:i,name:s.name,isDir:!1,size:s.size,updated:s.updated,hash:null})}return t}async scanRemoteDirectory(e,t){const i={path:t,isDir:!0,files:[]},s=await this.callRemoteAPI(e,"/api/file/readDir",{path:t});if(0!==s.code)return i;for(const a of s.data){const s=`${t}/${a.name}`;if(a.isDir){const t=await this.scanRemoteDirectory(e,s);i.files.push(t)}else i.files.push({path:s,name:a.name,isDir:!1,size:a.size,updated:a.updated,hash:null})}return i}async compareFileTrees(e,t,i){const s=[],a=new Map,n=new Map,c=(e,t)=>{if(e.isDir)for(const i of e.files)c(i,t);else t.set(e.path,e)};c(e,a),c(t,n);for(const[e,t]of a)n.has(e)||s.push({type:"upload",path:e,file:t});for(const[e,t]of n)a.has(e)||s.push({type:"download",path:e,file:t});const o=[];for(const[e,t]of a){const i=n.get(e);!i||t.size===i.size&&t.updated===i.updated||o.push({path:e,localFile:t,remoteFile:i})}if(o.length>0){const e=5;for(let t=0;t<o.length;t+=e){const a=o.slice(t,t+e),n=await Promise.all(a.map((async({path:e,localFile:t,remoteFile:s})=>{let a=await this.getCachedHash(e,t.updated);if(!a){const i=await this.getLocalFileBlob(e);i?(a=await this.computeFileHash(i),await this.updateHashCache(e,t.updated,a)):a=""}let n=await this.getCachedHash(e,s.updated);if(!n){const t=await this.getRemoteFileBlob(i,e);t?(n=await this.computeFileHash(t),await this.updateHashCache(e,s.updated,n)):n=""}if(a!==n){const i=t.updated>s.updated?"upload":"download";return{type:i,path:e,file:"upload"===i?t:s}}return null})));s.push(...n.filter((e=>null!==e)))}}return s}async syncWithFileSystem(e){const t=Date.now(),i=((await this.callLocalAPI("/api/notebook/lsNotebooks",{}))?.data?.notebooks||[]).map((e=>`data/${e.id}`)),s=this.settings.syncDirectories||{},a={assets:"data/assets",plugins:"data/plugins",templates:"data/templates",widgets:"data/widgets",emojis:"data/emojis",storage:"data/storage",public:"data/public",snippets:"data/snippets",dotSiYuan:".siyuan"},n=[];for(const[e,t]of Object.entries(s))t&&a[e]&&n.push(a[e]);const c=[...i,...n];let o=0,l=0,r=0,d=0;for(const t of c){const i=await this.pathExistsLocal(t),s=await this.pathExistsRemote(e,t);if(!i&&!s)continue;const a=i?await this.scanLocalDirectory(t):null,n=s?await this.scanRemoteDirectory(e,t):null;if(a&&!n){await this.uploadDirectoryRecursive(e,a);o+=this.countFilesInTree(a)}else if(!a&&n){await this.downloadDirectoryRecursive(e,n);l+=this.countFilesInTree(n)}else if(a&&n){const t=await this.compareFileTrees(a,n,e);for(const i of t)if("upload"===i.type){const t=await this.getLocalFileBlob(i.path);t&&(await this.uploadFileToRemote(e,i.path,t),o++,r+=t.size)}else if("download"===i.type){const t=await this.getRemoteFileBlob(e,i.path);t&&(await this.writeLocalFile(i.path,t),l++,d+=t.size)}}}((Date.now()-t)/1e3).toFixed(1);if("fast"===this.settings.postSyncAction){try{await this.callLocalAPI("/api/ui/reloadFiletree",{})}catch(e){}try{await this.callRemoteAPI(e,"/api/ui/reloadFiletree",{})}catch(e){}}else{try{await this.callLocalAPI("/api/notebook/rebuildIndex",{})}catch(e){}try{await this.callRemoteAPI(e,"/api/ui/reloadFiletree",{})}catch(e){}}}async pathExistsLocal(e){const t=e.substring(0,e.lastIndexOf("/"))||"/",i=e.substring(e.lastIndexOf("/")+1),s=await this.callLocalAPI("/api/file/readDir",{path:t});return 0===s.code&&s.data.some((e=>e.name===i))}async pathExistsRemote(e,t){const i=t.substring(0,t.lastIndexOf("/"))||"/",s=t.substring(t.lastIndexOf("/")+1),a=await this.callRemoteAPI(e,"/api/file/readDir",{path:i});return 0===a.code&&a.data.some((e=>e.name===s))}countFilesInTree(e){return e.isDir?e.files.reduce(((e,t)=>e+this.countFilesInTree(t)),0):1}async uploadDirectoryRecursive(e,t){for(const i of t.files)if(i.isDir)await this.uploadDirectoryRecursive(e,i);else{const t=await this.getLocalFileBlob(i.path);t&&await this.uploadFileToRemote(e,i.path,t)}}async downloadDirectoryRecursive(e,t){for(const i of t.files)if(i.isDir)await this.downloadDirectoryRecursive(e,i);else{const t=await this.getRemoteFileBlob(e,i.path);t&&await this.writeLocalFile(i.path,t)}}async getHashCache(){let e=await this.loadData("file_hash_cache");return e||(e={}),e}async saveHashCache(e){await this.saveData("file_hash_cache",e)}async getCachedHash(e,t){const i=(await this.getHashCache())[e];return i&&i.lastModified===t?i.hash:null}async updateHashCache(e,t,i){const s=await this.getHashCache();s[e]={lastModified:t,hash:i};const a=Object.keys(s);if(a.length>2e3){a.slice(0,a.length-1e3).forEach((e=>delete s[e]))}await this.saveHashCache(s)}async activateLicense(e){const t=await this.licenseManager.activate(e);if(alert(t.message),t.success&&this.currentDialog){const e=this.currentDialog.element.querySelector("#duanduan-settings-container");e&&this.renderSettings(e)}}updateRemainingTrialsDisplay(e){const t=e.querySelector("#trial-remaining");t&&(t.textContent=Math.max(0,this.licenseManager.getRemainingTrials()))}escapeHtml(e){return e?e.replace(/[&<>]/g,(e=>({"&":"&","<":"<",">":">"}[e]))):""}arrayBufferToBase64(e){let t="";const i=new Uint8Array(e);for(let e=0;e<i.byteLength;e++)t+=String.fromCharCode(i[e]);return btoa(t)}t(e){return{notLoggedIn:"请先登录链滴账号",invalidLicenseFormat:"激活码格式错误",licenseNotMatchUser:"激活码与当前用户不匹配",licenseExpired:"激活码已过期",invalidLicenseCode:"激活码无效",licenseActivated:"激活成功!",trialExhausted:"试用次数已用完",vipLimitDevices:"免费版最多{max}台设备"}[e]||e}async activateLicenseFromInput(e){const t=e.querySelector("#vip-license-input")?.value.trim();if(!t)return void alert("请输入激活码");const i=await this.licenseManager.activate(t);if(i.success){if(alert(i.message),this.currentDialog){const e=this.currentDialog.element.querySelector("#duanduan-settings-container");e&&this.renderSettings(e)}}else alert(i.message)}}module.exports={default:SiYuanPluginDuanduanSync};