From 4555ec275fc577a85470ca4418a4f0224df98cec Mon Sep 17 00:00:00 2001 From: MassiveBox Date: Sat, 5 Apr 2025 19:30:31 +0200 Subject: [PATCH] Fix sync inconsistencies across devices Changed APIs to upload assets, reworked saving logic so that files are synced across devices when changed locally. --- src/editorTab.ts | 63 ++++++++++++++++++++++++++++++++---------------- src/file.ts | 17 ++++++++++++- src/helper.ts | 52 ++++++++++++++++++++++++--------------- src/index.ts | 22 ++++++++--------- src/protyle.ts | 10 +++----- 5 files changed, 104 insertions(+), 60 deletions(-) diff --git a/src/editorTab.ts b/src/editorTab.ts index d2f89fb..8ebc0a0 100644 --- a/src/editorTab.ts +++ b/src/editorTab.ts @@ -2,37 +2,51 @@ import {Dialog, getFrontend, ITabModel, openTab, Plugin} from "siyuan" import Editor, {BaseWidget, EditorEventType} from "js-draw"; import { MaterialIconProvider } from '@js-draw/material-icons'; import 'js-draw/styles'; -import {getFile, saveFile} from "@/file"; +import {getFile, saveFile, uploadAsset} from "@/file"; import {DATA_PATH, JSON_MIME, SVG_MIME, TOOLBAR_PATH} from "@/const"; -import {replaceAntiCacheID} from "@/protyle"; -import {idToPath} from "@/helper"; +import {replaceSyncID} from "@/protyle"; +import {IDsToAssetPath} from "@/helper"; +import {removeFile} from "@/api"; -export function openEditorTab(p: Plugin, path: string) { +export function openEditorTab(p: Plugin, fileID: string, initialSyncID: string) { if(getFrontend() == "mobile") { const dialog = new Dialog({ width: "100vw", height: "100vh", content: `
`, }); - createEditor(dialog.element.querySelector("#DrawingPanel"), path); + createEditor(dialog.element.querySelector("#DrawingPanel"), fileID, initialSyncID); return; } + for(const tab of p.getOpenedTab()["whiteboard"]) { + if(tab.data.fileID == fileID) { + alert("File is already open in another editor tab!"); + return; + } + } openTab({ app: p.app, custom: { title: 'Drawing', icon: 'iconDraw', id: "siyuan-jsdraw-pluginwhiteboard", - data: { path: path } + data: { + fileID: fileID, + initialSyncID: initialSyncID + } } }); } -async function saveCallback(editor: Editor, path: string, saveButton: BaseWidget) { +async function saveCallback(editor: Editor, fileID: string, oldSyncID: string, saveButton: BaseWidget): Promise { + const svgElem = editor.toSVG(); + let newSyncID; + try { - saveFile(DATA_PATH + path, SVG_MIME, svgElem.outerHTML); - await replaceAntiCacheID(path); + newSyncID = (await uploadAsset(fileID, SVG_MIME, svgElem.outerHTML)).syncID; + await replaceSyncID(fileID, oldSyncID, newSyncID); + await removeFile(DATA_PATH + IDsToAssetPath(fileID, oldSyncID)); saveButton.setDisabled(true); setTimeout(() => { // @todo improve save button feedback saveButton.setDisabled(false); @@ -41,11 +55,14 @@ async function saveCallback(editor: Editor, path: string, saveButton: BaseWidget alert("Error saving drawing! Enter developer mode to find the error, and a copy of the current status."); console.error(error); console.log("Couldn't save SVG: ", svgElem.outerHTML) + return oldSyncID; } + return newSyncID + } -export function createEditor(element: HTMLElement, path: string) { +export function createEditor(element: HTMLElement, fileID: string, initialSyncID: string) { const editor = new Editor(element, { iconProvider: new MaterialIconProvider(), @@ -60,14 +77,21 @@ export function createEditor(element: HTMLElement, path: string) { } }); // restore drawing - getFile(DATA_PATH + path).then(svg => { + getFile(DATA_PATH +IDsToAssetPath(fileID, initialSyncID)).then(svg => { if(svg != null) { editor.loadFromSVG(svg); } }); + let syncID = initialSyncID; // save logic - const saveButton = toolbar.addSaveButton(() => saveCallback(editor, path, saveButton)); + const saveButton = toolbar.addSaveButton(() => { + saveCallback(editor, fileID, syncID, saveButton).then( + newSyncID => { + syncID = newSyncID + } + ) + }); // save toolbar config on tool change (toolbar state is not saved in SVGs!) editor.notifier.on(EditorEventType.ToolUpdated, () => { @@ -81,15 +105,12 @@ export function createEditor(element: HTMLElement, path: string) { export function editorTabInit(tab: ITabModel) { - let path = tab.data.path; - if(path == null) { - const fileID = tab.data.id; // legacy compatibility - if (fileID == null) { - alert("File ID and path missing - couldn't open file.") - return; - } - path = idToPath(fileID); + const fileID = tab.data.fileID; + const initialSyncID = tab.data.initialSyncID; + if (fileID == null || initialSyncID == null) { + alert("File or Sync ID and path missing - couldn't open file.") + return; } - createEditor(tab.element, path); + createEditor(tab.element, fileID, initialSyncID); } \ No newline at end of file diff --git a/src/file.ts b/src/file.ts index 5ce0d20..79e0e48 100644 --- a/src/file.ts +++ b/src/file.ts @@ -1,10 +1,25 @@ -import {getFileBlob, putFile} from "@/api"; +import {getFileBlob, putFile, upload} from "@/api"; +import {ASSETS_PATH} from "@/const"; +import {assetPathToIDs} from "@/helper"; function toFile(title: string, content: string, mimeType: string){ const blob = new Blob([content], { type: mimeType }); return new File([blob], title, { type: mimeType }); } +// upload asset to the assets folder, return fileID and syncID +export async function uploadAsset(fileID: string, mimeType: string, content: string) { + + const file = toFile(fileID + ".svg", content, mimeType); + + let r = await upload('/' + ASSETS_PATH, [file]); + if(r.errFiles) { + throw new Error("Failed to upload file"); + } + return assetPathToIDs(r.succMap[file.name]); + +} + export function saveFile(path: string, mimeType: string, content: string) { const file = toFile(path.split('/').pop(), content, mimeType); diff --git a/src/helper.ts b/src/helper.ts index d956158..ea4524d 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -1,5 +1,5 @@ import { Plugin } from 'siyuan'; -import {DATA_PATH, EMBED_PATH} from "@/const"; +import {ASSETS_PATH} from "@/const"; const drawIcon: string = ` @@ -23,7 +23,7 @@ export function getMenuHTML(icon: string, text: string): string { `; } -export function generateSiyuanId() { +export function generateTimeString() { const now = new Date(); const year = now.getFullYear().toString(); @@ -33,26 +33,45 @@ export function generateSiyuanId() { const minutes = now.getMinutes().toString().padStart(2, '0'); const seconds = now.getSeconds().toString().padStart(2, '0'); - const timestamp = `${year}${month}${day}${hours}${minutes}${seconds}`; + return `${year}${month}${day}${hours}${minutes}${seconds}`; +} + +export function generateRandomString() { const characters = 'abcdefghijklmnopqrstuvwxyz'; let random = ''; for (let i = 0; i < 7; i++) { random += characters.charAt(Math.floor(Math.random() * characters.length)); } + return random; - return `${timestamp}-${random}`; } -export function idToPath(id: string) { - return DATA_PATH + id + '.svg'; +export function IDsToAssetPath(fileID: string, syncID: string) { + return `${ASSETS_PATH}${fileID}-${syncID}.svg` +} +export function assetPathToIDs(assetPath: string): { fileID: string; syncID: string } | null { + + const filename = assetPath.split('/').pop() || ''; + if (!filename.endsWith('.svg')) return null; + + // Split into [basename, extension] and check format + const [basename] = filename.split('.'); + const parts = basename.split('-'); + + // Must contain exactly 2 hyphens separating 3 non-empty parts + if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) return null; + + return { + fileID: parts[0], + syncID: parts[1] + '-' + parts[2] + }; + } -// [Edit](siyuan://plugins/siyuan-jsdraw-pluginwhiteboard/?icon=iconDraw&title=Drawing&data={"id":"${id}"}) -// ![Drawing](assets/${id}.svg) -export function getPreviewHTML(path: string): string { +export function getMarkdownBlock(fileID: string, syncID: string): string { return ` - + ![Drawing](${IDsToAssetPath(fileID, syncID)}) ` } @@ -74,20 +93,13 @@ export function findImgSrc(element: HTMLElement): string | null { return null; } -export function imgSrcToPath(imgSrc: string | null): string | null { +export function imgSrcToIDs(imgSrc: string | null): { fileID: string; syncID: string } | null { + if (!imgSrc) return null; const url = new URL(imgSrc); imgSrc = decodeURIComponent(url.pathname); - if(imgSrc.startsWith('/assets/')) { - return imgSrc.substring(1); - } - return null + return assetPathToIDs(imgSrc); -} - -// Helper to safely escape regex special characters -export function escapeRegExp(string: string) { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index a3d8d43..78f1768 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,15 @@ import {Plugin, Protyle} from 'siyuan'; import { - getPreviewHTML, + getMarkdownBlock, loadIcons, getMenuHTML, - generateSiyuanId, findImgSrc, - imgSrcToPath + imgSrcToIDs, generateTimeString, generateRandomString } from "@/helper"; import {editorTabInit, openEditorTab} from "@/editorTab"; -import {ASSETS_PATH} from "@/const"; export default class DrawJSPlugin extends Plugin { + onload() { loadIcons(this); @@ -24,22 +23,21 @@ export default class DrawJSPlugin extends Plugin { filter: ["Insert Drawing", "Add drawing", "whiteboard", "freehand", "graphics", "jsdraw"], html: getMenuHTML("iconDraw", this.i18n.insertDrawing), callback: (protyle: Protyle) => { - const path = ASSETS_PATH + generateSiyuanId() + ".svg"; - protyle.insert(getPreviewHTML(path), true, false); - openEditorTab(this, path); + const fileID = generateRandomString(); + const syncID = generateTimeString() + '-' + generateRandomString(); + protyle.insert(getMarkdownBlock(fileID, syncID), true, false); + openEditorTab(this, fileID, syncID); } }]; this.eventBus.on("open-menu-image", (e: any) => { - const path = imgSrcToPath(findImgSrc(e.detail.element)); - if(path === null) { - return; - } + const ids = imgSrcToIDs(findImgSrc(e.detail.element)); + if(ids === null) return; e.detail.menu.addItem({ icon: "iconDraw", label: "Edit with js-draw", click: () => { - openEditorTab(this, path); + openEditorTab(this, ids.fileID, ids.syncID); } }) }) diff --git a/src/protyle.ts b/src/protyle.ts index cda24f9..292221c 100644 --- a/src/protyle.ts +++ b/src/protyle.ts @@ -1,5 +1,5 @@ import {getBlockByID, sql, updateBlock} from "@/api"; -import {DUMMY_HOST} from "@/const"; +import {IDsToAssetPath} from "@/helper"; export async function findImageBlocks(src: string) { @@ -45,9 +45,9 @@ export async function replaceBlockContent( } } -export async function replaceAntiCacheID(src: string) { +export async function replaceSyncID(fileID: string, oldSyncID: string, newSyncID: string) { - const search = encodeURI(src); // the API uses URI-encoded + const search = encodeURI(IDsToAssetPath(fileID, oldSyncID)); // the API uses URI-encoded // find blocks containing that image const blocks = await findImageBlocks(search); @@ -61,9 +61,7 @@ export async function replaceAntiCacheID(src: string) { .filter(source => source.startsWith(search)) // discard other images for(const source of sources) { - const url = new URL(source, DUMMY_HOST); - url.searchParams.set('antiCache', Date.now().toString()); // set or replace antiCache - const newSource = url.href.replace(DUMMY_HOST, ''); + const newSource = IDsToAssetPath(fileID, newSyncID); await replaceBlockContent(block.id, source, newSource); } }