diff --git a/scripts/user-notes-backup.js b/scripts/user-notes-backup.js new file mode 100644 index 0000000..b4b8e5f --- /dev/null +++ b/scripts/user-notes-backup.js @@ -0,0 +1,474 @@ +import { + USER_NOTES_MODULE_ID +} from "./user-notes-constants.js"; + +import { + userNotesDecodeBase64, + userNotesEncodeBase64, + userNotesLoadAppearance, + userNotesLoadNotes, + userNotesLoadPosition, + userNotesSaveAppearance, + userNotesSaveNotes, + userNotesSavePositionData +} from "./user-notes-storage.js"; + +import { + userNotesEscapePlainTextAsHtml, + userNotesSanitizeHtml +} from "./user-notes-sanitize.js"; + +const USER_NOTES_EXPORT_SCHEMA_VERSION = 2; + +const USER_NOTES_APPEARANCE_DEFAULTS_FOR_BACKUP = { + windowBackgroundColor: "#191813", + windowBackgroundAlpha: 0.96, + windowTextColor: "#f0f0e0", + textareaBackgroundColor: "#ffffff", + textareaBackgroundAlpha: 0.92, + textareaTextColor: "#111111" +}; + +function userNotesDownloadJson(filename, data) { + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + + const link = document.createElement("a"); + link.href = url; + link.download = filename; + + document.body.appendChild(link); + link.click(); + link.remove(); + + URL.revokeObjectURL(url); +} + +function userNotesReadJsonFile(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = () => { + try { + resolve(JSON.parse(String(reader.result ?? ""))); + } catch (err) { + reject(err); + } + }; + + reader.onerror = () => reject(reader.error); + reader.readAsText(file); + }); +} + +function userNotesBuildExportData(options) { + const data = {}; + + if (options.includeNotes) { + data.notes = { + format: "html", + encoding: "base64", + data: userNotesEncodeBase64( + userNotesSanitizeHtml(userNotesLoadNotes()) + ) + }; + } + + if (options.includeAppearance) { + data.appearance = userNotesLoadAppearance( + USER_NOTES_APPEARANCE_DEFAULTS_FOR_BACKUP + ); + } + + if (options.includePosition) { + const position = userNotesLoadPosition(null); + + if (position) { + data.position = position; + } + } + + return { + module: USER_NOTES_MODULE_ID, + schemaVersion: USER_NOTES_EXPORT_SCHEMA_VERSION, + exportedAt: new Date().toISOString(), + data + }; +} + +function userNotesNormalizeImportedNotes(notes) { + if (typeof notes === "string") { + return userNotesEscapePlainTextAsHtml(notes); + } + + if (!notes || typeof notes !== "object") { + return ""; + } + + if (notes.encoding === "base64") { + return userNotesSanitizeHtml( + userNotesDecodeBase64(notes.data ?? notes.content ?? "") + ); + } + + if (notes.format === "html") { + return userNotesSanitizeHtml(notes.content ?? notes.data ?? ""); + } + + if (notes.format === "text") { + return userNotesEscapePlainTextAsHtml(notes.content ?? notes.data ?? ""); + } + + return userNotesSanitizeHtml(notes.content ?? notes.data ?? ""); +} + +function userNotesValidateImportData(importData) { + if (!importData || typeof importData !== "object") { + return false; + } + + if (importData.module !== USER_NOTES_MODULE_ID) { + return false; + } + + if (!importData.data || typeof importData.data !== "object") { + return false; + } + + return true; +} + +function userNotesApplyImportData(importData, options) { + const data = importData?.data ?? {}; + + let changed = false; + + if (options.importNotes && data.notes !== undefined) { + const importedNotes = userNotesNormalizeImportedNotes(data.notes); + + if (options.noteMode === "append") { + const existing = userNotesSanitizeHtml(userNotesLoadNotes()); + const separator = existing.trim() ? "
" : ""; + + userNotesSaveNotes(`${existing}${separator}${importedNotes}`); + } else { + userNotesSaveNotes(importedNotes); + } + + changed = true; + } + + if (options.importAppearance && data.appearance) { + userNotesSaveAppearance(data.appearance); + changed = true; + } + + if (options.importPosition && data.position) { + userNotesSavePositionData(data.position); + changed = true; + } + + if (!changed) { + return false; + } + + try { + globalThis.UserNotes?.refresh?.({ + saveCurrentContent: false + }); + } catch (err) { + console.warn( + "User Notes | Import was saved, but refreshing the open window failed", + err + ); + } + + return true; +} + +class UserNotesExportSettings extends FormApplication { + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + id: "user-notes-export-settings", + title: "User Notes Export", + template: null, + width: 480, + height: "auto", + closeOnSubmit: false, + submitOnChange: false, + submitOnClose: false + }); + } + + async _renderInner() { + return $(` +
+

+ Exportiert ausgewählte lokale Browserdaten als JSON-Datei. + Die Datei enthält keine Welt- oder Benutzerdaten und wird beim Import + immer in den aktuell geöffneten Scope übernommen. +

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ `); + } + + activateListeners(html) { + super.activateListeners(html); + + html.on("click", ".user-notes-export-now", event => { + event.preventDefault(); + event.stopPropagation(); + + const root = html[0]; + + if (!root) { + console.warn("User Notes | export settings root element not found"); + return false; + } + + const options = { + includeNotes: root.querySelector('[name="includeNotes"]')?.checked ?? false, + includeAppearance: root.querySelector('[name="includeAppearance"]')?.checked ?? false, + includePosition: root.querySelector('[name="includePosition"]')?.checked ?? false + }; + + if (!options.includeNotes && !options.includeAppearance && !options.includePosition) { + ui.notifications?.warn("User Notes: Bitte mindestens einen Export-Inhalt auswählen."); + return false; + } + + const exportData = userNotesBuildExportData(options); + const date = new Date().toISOString().slice(0, 10); + + userNotesDownloadJson(`user-notes-${date}.json`, exportData); + + ui.notifications?.info("User Notes: Export wurde erstellt."); + this.close(); + + return false; + }); + } + + async _updateObject(_event, _formData) { + // Nicht verwendet. Buttons verarbeiten den Export direkt. + } +} + +class UserNotesImportSettings extends FormApplication { + constructor(...args) { + super(...args); + this.importData = null; + } + + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + id: "user-notes-import-settings", + title: "User Notes Import", + template: null, + width: 560, + height: "auto", + closeOnSubmit: false, + submitOnChange: false, + submitOnClose: false + }); + } + + async _renderInner() { + const hasData = Boolean(this.importData?.data); + const hasNotes = hasData && this.importData.data.notes !== undefined; + const hasAppearance = hasData && Boolean(this.importData.data.appearance); + const hasPosition = hasData && Boolean(this.importData.data.position); + + return $(` +
+

+ Importiert eine User-Notes-JSON-Datei in die aktuell geöffnete Welt + und für den aktuell eingeloggten Benutzer. +

+ +

+ Sicherheits-Hinweis: Importiertes HTML wird streng bereinigt. + Skripte, Eventhandler, eingebettete aktive Inhalte, unsichere URLs und + nicht erlaubte HTML-Elemente werden entfernt. Dadurch können + Formatierungen verloren gehen. +

+ +
+ +
+ +
+
+ +
+ +
+ + +
+ +
+ +
+ +
+
+ +
+ + +
+ +
+ + +
+ + +
+ `); + } + + activateListeners(html) { + super.activateListeners(html); + + html.on("change", 'input[name="importFile"]', async event => { + const fileInput = event.currentTarget; + const file = fileInput.files?.[0]; + + if (!file) { + return; + } + + try { + const json = await userNotesReadJsonFile(file); + + if (!userNotesValidateImportData(json)) { + ui.notifications?.error("User Notes: Diese JSON-Datei ist kein gültiger User-Notes-Export."); + return; + } + + this.importData = json; + + console.log("User Notes | import file loaded", { + hasNotes: json.data?.notes !== undefined, + hasAppearance: Boolean(json.data?.appearance), + hasPosition: Boolean(json.data?.position) + }); + + ui.notifications?.info("User Notes: Importdatei wurde gelesen."); + this.render(true); + } catch (err) { + console.error("User Notes | Import failed while reading JSON", err); + ui.notifications?.error("User Notes: JSON-Datei konnte nicht gelesen werden."); + } + }); + + html.on("click", ".user-notes-import-now", event => { + event.preventDefault(); + event.stopPropagation(); + + const root = html[0]; + + console.log("User Notes | import button clicked", { + hasImportData: Boolean(this.importData) + }); + + if (!root) { + console.warn("User Notes | import settings root element not found"); + return false; + } + + if (!this.importData) { + ui.notifications?.warn("User Notes: Bitte zuerst eine JSON-Datei auswählen."); + return false; + } + + const options = { + importNotes: root.querySelector('[name="importNotes"]')?.checked ?? false, + noteMode: root.querySelector('[name="noteMode"]')?.value ?? "overwrite", + importAppearance: root.querySelector('[name="importAppearance"]')?.checked ?? false, + importPosition: root.querySelector('[name="importPosition"]')?.checked ?? false + }; + + console.log("User Notes | import options", options); + + if (!options.importNotes && !options.importAppearance && !options.importPosition) { + ui.notifications?.warn("User Notes: Bitte mindestens einen Import-Inhalt auswählen."); + return false; + } + + try { + const changed = userNotesApplyImportData(this.importData, options); + + if (!changed) { + ui.notifications?.warn("User Notes: Es wurden keine importierbaren Daten angewendet."); + return false; + } + + ui.notifications?.info("User Notes: Import wurde angewendet."); + this.close(); + } catch (err) { + console.error("User Notes | Import failed while applying data", err); + ui.notifications?.error("User Notes: Import konnte nicht angewendet werden. Details stehen in der Browser-Konsole."); + } + + return false; + }); + } + + async _updateObject(_event, _formData) { + // Nicht verwendet. Buttons verarbeiten den Import direkt. + } +} + +export function userNotesRegisterBackupSettings() { + game.settings.registerMenu(USER_NOTES_MODULE_ID, "exportData", { + name: "Export", + label: "Notizen exportieren", + hint: "Exportiert ausgewählte lokale User-Notes-Daten als JSON-Datei.", + icon: "fas fa-file-export", + type: UserNotesExportSettings, + restricted: false + }); + + game.settings.registerMenu(USER_NOTES_MODULE_ID, "importData", { + name: "Import", + label: "Notizen importieren", + hint: "Importiert Notizen, Darstellung und/oder Fensterposition aus einer JSON-Datei. Importiertes HTML wird aus Sicherheitsgründen streng bereinigt.", + icon: "fas fa-file-import", + type: UserNotesImportSettings, + restricted: false + }); +} diff --git a/scripts/user-notes-storage.js b/scripts/user-notes-storage.js index 8ec28e8..caa693c 100644 --- a/scripts/user-notes-storage.js +++ b/scripts/user-notes-storage.js @@ -2,66 +2,270 @@ import { USER_NOTES_MODULE_ID } from "./user-notes-constants.js"; -export function userNotesStorageKey() { +const USER_NOTES_STORAGE_SCHEMA_VERSION = 2; + +function userNotesBaseKey() { const worldId = game.world?.id ?? game.world?.data?.id ?? "unknown-world"; const userId = game.user?.id ?? "unknown-user"; - return `${USER_NOTES_MODULE_ID}.${worldId}.${userId}.notes`; + return `${USER_NOTES_MODULE_ID}.${worldId}.${userId}`; +} + +export function userNotesContentKey() { + return `${userNotesBaseKey()}.content`; +} + +export function userNotesStateKey() { + return `${userNotesBaseKey()}.state`; +} + +/** + * Legacy keys from older versions. + * Kept only for migration. + */ +export function userNotesStorageKey() { + return `${userNotesBaseKey()}.notes`; } export function userNotesPositionKey() { - const worldId = game.world?.id ?? game.world?.data?.id ?? "unknown-world"; - const userId = game.user?.id ?? "unknown-user"; - - return `${USER_NOTES_MODULE_ID}.${worldId}.${userId}.position`; + return `${userNotesBaseKey()}.position`; } export function userNotesAppearanceKey() { - const worldId = game.world?.id ?? game.world?.data?.id ?? "unknown-world"; - const userId = game.user?.id ?? "unknown-user"; - - return `${USER_NOTES_MODULE_ID}.${worldId}.${userId}.appearance`; + return `${userNotesBaseKey()}.appearance`; } -export function userNotesLoadNotes() { - return window.localStorage.getItem(userNotesStorageKey()) ?? ""; +export function userNotesEncodeBase64(value) { + const bytes = new TextEncoder().encode(String(value ?? "")); + const chunkSize = 0x8000; + let binary = ""; + + for (let index = 0; index < bytes.length; index += chunkSize) { + const chunk = bytes.subarray(index, index + chunkSize); + binary += String.fromCharCode(...chunk); + } + + return window.btoa(binary); } -export function userNotesSaveNotes(value) { - window.localStorage.setItem(userNotesStorageKey(), value); -} - -export function userNotesRemoveSavedPosition() { - window.localStorage.removeItem(userNotesPositionKey()); -} - -export function userNotesLoadAppearance(defaults) { +export function userNotesDecodeBase64(value) { try { - const raw = window.localStorage.getItem(userNotesAppearanceKey()); + const binary = window.atob(String(value ?? "")); + const bytes = new Uint8Array(binary.length); - if (!raw) { - return { ...defaults }; + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); } - const parsed = JSON.parse(raw); - - return { - ...defaults, - ...parsed - }; + return new TextDecoder().decode(bytes); } catch (err) { - console.warn("User Notes | Could not load appearance settings", err); - return { ...defaults }; + console.warn("User Notes | Could not decode base64 content", err); + return ""; } } -export function userNotesSaveAppearance(values) { +function userNotesRemoveLegacyStorageKeys() { + window.localStorage.removeItem(userNotesStorageKey()); + window.localStorage.removeItem(userNotesPositionKey()); + window.localStorage.removeItem(userNotesAppearanceKey()); +} + +function userNotesReadJson(key, fallback = null) { + try { + const raw = window.localStorage.getItem(key); + + if (!raw) { + return fallback; + } + + return JSON.parse(raw); + } catch (err) { + console.warn(`User Notes | Could not parse localStorage key: ${key}`, err); + return fallback; + } +} + +function userNotesWriteJson(key, value) { window.localStorage.setItem( - userNotesAppearanceKey(), - JSON.stringify(values) + key, + JSON.stringify(value) ); } -export function userNotesRemoveSavedAppearance() { - window.localStorage.removeItem(userNotesAppearanceKey()); +function userNotesCreateEmptyContent() { + return { + schemaVersion: USER_NOTES_STORAGE_SCHEMA_VERSION, + updatedAt: new Date().toISOString(), + notes: { + format: "html", + encoding: "base64", + data: "" + } + }; +} + +function userNotesCreateEmptyState() { + return { + schemaVersion: USER_NOTES_STORAGE_SCHEMA_VERSION, + updatedAt: new Date().toISOString(), + appearance: null, + position: null + }; +} + +function userNotesLoadContentObject() { + const content = userNotesReadJson( + userNotesContentKey(), + null + ); + + if (content?.notes?.encoding === "base64") { + return content; + } + + const legacyNotes = window.localStorage.getItem(userNotesStorageKey()); + + if (legacyNotes !== null) { + const migrated = userNotesCreateEmptyContent(); + migrated.notes.data = userNotesEncodeBase64(legacyNotes); + + userNotesWriteJson(userNotesContentKey(), migrated); + window.localStorage.removeItem(userNotesStorageKey()); + + return migrated; + } + + return userNotesCreateEmptyContent(); +} + +function userNotesLoadStateObject() { + const state = userNotesReadJson( + userNotesStateKey(), + null + ); + + if (state && typeof state === "object") { + return { + ...userNotesCreateEmptyState(), + ...state + }; + } + + const migrated = userNotesCreateEmptyState(); + + const legacyPosition = userNotesReadJson( + userNotesPositionKey(), + null + ); + + const legacyAppearance = userNotesReadJson( + userNotesAppearanceKey(), + null + ); + + if (legacyPosition) { + migrated.position = legacyPosition; + } + + if (legacyAppearance) { + migrated.appearance = legacyAppearance; + } + + if (legacyPosition || legacyAppearance) { + userNotesWriteJson(userNotesStateKey(), migrated); + window.localStorage.removeItem(userNotesPositionKey()); + window.localStorage.removeItem(userNotesAppearanceKey()); + } + + return migrated; +} + +function userNotesSaveStateObject(state) { + userNotesWriteJson( + userNotesStateKey(), + { + ...userNotesCreateEmptyState(), + ...state, + schemaVersion: USER_NOTES_STORAGE_SCHEMA_VERSION, + updatedAt: new Date().toISOString() + } + ); + + userNotesRemoveLegacyStorageKeys(); +} + +export function userNotesLoadNotes() { + const content = userNotesLoadContentObject(); + + if (content?.notes?.encoding === "base64") { + return userNotesDecodeBase64(content.notes.data); + } + + return ""; +} + +export function userNotesSaveNotes(value) { + const content = { + schemaVersion: USER_NOTES_STORAGE_SCHEMA_VERSION, + updatedAt: new Date().toISOString(), + notes: { + format: "html", + encoding: "base64", + data: userNotesEncodeBase64(value) + } + }; + + userNotesWriteJson(userNotesContentKey(), content); + userNotesRemoveLegacyStorageKeys(); +} + +export function userNotesLoadPosition(defaultValue = null) { + const state = userNotesLoadStateObject(); + + return state.position ?? defaultValue; +} + +export function userNotesSavePositionData(position) { + const state = userNotesLoadStateObject(); + + state.position = position; + + userNotesSaveStateObject(state); +} + +export function userNotesRemoveSavedPosition() { + const state = userNotesLoadStateObject(); + + state.position = null; + + userNotesSaveStateObject(state); +} + +export function userNotesLoadAppearance(defaults) { + const state = userNotesLoadStateObject(); + + if (!state.appearance || typeof state.appearance !== "object") { + return { ...defaults }; + } + + return { + ...defaults, + ...state.appearance + }; +} + +export function userNotesSaveAppearance(values) { + const state = userNotesLoadStateObject(); + + state.appearance = values; + + userNotesSaveStateObject(state); +} + +export function userNotesRemoveSavedAppearance() { + const state = userNotesLoadStateObject(); + + state.appearance = null; + + userNotesSaveStateObject(state); } diff --git a/scripts/user-notes-window.js b/scripts/user-notes-window.js index d9dec30..bffceb4 100644 --- a/scripts/user-notes-window.js +++ b/scripts/user-notes-window.js @@ -9,8 +9,9 @@ import { import { userNotesLoadNotes, + userNotesLoadPosition, userNotesSaveNotes, - userNotesPositionKey, + userNotesSavePositionData, userNotesRemoveSavedPosition } from "./user-notes-storage.js"; @@ -99,9 +100,9 @@ export function userNotesClampPosition(position) { export function userNotesRestorePosition(win) { try { - const raw = window.localStorage.getItem(userNotesPositionKey()); + const pos = userNotesLoadPosition(null); - if (!raw) { + if (!pos) { userNotesApplyPosition( win, userNotesClampPosition(USER_NOTES_DEFAULT_POSITION) @@ -109,8 +110,6 @@ export function userNotesRestorePosition(win) { return; } - const pos = JSON.parse(raw); - const restoredPosition = { left: Number.isFinite(pos.left) ? pos.left : USER_NOTES_DEFAULT_POSITION.left, top: Number.isFinite(pos.top) ? pos.top : USER_NOTES_DEFAULT_POSITION.top, @@ -153,10 +152,7 @@ export function userNotesSavePosition(win) { height: Math.round(rect.height) }); - window.localStorage.setItem( - userNotesPositionKey(), - JSON.stringify(position) - ); + userNotesSavePositionData(position); } export function userNotesResetPositionAndSize() { @@ -315,10 +311,15 @@ function userNotesCreateEditor(win, editorElement) { const editor = globalThis.Jodit.make(editorElement, { height: "100%", minHeight: 150, + + allowResizeX: false, + allowResizeY: false, + toolbarAdaptive: false, askBeforePasteHTML: false, askBeforePasteFromWord: false, defaultActionOnPaste: "insert_clear_html", + disablePlugins: [ "about", "add-new-line", @@ -334,6 +335,7 @@ function userNotesCreateEditor(win, editorElement) { "speech-recognize", "video" ], + buttons: [ "bold", "italic", @@ -527,14 +529,19 @@ export function userNotesOpenNotes() { } } -export function userNotesRefreshOpenWindow() { +export function userNotesRefreshOpenWindow(options = {}) { + const saveCurrentContent = options.saveCurrentContent ?? true; + const oldWin = document.getElementById(USER_NOTES_WINDOW_ID); if (!oldWin) { return; } - userNotesSaveNotes(userNotesGetEditorValue(oldWin)); + if (saveCurrentContent) { + userNotesSaveNotes(userNotesGetEditorValue(oldWin)); + } + userNotesSavePosition(oldWin); userNotesDestroyEditor(oldWin); oldWin.remove(); diff --git a/scripts/user-notes.js b/scripts/user-notes.js index e9ecd11..670757e 100644 --- a/scripts/user-notes.js +++ b/scripts/user-notes.js @@ -2,6 +2,10 @@ import { userNotesRegisterSettings } from "./user-notes-settings.js"; +import { + userNotesRegisterBackupSettings +} from "./user-notes-backup.js"; + import { userNotesRegisterTokenControl } from "./user-notes-controls.js"; @@ -23,8 +27,10 @@ Hooks.once("init", () => { console.log("User Notes | application registered"); userNotesRegisterSettings(userNotesResetPositionAndSize); - console.log("User Notes | settings registered"); + + userNotesRegisterBackupSettings(); + console.log("User Notes | export/import settings registered"); } catch (err) { console.error("User Notes | error during init", err); @@ -44,4 +50,4 @@ Hooks.on("getSceneControlButtons", controls => { "User Notes: Fehler beim Registrieren des Token-Controls." ); } -}); \ No newline at end of file +}); diff --git a/styles/user-notes-settings.css b/styles/user-notes-settings.css index 69ec60c..7217c93 100644 --- a/styles/user-notes-settings.css +++ b/styles/user-notes-settings.css @@ -121,7 +121,7 @@ width: 100%; } -.user-notes-backup-settings .sheet-footer { +.user-notes-backup-footer { margin-top: 0.75rem; display: flex; @@ -129,6 +129,34 @@ gap: 0.5rem; } -.user-notes-backup-settings .sheet-footer button { +.user-notes-backup-footer button { flex: 1 1 auto; + height: 2rem; + min-height: 2rem; + + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + + box-sizing: border-box; +} + +.user-notes-checkbox-row { + display: grid; + grid-template-columns: 1fr 2rem; + align-items: center; + column-gap: 0.75rem; + + margin: 0 0 0.45rem 0; +} + +.user-notes-checkbox-row label { + margin: 0; + line-height: 1.3; +} + +.user-notes-checkbox-row input[type="checkbox"] { + justify-self: center; + margin: 0; }