diff --git a/.vscode/launch.json b/.vscode/launch.json index 74a4b9d..f68267e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,7 +8,7 @@ "type": "chrome", "request": "launch", "name": "Launch Chrome", - "url": "http://192.168.1.69:59999", + "url": "http://localhost:59999", "pathMapping": { "/modules/${workspaceFolderBasename}": "${workspaceFolder}" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 7576068..be2c6ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [1.3.0] - 2021-03-27 +- Added a setting to automatically create folders per-user to store Journal Entries that are related to Notes (thanks @ethaks!) +- Added a setting for the default permission to apply to newly created Player Notes (eg. `Observer`, `Limited` etc) (thanks @ethaks!) +- 居酒屋はここにあります -- 日本語 (Japanese) translation added (thanks @brothersharper and `touge`!) + ## [1.2.0] - 2021-03-18 - Players can now create Notes on the scene/map! (🎉 thanks @ethaks for this awesome new feature! 🙌) - - Note: in order to allow Players to create Notes they must have the `Create Journal Entry` permission in the core Foundry permissions. You'll also need to enable `Allow Player Notes` in module settings! diff --git a/lang/en.json b/lang/en.json index 5b91c84..47a83ed 100644 --- a/lang/en.json +++ b/lang/en.json @@ -10,5 +10,21 @@ "SETTINGS.PreviewMaxLengthN": "Preview Maximum Length", "SETTINGS.PreviewMaxLengthH": "**TEXT PREVIEW ONLY** How many characters should the text preview show?", "SETTINGS.PreviewDelayN": "Preview Delay", - "SETTINGS.PreviewDelayH": "How long before preview appears (in milliseconds)" + "SETTINGS.PreviewDelayH": "How long before preview appears (in milliseconds)", + "SETTINGS.DefaultJournalPermissionN": "Default Journal Entry Permission", + "SETTINGS.DefaultJournalPermissionH": "The default permission journal entries will be created with", + "SETTINGS.DefaultJournalFolderN": "Default Journal Entry Folder", + "SETTINGS.DefaultJournalFolderH": "The default folder journal entries will be created in when creating Map Pins with a double-click", + + "PinCushion": { + "None": "None", + "PerUser": "Per User", + "CreateMissingFoldersT": "Create Missing Folders", + "CreateMissingFoldersC": "Create a Journal Entry folder for each player?
This ensures that each player has their own folder for entries to be created in.", + + "Warn": { + "AllowPlayerNotes": "Players also need the Core Foundry Permission \"{permission}\" to create map pins.", + "MissingPinName": "Missing Map Pin Name!" + } + } } diff --git a/lang/ja.json b/lang/ja.json new file mode 100644 index 0000000..13be668 --- /dev/null +++ b/lang/ja.json @@ -0,0 +1,14 @@ +{ + "SETTINGS.AboutAppN": "Pin Cushionについて", + "SETTINGS.AboutAppH": "Pin Cushionの追加情報を表示します。", + "SETTINGS.AllowPlayerNotesN": "プレイヤーにノートを許可する", + "SETTINGS.AllowPlayerNotesH": "プレイヤーがマップピンを作成できるようになります。", + "SETTINGS.ShowJournalPreviewN": "資料のプレビューを表示", + "SETTINGS.ShowJournalPreviewH": "マップピンにカーソルを置くと、資料のプレビューが表示されます。", + "SETTINGS.PreviewTypeN": "資料のプレビュー形式", + "SETTINGS.PreviewTypeH": "表示される資料のプレビュー形式を指定します(HTMLもしくはテキスト)", + "SETTINGS.PreviewMaxLengthN": "プレビューの最大長", + "SETTINGS.PreviewMaxLengthH": "**テキスト形式のみ** 何文字までプレビュー表示しますか?", + "SETTINGS.PreviewDelayN": "プレビューのディレイ値", + "SETTINGS.PreviewDelayH": "プレビューが表示されるまでの時間(ミリ秒)" +} \ No newline at end of file diff --git a/module.json b/module.json index bfd2def..fb460f5 100644 --- a/module.json +++ b/module.json @@ -2,7 +2,7 @@ "name": "pin-cushion", "title": "Pin Cushion", "description": "Adds additional map pin (Journal Note/Scene Note) functionality", - "version": "1.2.0", + "version": "1.3.0", "minimumCoreVersion": "0.7.5", "compatibleCoreVersion": "0.7.9", "author": "Evan Clarke [errational#2007]", @@ -13,6 +13,12 @@ "discord": "errational#2007", "twitter": "@death_save_dev", "reddit": "u/etherboy12" + }, + { + "name": "Ethaks", + "ko-fi": "ethaks", + "discord": "Ethaks#2903", + "reddit": "u/ethaks" } ], "url": "https://github.com/death-save/pin-cushion", @@ -41,6 +47,11 @@ "lang": "es", "name": "Español", "path": "./lang/es.json" + }, + { + "lang": "ja", + "name": "日本語", + "path": "./lang/ja.json" } ], "media": [ diff --git a/package.json b/package.json index 42660d2..31de448 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pin-cushion", - "version": "1.2.0", + "version": "1.3.0", "description": "Provides additional functionality around Map Pins", "main": "pin-cushion.js", "scripts": { diff --git a/pin-cushion.js b/pin-cushion.js index 75cf3e0..c3e5261 100644 --- a/pin-cushion.js +++ b/pin-cushion.js @@ -5,6 +5,10 @@ import { libWrapper } from "./lib-wrapper-shim.js"; * @author Evan Clarke (errational#2007) */ class PinCushion { + constructor() { + // Storage for requests sent over a socket, pending GM execution + this._requests = {}; + } /* -------------------------------- Constants ------------------------------- */ @@ -31,10 +35,6 @@ class PinCushion { return "NotesLayer"; } - static get MISSING_NAME() { - return "Missing Map Pin Name!"; - } - static get FONT_SIZE() { return 16; } @@ -79,10 +79,28 @@ class PinCushion { const input = html.find("input[name='name']"); if (!input[0].value) { - ui.notifications.warn(PinCushion.MISSING_NAME); + ui.notifications.warn(game.i18n.localize("PinCushion.Warn.MissingPinName")); return; } - const entry = await JournalEntry.create({name: `${input[0].value}`}); + // Permissions the Journal Entry will be created with + const permission = { + [game.userId]: ENTITY_PERMISSIONS.OWNER, + default: game.settings.get(PinCushion.MODULE_NAME, "defaultJournalPermission") ?? 0 + } + + // Get folder ID for Journal Entry + let folder = PinCushion.getFolder(); + if ( + !game.user.isGM && + game.settings.get(PinCushion.MODULE_NAME, "defaultJournalFolder") === "perUser" && + folder === undefined + ) { + // Request folder creation when perUser is set and the entry is created by a user + // Since only the ID is required, instantiating a Folder from the data is not necessary + folder = (await PinCushion.requestEvent({ action: "createFolder" }))?._id; + } + + const entry = await JournalEntry.create({name: `${input[0].value}`, permission, folder}); if (!entry) { return; @@ -98,6 +116,95 @@ class PinCushion { await canvas.activeLayer._onDropData(eventData, entryData); } + /** + * Request an action to be executed with GM privileges. + * + * @static + * @param {object} message - The object that will get emitted via socket + * @param {string} message.action - The specific action to execute + * @returns {Promise} The promise of the action which will be resolved after execution by the GM + */ + static requestEvent(message) { + // A request has to define what action should be executed by the GM + if (!"action" in message) return; + + const promise = new Promise((resolve, reject) => { + const id = `${game.user.id}_${Date.now()}_${randomID()}`; + message.id = id; + game.pinCushion._requests[id] = {resolve, reject}; + game.socket.emit(`module.${PinCushion.MODULE_NAME}`, message); + setTimeout(() => { + delete game.pinCushion._requests[id]; + reject(new Error (`${PinCushion.MODULE_TITLE} | Call to ${message.action} timed out`)); + }, 5000); + }); + return promise; + } + + /** + * Gets the JournalEntry Folder ID to be used for JournalEntry creations, if any. + * + * @static + * @param {string} name - The player name to check folders against, defaults to current user's name + * @returns {string|undefined} The folder's ID, or undefined if there is no target folder + */ + static getFolder(name) { + name = name ?? game.user.name; + const setting = game.settings.get(PinCushion.MODULE_NAME, "defaultJournalFolder"); + switch (setting) { + // No target folder set + case "none": + return undefined; + // Target folder should match the user's name + case "perUser": + return game.journal.directory.folders.find((f) => f.name === name)?.id ?? undefined; + default: + return undefined; + } + } + + /** + * Checks for missing Journal Entry folders and creates them + * + * @static + * @private + * @returns {void} + */ + static async _createFolders() { + // Collect missing folders + const missingFolders = game.users + .filter((u) => !u.isGM && PinCushion.getFolder(u.name) === undefined) + .map((user) => ({ + name: user.name, + type: "JournalEntry", + parent: null, + sorting: "a", + })); + if (missingFolders.length) { + // Ask for folder creation confirmation in a dialog + const createFolders = await new Promise((resolve, reject) => { + new Dialog({ + title: game.i18n.localize("PinCushion.CreateMissingFoldersT"), + content: game.i18n.localize("PinCushion.CreateMissingFoldersC"), + buttons: { + yes: { + label: ` ${game.i18n.localize("Yes")}`, + callback: () => resolve(true), + }, + no: { + label: ` ${game.i18n.localize("No")}`, + callback: () => reject(), + }, + }, + default: "yes", + close: () => reject(), + }).render(true); + }).catch((_) => {}); + // Create folders + if (createFolders) await Folder.create(missingFolders); + } + } + /** * Replaces icon selector in Notes Config form with filepicker * @param {*} app @@ -146,13 +253,14 @@ class PinCushion { * Handles pressing the delete key * * @static + * @async * @param {function} wrapped - The original function * @param {Event} event - The triggering event */ - static _onDeleteKey(wrapped, event) { + static async _onDeleteKey(wrapped, event) { if (!game.user.isGM && this._hover?.entry.owner && game.settings.get(PinCushion.MODULE_NAME, "allowPlayerNotes")) { const note = {id: this._hover.id, scene: this._hover.scene.id}; - return game.socket.emit(`module.${PinCushion.MODULE_NAME}`, {action: "deferNoteDelete", object: note}); + return await PinCushion.requestEvent({action: "deferNoteDelete", object: note}); } return wrapped(event); } @@ -161,19 +269,71 @@ class PinCushion { * Socket handler * * @param {object} message - The socket event's content + * @param {string} message.action - The action the socket receiver should take + * @param {object} [message.object] - The object that should be acted upon + * @param {Data} [message.data] - The data to be used for Entity actions + * @param {Options} [message.options] - Additional options given to Foundry methods + * @param {string} [message.id] - The ID used to handle promises * @param {string} userId - The ID of the user emitting the socket event - * @return {Promise|boolean} The affected entity or false in case of missing permissions + * @returns {void} */ _onSocket(message, userId) { - const {action, object, data, options} = message; + const {action, object, data, options, id} = message; const isFirstGM = game.user === game.users.find((u) => u.isGM && u.active) - const scene = game.scenes.get(object.scene) + + // Handle resolving or rejecting promises for GM priviliged requests + if (action === "return") { + const promise = game.pinCushion._requests[message.id]; + if (promise) { + delete game.pinCushion._requests[message.id]; + if ("error" in message) promise.reject(message.error); + promise.resolve(data); + } + return; + } + + // Create a Journal Entry Folder + if (action === "createFolder") { + const userName = game.users.get(userId).name; + return Folder.create({ name: userName, type: "JournalEntry", parent: null, sorting: "a" }) + .then((response) => { + game.socket.emit(`module.${PinCushion.MODULE_NAME}`, { + action: "return", + data: response.data, + id: id, + }); + }) + .catch((error) => { + game.socket.emit(`module.${PinCushion.MODULE_NAME}`, { + action: "return", + error: error, + id: id, + }); + }); + } // Cancel Note handling if users are not allowed to affect Notes if (!game.settings.get(PinCushion.MODULE_NAME, "allowPlayerNotes")) return false; + const scene = game.scenes.get(object.scene) + if (action === "deferNoteCreate" && isFirstGM) { - return scene.createEmbeddedEntity("Note", data); + return scene.createEmbeddedEntity("Note", data, options) + .then((response) => { + game.socket.emit(`module.${PinCushion.MODULE_NAME}`, { + action: "return", + id: id, + data: response, + object: {scene: scene.id} + }); + }) + .catch((error) => { + game.socket.emit(`module.${PinCushion.MODULE_NAME}`, { + action: "return", + id: id, + error: error, + }); + }); } // The following actions deal with a single Note, so a common instance can be created @@ -183,12 +343,45 @@ class PinCushion { // Only handle update if user is the owner of the JournalEntry if (isFirstGM && userPermission) { + // Update a Note if (action === "deferNoteUpdate") { - return note.update(data, options); + return note + .update(data, options) + .then((response) => { + game.socket.emit(`module.${PinCushion.MODULE_NAME}`, { + action: "return", + id: id, + data: response.data, + object: {scene: response.scene.id}, + }); + }) + .catch((error) => { + game.socket.emit(`module.${PinCushion.MODULE_NAME}`, { + action: "return", + id: id, + error: error, + }); + }); } + // Delete a Note if (action === "deferNoteDelete") { - return note.delete(); + return note + .delete(options) + .then((response) => { + game.socket.emit(`module.${PinCushion.MODULE_NAME}`, { + action: "return", + id: id, + data: response.data, + }); + }) + .catch((error) => { + game.socket.emit(`module.${PinCushion.MODULE_NAME}`, { + action: "return", + id: id, + error: error, + }); + }); } } } @@ -197,34 +390,46 @@ class PinCushion { /** * Override Note Create to allow Player creation + * + * @async * @param {*} wrapped * @param {*} data * @param {*} options - * @returns {Function} the wrapped function + * @returns {Promise} The created Note */ - static _overrideNoteCreate(wrapped, data, options) { + static async _overrideNoteCreate(wrapped, data, options) { if (!game.user.isGM && game.settings.get(PinCushion.MODULE_NAME, "allowPlayerNotes")) { - return game.socket.emit( - `module.${PinCushion.MODULE_NAME}`, - {action: "deferNoteCreate", object: {scene: canvas.scene.id}, data: data} - ); + const response = await PinCushion.requestEvent({ + action: "deferNoteCreate", + object: { scene: canvas.scene.id }, + data: data, + options: options, + }); + // Create Note instance from data to keep original function signature + return new Note(response.data, response.object?.scene); } return wrapped(data, options); } /** * Override Note Update to allow Player updates + * + * @async * @param {*} wrapped * @param {*} data - * @returns {Function} the wrapped function + * @returns {Promise} The updated Note */ - static _overrideNoteUpdate(wrapped, data) { + static async _overrideNoteUpdate(wrapped, data, options) { const note = {id: this.id, scene: this.scene.id}; if (!game.user.isGM && game.settings.get(PinCushion.MODULE_NAME, "allowPlayerNotes")) { - return game.socket.emit( - `module.${PinCushion.MODULE_NAME}`, - {action: "deferNoteUpdate", object: note, data: data, options: options} - ); + const response = await PinCushion.requestEvent({ + action: "deferNoteUpdate", + object: note, + data: data, + options: options, + }); + // Create Note instance from data to keep original function signature + return new Note(response.data, response.object?.scene); } return wrapped(data); } @@ -234,9 +439,9 @@ class PinCushion { * @param {*} wrapped * @param {*} user * @param {*} event - * @returns {Function} the wrapped function + * @returns {boolean} Whether the user can configure the Note */ - static _overrideNoteCanConfigue(wrapped, user, event) { + static _overrideNoteCanConfigure(wrapped, user, event) { if (game.settings.get(PinCushion.MODULE_NAME, "allowPlayerNotes") && this.entry?.owner) return true; return wrapped(user, event); } @@ -246,7 +451,7 @@ class PinCushion { * @param {*} wrapped * @param {*} user * @param {*} event - * @returns {Function} the wrapped function + * @returns {boolean} Wehther the user can control the Note */ static _overrideNoteCanControl(wrapped, user, event) { if (game.settings.get(PinCushion.MODULE_NAME, "allowPlayerNotes") && this.entry?.owner) return true; @@ -323,8 +528,46 @@ class PinCushion { type: Boolean, default: false, config: true, + onChange: s => { + // Warn the GM if player notes are allowed while players cannot create Journal Entries + if (s === true && game.user.isGM && !game.permissions.JOURNAL_CREATE.includes(1)) { + ui.notifications.warn(game.i18n.format("PinCushion.Warn.AllowPlayerNotes", {permission: game.i18n.localize("PERMISSION.JournalCreate")})); + } + } + }); + + game.settings.register(PinCushion.MODULE_NAME, "defaultJournalPermission", { + name: "SETTINGS.DefaultJournalPermissionN", + hint: "SETTINGS.DefaultJournalPermissionH", + scope: "world", + type: Number, + choices: Object.entries(ENTITY_PERMISSIONS).reduce((acc, [perm, key]) => { + acc[key] = game.i18n.localize(`PERMISSION.${perm}`) + return acc; + }, {}), + default: 0, + config: true, onChange: s => {} }); + + game.settings.register(PinCushion.MODULE_NAME, "defaultJournalFolder", { + name: "SETTINGS.DefaultJournalFolderN", + hint: "SETTINGS.DefaultJournalFolderH", + scope: "world", + type: String, + choices: { + none: game.i18n.localize("PinCushion.None"), + perUser: game.i18n.localize("PinCushion.PerUser"), + }, + default: "none", + config: true, + onChange: s => { + // Only run check for folder creation for the main GM + if (s === "perUser" && game.user === game.users.find(u => u.isGM && u.active)) { + PinCushion._createFolders(); + } + } + }); } } @@ -408,7 +651,7 @@ Hooks.on("init", () => { PinCushion._registerSettings(); // Register overrides to enable creation, configuration, deletion, and movement of Notes by users - libWrapper.register(PinCushion.MODULE_NAME, "Note.prototype._canConfigure", PinCushion._overrideNoteCanConfigue); + libWrapper.register(PinCushion.MODULE_NAME, "Note.prototype._canConfigure", PinCushion._overrideNoteCanConfigure); libWrapper.register(PinCushion.MODULE_NAME, "Note.prototype._canControl", PinCushion._overrideNoteCanControl); libWrapper.register(PinCushion.MODULE_NAME, "Note.create", PinCushion._overrideNoteCreate); libWrapper.register(PinCushion.MODULE_NAME, "Note.prototype.update", PinCushion._overrideNoteUpdate); @@ -421,6 +664,8 @@ Hooks.on("init", () => { * Hook on ready */ Hooks.on("ready", () => { + // Instantiate PinCushion instance for central socket request handling + game.pinCushion = new PinCushion(); // Wait for game to exist, then register socket handler game.socket.on(`module.${PinCushion.MODULE_NAME}`, game.pinCushion._onSocket); }); @@ -432,13 +677,6 @@ Hooks.on("renderNoteConfig", (app, html, data) => { PinCushion._replaceIconSelector(app, html, data); }); -/** - * Hook on canvas ready to register click listener - */ -Hooks.on("canvasReady", (app, html, data) => { - game.pinCushion = new PinCushion(); -}); - /** * Hook on render HUD */ @@ -485,6 +723,7 @@ Hooks.on("preUpdateNote", (scene, noteData, updateData, options, userId) => { const journalEntry = game.journal.get(noteData.entryId); if (!user.isGM && journalEntry.owner) { game.socket.emit(`module.${PinCushion.MODULE_NAME}`, {action: "deferNoteUpdate", object: {id: noteData._id, scene: scene.id}, data: updateData}); + // Prevent the update call for non-GM users (and the subsequent error) return false; } });