Commit c553e78f authored by Andrew's avatar Andrew
Browse files

Add module manifest and download URL. Add documentation. Improve UX.

parent 29f5f265
MIT License
Copyright (c) 2020 Foundry Network
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
# Foundry Virtual Tabletop - World Anvil Integration
A module to integrate World Anvil with Foundry Virtual Tabletop.
This module provides an integration with [World Anvil](https://worldanvil.com) for Foundry Virtual Tabletop, allowing you to import article content from World Anvil into the Foundry VTT journal system and easily keep that content synchronized over time as you update and create articles on the World Anvil platform.
-----
## Installation
This module can be installed automatically from the Foundry Virtual Tabletop module browser, or by using the following module manifest url: (https://gitlab.com/foundrynet/world-anvil/-/raw/master/module.json).
-----
## Configuration
To begin using this module, some initial configuration is required. You must provide a World Anvil user authorization API token which is available to any Guild member user by visiting the **API Keys Management** section of your World Anvil user dashboard.
Enable the World Anvil module in Foundry VTT and click the small **WA** logo at the bottom-right of the Journal Directory to open the World Anvil browser. This will open an initial configuration screen where you should enter your User Authentication Token and then choose the World from which you want to import content.
-----
## Importing World Anvil Content
Once the module is configured, the same **WA** icon will open the World Anvil browser which allows you to see all the Categories and Articles which exist within your world.
Clicking on the name of a Category or an Article will open the respective page on the World Anvil website. Clicking the button on the right side of the page will import the category or article into Foundry Virtual Tabletop.
### Importing a Category
When importing a category, it will become a Folder in your Journal sidebar and all articles within that category will be imported as entries under that new Folder.
### Importing a Single Article
When importing a single article, it will become a new Journal Entry in your Journal sidebar which you can move to a Folder of your choice.
![World Anvil Browser]["docs/wa-browser.jpg"]
-----
## Updating/Syncing an Article
Once a Category or Article has been imported, a link to it will display in the World Anvil Browser instead of an import button. For any imported World Anvil article, there is a **WA Sync** button in the header of the article which allows you to refresh the content, pulling the latest changes from the World Anvil website.
### Cross-Links
Content links from World Anvil are also preserved in Foundry VTT. If the linked Article has already been imported, the link will open the Journal Entry in Foundry VTT, otherwise that linked Article will be automatically imported.
-----
## Software License and Contribution Policy
This software is licensed under the MIT License. See the LICENSE.txt file in this repository for details.
If you would like to contribute to making this software better, merge requests are welcomed. In your merge request, please include a clear description of the change you are making and be prepared to engage with me for 1-2 rounds of code review.
Documentation is not yet created.
\ No newline at end of file
......@@ -3,6 +3,11 @@
"WA.ConfigureMenu": "World Anvil Integration",
"WA.ConfigureLabel": "World Anvil Integration",
"WA.ConfigureHint": "Configure your World Anvil integration.",
"WA.ConfigureStep1": "Step 1: Provide a World Anvil user authorization Token from the API Keys Management section of your World Anvil User Dashboard.",
"WA.ConfigureStep2": "Step 2: Choose which of your available World Anvil worlds you would like to associated with this Foundry VTT World.",
"WA.ConfigureStep3": "Your World Anvil integration is properly configured!",
"WA.UserToken": "User Authentication Token",
"WA.UserTokenHint": "Enter a World Anvil user authentication token for your World Anvil account on the Account Management page of your World Anvil user profile.",
"WA.WorldId": "Select World",
......
{
"name": "world-anvil",
"title": "World Anvil Integration",
"description": "<p>A module to integrate <strong>World Anvil</strong> with Foundry Virtual Tabletop.</p>",
"description": "<p>A module to integrate <a href=\"https://worldanvil.com\" title=\"World Anvil\" target=\"_blank\">World Anvil</a> with Foundry Virtual Tabletop.</p>",
"version": "0.1",
"minimumCoreVersion": "0.5.0",
"compatibleCoreVersion": "0.7.0",
......@@ -15,5 +15,8 @@
"name": "English",
"path": "lang/en.json"
}
]
],
"url": "https://gitlab.com/foundrynet/world-anvil",
"manifest": "https://gitlab.com/foundrynet/world-anvil/-/raw/master/module.json",
"download": "https://gitlab.com/foundrynet/world-anvil/-/archive/master/world-anvil-master.zip"
}
......@@ -3,6 +3,7 @@
*/
export default class WorldAnvil {
constructor() {
const config = game.settings.get("world-anvil", "configuration");
/**
* The Foundry VTT Application API key
......@@ -14,7 +15,7 @@ export default class WorldAnvil {
* The World Anvil user token
* @type {string|null}
*/
this.authToken = game.settings.get("world-anvil", "authToken");
this.authToken = config.authToken;
/**
* An array of World IDs which belong to the World Anvil user
......@@ -26,7 +27,7 @@ export default class WorldAnvil {
* The currently associated World ID
* @type {string|null}
*/
this.worldId = game.settings.get("world-anvil", "worldId");
this.worldId = config.worldId;
/**
* The currently associated World Anvil User
......@@ -87,7 +88,8 @@ export default class WorldAnvil {
* @param {string} [authToken]
*/
async connect(authToken) {
if ( authToken ) this.authToken = authToken;
if ( authToken !== undefined ) this.authToken = authToken;
if ( !this.authToken ) return;
this.user = await this.getUser();
console.log(`World Anvil | Connected to World Anvil API as User ${this.user.username}`);
}
......@@ -107,13 +109,26 @@ export default class WorldAnvil {
/**
* Fetch all articles from within a World, optionally filtering with a specific search query
* @param {string} worldId The World ID
* @param {object} [params] An optional search string
* @param {object} [params] An optional query parameters
* @return {Promise<object[]>} An array of Article objects
*/
async getArticles(worldId, params={}) {
worldId = worldId || this.worldId;
return this._fetch(`world/${worldId}/articles`, params);
async getArticles(params={}) {
params.limit = parseInt(params.limit) || 50;
params.offset = parseInt(params.offset) || 0;
// Query paged articles until we have retrieved them all
let hasMore = true;
let result = null;
while ( hasMore ) {
let batch = await this._fetch(`world/${this.worldId}/articles`, params);
hasMore = batch.articles.length === params.limit; // There may be more
params.offset += batch.articles.length; // Increment the pagination
if ( !result ) result = batch; // Store the 1st result
else result.articles = result.articles.concat(batch.articles); // Append additional results
}
// Return the complete result
return result;
}
/* -------------------------------------------- */
......@@ -147,9 +162,9 @@ export default class WorldAnvil {
* @return {Promise<object>} An array of Article objects
*/
async getWorld(worldId) {
worldId = worldId || this.worldId;
if ( !worldId ) throw new Error("You must first identify a World Id to integrate with");
const world = await this._fetch(`world/${worldId}`);
if ( worldId !== undefined ) this.worldId = worldId;
const world = await this._fetch(`world/${this.worldId}`);
this.worldId = worldId;
return this.world = world;
}
}
......@@ -10,7 +10,8 @@ export default class WorldAnvilConfig extends FormApplication {
id: "world-anvil-config",
template: "modules/world-anvil/templates/config.html",
width: 600,
height: "auto"
height: "auto",
closeOnSubmit: false
});
}
......@@ -26,8 +27,30 @@ export default class WorldAnvilConfig extends FormApplication {
/** @override */
async getData() {
const anvil = game.modules.get("world-anvil").anvil;
if ( !anvil.worlds.length ) await anvil.getWorlds();
// Determine the configuration step
let stepNumber = 0;
let stepLabel = "WA.ConfigureStep3";
if ( !anvil.user ) {
stepLabel = "WA.ConfigureStep1";
stepNumber = 1;
}
else if ( !anvil.worldId ) {
stepLabel = "WA.ConfigureStep2";
stepNumber = 2;
}
else stepNumber = 3;
// If we have reached step 3, we can safely close the form when it is submitted
if ( stepNumber === 3 ) this.options.closeOnSubmit = true;
// Maybe retrieve a list of world options
if ( anvil.user && !anvil.worlds.length ) await anvil.getWorlds();
// Return the template data for rendering
return {
stepLabel: stepLabel,
displayWorldChoices: stepNumber >= 2,
worlds: anvil.worlds,
worldId: anvil.worldId,
authToken: anvil.authToken
......@@ -38,9 +61,7 @@ export default class WorldAnvilConfig extends FormApplication {
/** @override */
_updateObject(event, formData) {
for ( let [k, v] of Object.entries(formData) ) {
game.settings.set("world-anvil", k, v);
}
game.settings.set("world-anvil", "configuration", formData);
}
/* -------------------------------------------- */
......@@ -61,32 +82,18 @@ export default class WorldAnvilConfig extends FormApplication {
});
// Auth User Key
game.settings.register("world-anvil", "authToken", {
name: "WA.UserToken",
hint: "WA.UserTokenHint",
game.settings.register("world-anvil", "configuration", {
scope: "world",
config: false,
default: null,
type: String,
onChange: token => {
type: Object,
onChange: async c => {
const anvil = game.modules.get("world-anvil").anvil;
if ( token !== anvil.authToken ) anvil.connect(token);
if ( c.authToken !== anvil.authToken ) await anvil.connect(c.authToken);
if ( c.worldId !== anvil.worldId ) await anvil.getWorld(c.worldId);
const app = Object.values(ui.windows).find(a => a.constructor === WorldAnvilConfig);
if ( app ) app.render();
}
});
// Associated World ID
game.settings.register("world-anvil", "worldId", {
name: "WA.WorldId",
hint: "WA.WorldIdHint",
scope: "world",
config: false,
default: null,
type: String,
onChange: worldId => {
const anvil = game.modules.get("world-anvil").anvil;
anvil.worldId = worldId;
anvil.getWorld(worldId);
}
})
}
}
\ No newline at end of file
......@@ -4,7 +4,7 @@
* @param {JournalEntry|null} entry An existing Journal Entry to sync
* @return {Promise<JournalEntry>}
*/
export async function importArticle(articleId, entry=null) {
export async function importArticle(articleId, {entry=null, renderSheet=false}={}) {
const anvil = game.modules.get("world-anvil").anvil;
const article = await anvil.getArticle(articleId);
const folder = game.folders.find(f => f.getFlag("world-anvil", "categoryId") === article.category.id);
......@@ -12,11 +12,13 @@ export async function importArticle(articleId, entry=null) {
// Update an existing Journal Entry
if ( entry ) {
return entry.update({
await entry.update({
name: article.title,
content: content.html,
img: content.img
});
ui.notifications.info(`Refreshed World Anvil article ${article.title}`);
return entry;
}
// Create a new Journal Entry
......@@ -26,7 +28,7 @@ export async function importArticle(articleId, entry=null) {
img: content.img,
folder: folder ? folder.id : null,
"flags.world-anvil.articleId": article.id
});
}, {renderSheet});
ui.notifications.info(`Imported World Anvil article ${article.title}`);
return entry;
}
......@@ -42,12 +44,11 @@ export async function importArticle(articleId, entry=null) {
* @private
*/
function _getArticleContent(article) {
let content = `<h1>${article.title}</h1>\n<p>${article.content_parsed}</p><hr/>`;
let content = `<h1>${article.title}</h1>\n<p><a href="${article.url}" title="${article.title} on World Anvil" target="_blank">${article.url}</a></p>\n<p>${article.content_parsed}</p><hr/>`;
if ( article.sections ) {
for ( let [s, c] of Object.entries(article.sections) ) {
if ( s.endsWith("_parsed") ) {
content += `<h2>${s.replace("_parsed", "").titleCase()}</h2>\n<p>${c}</p><hr/>`;
}
for ( let [id, section] of Object.entries(article.sections) ) {
let title = section.title || id.titleCase();
content += `<h2>${title}</h2>\n<p>${section.content_parsed}</p><hr/>`;
}
}
......@@ -70,6 +71,11 @@ function _getArticleContent(article) {
image = image || img;
});
// World Anvil Content Links
div.querySelectorAll('span[data-article-id]').forEach(s => {
s.classList.add("entity-link", "wa-link");
});
// Regex formatting
let html = div.innerHTML;
html = html.replace(/%p%/g, "</p>\n<p>");
......
......@@ -3,7 +3,7 @@ import {importArticle} from "./framework.js";
/**
* A World Anvil Directory that allows you to see and manage your World Anvil content in Foundry VTT
*/
export default class WorldAnvilJournal extends Application {
export default class WorldAnvilBrowser extends Application {
/** @override */
static get defaultOptions() {
......@@ -35,7 +35,7 @@ export default class WorldAnvilJournal extends Application {
/** @override */
async getData() {
const world = this.anvil.world || await this.anvil.getWorld();
const world = this.anvil.world || await this.anvil.getWorld(this.anvil.worldId);
const categories = await this.getArticleCategories();
return {
world: world,
......@@ -125,13 +125,13 @@ export default class WorldAnvilJournal extends Application {
"flags.world-anvil.categoryId": category.id
});
for ( let a of category.articles ) {
await importArticle(a.id);
await importArticle(a.id, {renderSheet: false});
}
break;
case "browse-folder":
break;
case "link-entry":
return await importArticle(button.dataset.articleId);
return await importArticle(button.dataset.articleId, {renderSheet: true});
case "browse-entry":
const entry = game.journal.get(button.dataset.entryId);
entry.sheet.render(true);
......
<form>
<p class="notification info">{{localize stepLabel}}</p>
<div class="form-group">
<label>{{localize "WA.UserToken"}}</label>
<input type="text" name="authToken" value="{{authToken}}"/>
<p class="notes">{{localize "WA.UserTokenHint"}}</p>
</div>
{{#if displayWorldChoices }}
<div class="form-group">
<label>{{localize "WA.WorldId"}}</label>
<select name="worldId">
......@@ -17,6 +20,7 @@
</select>
<p class="notes">{{localize "WA.WorldIdHint"}}</p>
</div>
{{/if}}
<button type="submit">{{localize "Save Changes"}}</button>
</form>
\ No newline at end of file
/** --------------------------------------------
Global Styling Rules
--------------------------------------------- */
button#world-anvil {
flex: 0 0 32px;
}
button#world-anvil img {
position: relative;
top: 4px;
border: none;
}
.wa-link {
padding: 1px 4px 1px 18px;
margin: 0;
white-space: nowrap;
word-break: break-all;
background: #DDD;
background-image: url(icons/wa-icon.svg);
background-repeat: no-repeat;
background-size: 14px 14px;
background-position: 2px 1px;
border: 1px solid #444;
border-radius: 2px;
}
.wa-link:hover {
text-shadow: 0 0 4px red;
cursor: pointer;
}
/** --------------------------------------------
Application Styling Rules
--------------------------------------------- */
.world-anvil .world-description {
max-height: 200px;
overflow-y: auto;
}
.world-anvil header {
display: flex;
flex-direction: row;
......@@ -37,7 +62,6 @@ button#world-anvil img {
text-align: right;
}
.world-anvil .world-anvil-link {
flex: 1;
margin: 0;
......
import WorldAnvil from "./module/api.js";
import WorldAnvilConfig from "./module/config.js";
import WorldAnvilJournal from "./module/journal.js";
import WorldAnvilBrowser from "./module/journal.js";
import {importArticle} from "./module/framework.js";
/**
* Initialization actions taken on Foundry Virtual Tabletop client init.
......@@ -39,13 +41,15 @@ Hooks.once("ready", () => {
* Add the World Anvil configuration button to the Journal Directory
*/
Hooks.on("renderJournalDirectory", (app, html, data) => {
// Add the World Anvil Button
const button = $(`<button type="button" id="world-anvil">
<img src="modules/world-anvil/icons/wa-icon.svg" title="${game.i18n.localize("WA.SidebarButton")}"/>
</button>`);
button.on("click", ev => {
const anvil = game.modules.get("world-anvil").anvil;
if ( anvil.worldId ) {
const journal = new WorldAnvilJournal();
const journal = new WorldAnvilBrowser();
journal.render(true);
} else {
const config = new WorldAnvilConfig();
......@@ -53,7 +57,41 @@ Hooks.on("renderJournalDirectory", (app, html, data) => {
}
});
html.find(".directory-footer").append(button);
// Re-render the browser, if it's active
const browser = Object.values(ui.windows).find(a => a.constructor === WorldAnvilBrowser);
if ( browser ) browser.render(false);
});
/* -------------------------------------------- */
/**
* Augment rendered Journal sheets to add WorldAnvil content
*/
Hooks.on("renderJournalSheet", (app, html, data) => {
const entry = app.object;
const articleId = entry.getFlag("world-anvil", "articleId");
if ( !articleId ) return;
// Add header button to re-sync
let title = html.find(".window-title");
if ( title ) {
const sync = $(`<a class="wa-sync"><i class="fas fa-sync"></i>WA Sync</a>`);
sync.on("click", event => {
event.preventDefault();
importArticle(articleId, {entry});
});
title.after(sync);
}
// Activate cross-link listeners
html.find(".wa-link").click(event => {
event.preventDefault();
const articleId = event.currentTarget.dataset.articleId;
const entry = game.journal.find(e => e.getFlag("world-anvil", "articleId") === articleId);
if ( entry ) entry.sheet.render(true);
else importArticle(articleId, {renderSheet: true});
});
});
\ No newline at end of file
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment