diff --git a/README.md b/README.md index c59fbe6..302d9cf 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ This module should warn you starting a week before it expires. - **Campaign**: After entering a valid Access Token you should receive a list of all Kanka campaigns you have access to. You must select the campaign you would like to import data from. You can always change the campaign later to import entries from another campaign without loosing what you have already imported. +- **Create folder tree**: Most entries in Kanka can be organized hierarchically. If this option is selected the module +will create folders to replicate this hierarchy. Foundry has a limit of 3 folders levels, thus everything on a lower +level will be flattened to this 3rd level instead. - **Include image in text**: When importing an entry from Kanka it will use the entries main image as the journal entries image. With this setting the image will additionally be displayed in the journal entry next to its text (see screenshot bellow). @@ -36,8 +39,10 @@ be refreshed from Kanka until this setting has been enabled again. - **Basic metadata import**: This setting allows you to define which *basic* metadata should be imported. *Basic* metadata includes properties like the *type* given to most entries in Kanka, *age* for characters and other basic properties. -- **Attributes import**: The module will import attributes on any Kanka entry as metadata. This settings allows you to +- **Attributes import**: The module will import attributes on any Kanka entry as metadata. This setting allows you to broadly define which attributes to import this way. +- **Inventory import**: The module will import inventory for all entries as metadata. This setting allows you to control +which inventory entries will be imported. - **Character trait import**: Characters in Kanka have appearance and personality traits. This setting allows you to select which of those traits you would like to import. - **Quest reference import**: Quests can contain references to Characters, Locations and other entries. This setting diff --git a/src/hooks/renderJournalSheet.ts b/src/hooks/renderJournalSheet.ts index 61964d5..e469828 100644 --- a/src/hooks/renderJournalSheet.ts +++ b/src/hooks/renderJournalSheet.ts @@ -32,7 +32,7 @@ export default async function renderJournalSheet( // Workaround for missing locale in links const parsedContent = contentElement.html().replace( 'https://kanka.io/campaign', - `https://kanka.io/${campaign.locale}/campaign`, + `https://kanka.io/${campaign.locale ?? 'en'}/campaign`, ); contentElement.html(parsedContent); diff --git a/src/kanka/entities/Ability.ts b/src/kanka/entities/Ability.ts index d4801c2..144b5e2 100644 --- a/src/kanka/entities/Ability.ts +++ b/src/kanka/entities/Ability.ts @@ -8,6 +8,14 @@ export default class Ability extends PrimaryEntity { return EntityType.ability; } + get treeParentId(): number | undefined { + return this.data.ability_id; + } + + async treeParent(): Promise { + return this.findReference(this.parent.abilities(), this.treeParentId); + } + public get type(): string | undefined { return this.data.type; } diff --git a/src/kanka/entities/Campaign.ts b/src/kanka/entities/Campaign.ts index dbb9a67..f4889fd 100644 --- a/src/kanka/entities/Campaign.ts +++ b/src/kanka/entities/Campaign.ts @@ -31,6 +31,10 @@ export default class Campaign extends PrimaryEntity { return EntityType.campaign; } + public abilities(): EntityCollection { + return this.#abilities; + } + public characters(): EntityCollection { return this.#characters; } @@ -39,6 +43,10 @@ export default class Campaign extends PrimaryEntity { return this.#items; } + public journals(): EntityCollection { + return this.#journals; + } + public locations(): EntityCollection { return this.#locations; } @@ -55,6 +63,14 @@ export default class Campaign extends PrimaryEntity { return this.#families; } + public quests(): EntityCollection { + return this.#quests; + } + + public notes(): EntityCollection { + return this.#notes; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any public getByType(type: string): EntityCollection | undefined { switch (type) { diff --git a/src/kanka/entities/Family.ts b/src/kanka/entities/Family.ts index d6be710..fd77516 100644 --- a/src/kanka/entities/Family.ts +++ b/src/kanka/entities/Family.ts @@ -9,6 +9,14 @@ export default class Family extends PrimaryEntity { return EntityType.family; } + get treeParentId(): number | undefined { + return this.data.family_id; + } + + async treeParent(): Promise { + return this.findReference(this.parent.families(), this.treeParentId); + } + public get type(): string | undefined { return this.data.type; } diff --git a/src/kanka/entities/InventoryItem.ts b/src/kanka/entities/InventoryItem.ts index f495d52..5ff946e 100644 --- a/src/kanka/entities/InventoryItem.ts +++ b/src/kanka/entities/InventoryItem.ts @@ -12,6 +12,10 @@ export default class InventoryItem extends EntityBase { + if (!this.data.item_id) return undefined; return this.findReference(this.parent.parent.items(), this.data.item_id); } } diff --git a/src/kanka/entities/Journal.ts b/src/kanka/entities/Journal.ts index 09e7d66..4c88b8b 100644 --- a/src/kanka/entities/Journal.ts +++ b/src/kanka/entities/Journal.ts @@ -10,6 +10,14 @@ export default class Journal extends PrimaryEntity { return EntityType.journal; } + get treeParentId(): number | undefined { + return this.data.journal_id; + } + + async treeParent(): Promise { + return this.findReference(this.parent.journals(), this.treeParentId); + } + public get type(): string | undefined { return this.data.type; } diff --git a/src/kanka/entities/Location.ts b/src/kanka/entities/Location.ts index 05bd025..ae8c83c 100644 --- a/src/kanka/entities/Location.ts +++ b/src/kanka/entities/Location.ts @@ -12,6 +12,14 @@ export default class Location extends PrimaryEntity { return this.data.type; } + get treeParentId(): number | undefined { + return this.data.parent_location_id; + } + + async treeParent(): Promise { + return this.findReference(this.parent.locations(), this.treeParentId); + } + protected async buildMetaData(): Promise { await super.buildMetaData(); this.addMetaData({ label: 'type', value: this.type }); diff --git a/src/kanka/entities/Note.ts b/src/kanka/entities/Note.ts index 0d3348a..0cd85a6 100644 --- a/src/kanka/entities/Note.ts +++ b/src/kanka/entities/Note.ts @@ -8,6 +8,14 @@ export default class Note extends PrimaryEntity { return EntityType.note; } + get treeParentId(): number | undefined { + return this.data.note_id; + } + + async treeParent(): Promise { + return this.findReference(this.parent.notes(), this.treeParentId); + } + public get type(): string | undefined { return this.data.type; } diff --git a/src/kanka/entities/Organisation.ts b/src/kanka/entities/Organisation.ts index 1a68ba8..78de8a7 100644 --- a/src/kanka/entities/Organisation.ts +++ b/src/kanka/entities/Organisation.ts @@ -9,6 +9,14 @@ export default class Organisation extends PrimaryEntity { + return this.findReference(this.parent.organisations(), this.treeParentId); + } + public get type(): string | undefined { return this.data.type; } diff --git a/src/kanka/entities/PrimaryEntity.ts b/src/kanka/entities/PrimaryEntity.ts index 2f73ace..f962bae 100644 --- a/src/kanka/entities/PrimaryEntity.ts +++ b/src/kanka/entities/PrimaryEntity.ts @@ -24,6 +24,21 @@ export default abstract class PrimaryEntity< abstract get entityType(): EntityType; + get treeParentId(): number | undefined { + return undefined; + } + + async treeParent(): Promise | undefined> { + return undefined; + } + + async treeAncestors(): Promise[]> { + const parent = await this.treeParent(); + if (!parent) return []; + const path = await parent.treeAncestors(); + return [...path, parent]; + } + public get attributes(): EntityAttribute[] { return this.#attributes; } @@ -42,7 +57,7 @@ export default abstract class PrimaryEntity< public get image(): string | undefined { if (this.data.has_custom_image === false) return undefined; - return `https://kanka-user-assets.s3.eu-central-1.amazonaws.com/${this.data.image}`; + return this.data.image_full; } public get entry(): string { @@ -94,14 +109,20 @@ export default abstract class PrimaryEntity< this.inventory .forEach((inventory, index) => { + const label = `${inventory.amount} × ${items[index]?.name ?? inventory.name}`; + const value: string[] = []; + + if (inventory.isEquipped) value.push(''); + if (inventory.description) value.push(`${inventory.description}`); + this.addMetaData({ + label, type: MetaDataType.inventory, section: inventory.position || 'inventory', - label: items[index]?.name, - value: `${inventory.amount ? `${inventory.amount} × ` : ''}${inventory.name}${inventory.isEquipped ? '*' : ''}`, + value: value.join(' '), originalData: inventory, linkTo: items[index], - }); + }, true); }); } diff --git a/src/kanka/entities/Quest.ts b/src/kanka/entities/Quest.ts index b20e601..6ae1a72 100644 --- a/src/kanka/entities/Quest.ts +++ b/src/kanka/entities/Quest.ts @@ -34,6 +34,14 @@ export default class Quest extends PrimaryEntity { return EntityType.quest; } + get treeParentId(): number | undefined { + return this.data.quest_id; + } + + async treeParent(): Promise { + return this.findReference(this.parent.quests(), this.treeParentId); + } + public get type(): string | undefined { return this.data.type; } @@ -71,8 +79,8 @@ export default class Quest extends PrimaryEntity { await Promise.all([ this.addQuestReferenceMetaData(this.#characters.all(), 'characters'), this.addQuestReferenceMetaData(this.#locations.all(), 'locations'), - // this.addQuestReferenceMetaData(this.#items.all(), 'items'), - // this.addQuestReferenceMetaData(this.#organisations.all(), 'organisations'), + this.addQuestReferenceMetaData(this.#items.all(), 'items'), + this.addQuestReferenceMetaData(this.#organisations.all(), 'organisations'), ]); } @@ -90,6 +98,6 @@ export default class Quest extends PrimaryEntity { type: MetaDataType.questReference, originalData: reference, linkTo: entities[index], - })); + }, true)); } } diff --git a/src/kanka/entities/Race.ts b/src/kanka/entities/Race.ts index 3cd3203..2aea125 100644 --- a/src/kanka/entities/Race.ts +++ b/src/kanka/entities/Race.ts @@ -8,6 +8,14 @@ export default class Race extends PrimaryEntity { return EntityType.race; } + get treeParentId(): number | undefined { + return this.data.race_id; + } + + async treeParent(): Promise { + return this.findReference(this.parent.races(), this.treeParentId); + } + public get type(): string | undefined { return this.data.type; } diff --git a/src/lang/de.json b/src/lang/de.json index 8ddba7f..edabf66 100644 --- a/src/lang/de.json +++ b/src/lang/de.json @@ -42,13 +42,17 @@ "KANKA.SettingsMetaDataInventoryVisibility.value.none": "Keine Inventareinträge", "KANKA.SettingsImageInText.label": "Bild neben Text", "KANKA.SettingsImageInText.hint": "Soll das Bild eines Eintrags neben dem Text angezeigt werden?", + "KANKA.SettingsKeepTreeStructure.label": "Ordnerstruktur nachbilden", + "KANKA.SettingsKeepTreeStructure.hint": "Soll Kankas Baumstruktur über Ordner nachgestellt werden? Die maximale Tiefe beträgt 3 Ordner.", "KANKA.SettingsEntityTypeVisibility.hint": "Soll diese Kategorie geladen werden? Eine reduzierte Anzahl von Kategorien reduziert auch die Anzahl der Anfragen an Kanka und kann somit die Ladezeiten verbessern.", "KANKA.BrowserLinkFolder": "Alle verbinden", "KANKA.BrowserRefreshFolder": "Verbundene aktualisieren", "KANKA.BrowserNotificationSyncedFolder": "Alle Kanka-Einträge vom Typ {type} wurden aktualisert.", "KANKA.BrowserNotificationLinkedFolder": "Alle Kanka-Einträge vom Typ {type} wurden verbunden.", + "KANKA.BrowserNotificationSynced": "Eintrag {name} wurde verbunden.", "KANKA.BrowserNotificationRefreshed": "Eintrag {name} wurde aktualisiert.", "KANKA.BrowserSyncError": "Unerwarteter Fehler beim aktualisieren.", + "KANKA.JournalFolder.root": "Kanka", "KANKA.EntityType.ability": "Fähigkeiten", "KANKA.EntityType.character": "Charaktere", "KANKA.EntityType.family": "Familien", diff --git a/src/lang/en.json b/src/lang/en.json index 68567d5..4f471ca 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -36,19 +36,22 @@ "KANKA.SettingsMetaDataQuestReferenceVisibility.value.public": "All public quest references", "KANKA.SettingsMetaDataQuestReferenceVisibility.value.none": "No quest references", "KANKA.SettingsMetaDataInventoryVisibility.label": "Inventory import", - "KANKA.SettingsMetaDataInventoryVisibility.hint": "Which inventory entries should be imported as metadata.", "KANKA.SettingsMetaDataInventoryVisibility.value.all": "All inventory entries", "KANKA.SettingsMetaDataInventoryVisibility.value.public": "All public inventory entries", "KANKA.SettingsMetaDataInventoryVisibility.value.none": "No inventory entries", "KANKA.SettingsImageInText.label": "Include image in text", "KANKA.SettingsImageInText.hint": "Should the image be include next to the text in addition to the regular journal entry image?", + "KANKA.SettingsKeepTreeStructure.label": "Create folder tree", + "KANKA.SettingsKeepTreeStructure.hint": "Should Kankas tree structure be replicated via folders? The maximum depth is 3 folders.", "KANKA.SettingsEntityTypeVisibility.hint": "Should this type of entity be loaded? Reducing the types of entity also reduces the number of requests made to Kanka and thus improves the performance.", "KANKA.BrowserLinkFolder": "Link all", "KANKA.BrowserRefreshFolder": "Refresh linked", "KANKA.BrowserNotificationSyncedFolder": "Refreshed all Kanka entities of type {type}.", "KANKA.BrowserNotificationLinkedFolder": "Linked all Kanka entities of type {type}.", + "KANKA.BrowserNotificationSynced": "Linked entity {name}.", "KANKA.BrowserNotificationRefreshed": "Updated entity {name}.", "KANKA.BrowserSyncError": "Unexpected error while syncing.", + "KANKA.JournalFolder.root": "Kanka", "KANKA.EntityType.ability": "Abilities", "KANKA.EntityType.character": "Characters", "KANKA.EntityType.family": "Families", diff --git a/src/module/KankaBrowser.ts b/src/module/KankaBrowser.ts index 038a471..656a088 100644 --- a/src/module/KankaBrowser.ts +++ b/src/module/KankaBrowser.ts @@ -5,7 +5,7 @@ import moduleConfig from '../module.json'; import EntityType from '../types/EntityType'; import { kankaImportTypeSetting, KankaSettings } from '../types/KankaSettings'; import getSetting from './getSettings'; -import { ensureJournalFolder, findEntriesByType, findEntryByEntity, findEntryByEntityId, writeJournalEntry } from './journal'; +import { findEntriesByType, findEntryByEntity, findEntryByEntityId, writeJournalEntry } from './journal'; interface EntityList { items: PrimaryEntity[]; @@ -219,6 +219,7 @@ export default class KankaBrowser extends Application { const entity = await campaign.getByType(type)?.byId(Number(id)); if (!entity) return; await this.syncEntity(entity, action === 'link-entry'); + this.render(); break; } @@ -232,11 +233,13 @@ export default class KankaBrowser extends Application { case 'sync-folder': if (!type) return; await this.syncFolder(type); + this.render(); break; case 'link-folder': { if (!type) return; await this.linkFolder(type); + this.render(); break; } @@ -255,10 +258,8 @@ export default class KankaBrowser extends Application { return; } - await ensureJournalFolder(type); - const linkedEntities = entities.filter(entity => !!findEntryByEntity(entity)); - await Promise.all(linkedEntities.map(entity => this.syncEntity(entity, false, false))); + await this.syncEntities(linkedEntities); this.showInfo('BrowserNotificationSyncedFolder', { type }); this.render(); } @@ -271,17 +272,21 @@ export default class KankaBrowser extends Application { return; } - await ensureJournalFolder(type); - const unlinkedEntities = entities.filter(entity => !findEntryByEntity(entity)); - await Promise.all(unlinkedEntities.map(entity => this.syncEntity(entity, false, false))); + await this.syncEntities(unlinkedEntities); this.showInfo('BrowserNotificationLinkedFolder', { type }); this.render(); } + private async syncEntities(entities: PrimaryEntity[]): Promise { + for (let i = 0; i < entities.length; i += 1) { + // eslint-disable-next-line no-await-in-loop + await this.syncEntity(entities[i], false, false); + } + } + private async syncEntity(entity: PrimaryEntity, renderSheet = false, notification = true): Promise { await writeJournalEntry(entity, { renderSheet, notification }); - this.render(); } private showInfo(msg: string, params?: Record): void { diff --git a/src/module/configureSettings.ts b/src/module/configureSettings.ts index 5f2b919..54813ae 100644 --- a/src/module/configureSettings.ts +++ b/src/module/configureSettings.ts @@ -136,6 +136,19 @@ export async function registerSettings(): Promise { }, ); + game.settings.register( + moduleConfig.name, + KankaSettings.keepTreeStructure, + { + name: game.i18n.localize('KANKA.SettingsKeepTreeStructure.label'), + hint: game.i18n.localize('KANKA.SettingsKeepTreeStructure.hint'), + scope: 'world', + config: true, + type: Boolean, + default: false, + }, + ); + game.settings.register( moduleConfig.name, KankaSettings.imageInText, diff --git a/src/module/journal.ts b/src/module/journal.ts index 4906d5e..5323022 100644 --- a/src/module/journal.ts +++ b/src/module/journal.ts @@ -3,6 +3,7 @@ import EntityMetaData from '../kanka/entities/EntityMetaData'; import InventoryItem from '../kanka/entities/InventoryItem'; import PrimaryEntity from '../kanka/entities/PrimaryEntity'; import QuestReference from '../kanka/entities/QuestReference'; +import { logInfo } from '../logger'; import moduleConfig from '../module.json'; import { CharacterTrait, Visibility } from '../types/kanka'; import { @@ -14,7 +15,7 @@ import { MetaDataQuestReferenceVisibility, MetaDataType, } from '../types/KankaSettings'; -import getSetting from './getSettings'; +import getSettings from './getSettings'; interface MetaData { label: string; @@ -49,22 +50,74 @@ export function findFolderByType(type: string): Folder | undefined { .find((f: Folder) => f.data.type === 'JournalEntry' && f.getFlag(moduleConfig.name, 'type') === type); } -export async function ensureJournalFolder(type: string): Promise { - let folder = findFolderByType(type); +export function findFolderByFlags(flags: Record): Folder | undefined { + const entries = Object.entries(flags); - if (!folder) { - const nameKey = `KANKA.EntityType.${type}`; - const name = game.i18n.localize(nameKey); + return game.folders + .find((f: Folder) => f.data.type === 'JournalEntry' + && entries.every(([flag, value]) => f.getFlag(moduleConfig.name, flag) === value)); +} - folder = await Folder.create({ - name: `[KANKA] ${name === nameKey ? type : name}`, - type: 'JournalEntry', - parent: null, - [`flags.${moduleConfig.name}.type`]: type, +function createJournalFolder(name: string, parent?: Folder, flags: Record = {}): Promise { + const data = { + name, + parent: parent ?? null, + type: 'JournalEntry', + }; + + Object + .entries(flags) + .forEach(([flag, value]) => { + data[`flags.${moduleConfig.name}.${flag}`] = value; }); + + logInfo('createJournalFolder()', data); + + return Folder.create(data); +} + +async function ensureTypeJournalFolder(type: string): Promise { + const folder = findFolderByFlags({ folderType: 'type', type }); + logInfo('ensureTypeJournalFolder()', folder?.name); + if (folder) return folder; + + const nameKey = `KANKA.EntityType.${type}`; + const name = game.i18n.localize(nameKey); + const folderName = name === nameKey ? type : name; + + return createJournalFolder(`[KANKA] ${folderName}`, undefined, { folderType: 'type', type }); +} + +async function ensureEntityJournalFolder(entity: PrimaryEntity, parent?: Folder): Promise { + const folder = findFolderByFlags({ folderType: 'entity', id: entity.id, type: entity.entityType }); + logInfo('ensureEntityJournalFolder() – create', entity.name, folder?.name, parent?.name); + if (folder) return folder; + + return createJournalFolder( + entity.name, + parent, + { folderType: 'entity', id: entity.id, type: entity.entityType, entityId: entity.entityId }, + ); +} + +async function ensureEntityJournalFolderPath(entity: PrimaryEntity): Promise { + if (!getSettings(KankaSettings.keepTreeStructure)) { + return ensureTypeJournalFolder(entity.entityType); + } + + const parent = await entity.treeParent(); + + if (!parent) { + return ensureTypeJournalFolder(entity.entityType); } - return folder; + const parentFolder = findFolderByFlags({ entityFolder: true, id: parent.id, type: parent.entityType }); + if (parentFolder) return parentFolder; + const grandParentFolder = await ensureEntityJournalFolderPath(parent); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + if (grandParentFolder.depth === 3) return grandParentFolder; + return ensureEntityJournalFolder(parent, grandParentFolder); } async function renderTemplate(path: string, params: Record): Promise { @@ -101,7 +154,7 @@ function checkSetting( setting: KankaSettings, results: Record boolean)>, ): boolean { - const settingValue = getSetting(setting) as string; + const settingValue = getSettings(setting) as string; const result = results[settingValue]; if (result === undefined) { @@ -223,7 +276,7 @@ export async function writeJournalEntry( { entity, metaData: await buildMetaData(entity), - includeImage: entity.image && getSetting(KankaSettings.imageInText), + includeImage: entity.image && getSettings(KankaSettings.imageInText), }, ); @@ -240,7 +293,7 @@ export async function writeJournalEntry( ui.notifications.info(game.i18n.format('KANKA.BrowserNotificationRefreshed', { type: entity.entityType, name: entity.name })); } } else { - const folder = await ensureJournalFolder(entity.entityType); + const folder = await ensureEntityJournalFolderPath(entity); entry = await JournalEntry.create({ ...journalData, folder: folder?.id, diff --git a/src/types/KankaSettings.ts b/src/types/KankaSettings.ts index c49e0d6..a9fd840 100644 --- a/src/types/KankaSettings.ts +++ b/src/types/KankaSettings.ts @@ -10,6 +10,7 @@ export enum KankaSettings { metaDataCharacterTraitVisibility = 'metaDataCharacterTraitVisibility', metaDataQuestReferenceVisibility = 'metaDataQuestReferenceVisibility', imageInText = 'imageInText', + keepTreeStructure = 'keepTreeStructure', } export function kankaImportTypeSetting(type: EntityType): KankaSettings { diff --git a/src/types/kanka.ts b/src/types/kanka.ts index 9ac5867..f1db19d 100644 --- a/src/types/kanka.ts +++ b/src/types/kanka.ts @@ -60,6 +60,7 @@ export interface KankaInventory { is_private: boolean; item_id: number; name: string; + description?: string; position?: string; visibility: Visibility; } @@ -158,6 +159,7 @@ export interface NoteData extends KankaEntityData { export interface JournalData extends KankaEntityData { type?: string; date?: string; + journal_id?: number; character_id?: number; location_id?: number; }