Skip to content

Commit 14b0ef9

Browse files
Current work on getting the wiki to be the source of truth.
1 parent 75a6b54 commit 14b0ef9

File tree

6 files changed

+358
-4
lines changed

6 files changed

+358
-4
lines changed

extensions/datamaps.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import dataMapsFormat from './includes/format.mjs';
22
import selectedObjectsChanged from './includes/popup.mjs';
33
import publishToWiki from './includes/publish.mjs';
4+
import pullFromWiki from './includes/pull.mjs';
45

56
tiled.registerMapFormat('dataMaps', dataMapsFormat);
67
const publishAction = tiled.registerAction('PublishToWiki', publishToWiki);
78
publishAction.text = 'Publish to wiki';
89
publishAction.icon = 'wiki.svg';
910
publishAction.shortcut = 'Ctrl+Shift+U';
11+
const pullAction = tiled.registerAction('PullFromWiki', pullFromWiki);
12+
pullAction.text = 'Pull from wiki';
1013
const enablePopup = tiled.registerAction('WikiMarkerPopup', () => {});
1114
enablePopup.checkable = true;
1215
enablePopup.checked = true;
@@ -16,6 +19,9 @@ enablePopup.shortcut = 'Ctrl+Shift+M';
1619
tiled.extendMenu('File', [
1720
{
1821
action: 'PublishToWiki'
22+
},
23+
{
24+
action: 'PullFromWiki'
1925
}
2026
]);
2127
tiled.extendMenu('Edit', [

extensions/includes/api.mjs

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { InterwikiDataImpl, MetadataImpl } from './metadata.mjs';
12
import { getStringProperty, getWikiUrl } from './util.mjs';
23

34
const USER_AGENT = `tiled-datamaps/1.0 (https://github.com/utdrwiki/maps; admin@undertale.wiki) tiled/${tiled.version}`;
@@ -28,13 +29,18 @@ export function getRestUrl(language = 'en') {
2829
* @param {(value: any|PromiseLike<any>) => void} resolve Promise
2930
* resolution function
3031
* @param {(reason: any?) => void} reject Promise rejection function
32+
* @param {boolean} isArrayBuffer Whether the request expects a binary response
3133
* @returns {() => void} Ready state change handler
3234
*/
33-
const readyStateChange = (xhr, resolve, reject) => () => {
35+
const readyStateChange = (xhr, resolve, reject, isArrayBuffer = false) => () => {
3436
if (xhr.readyState === XMLHttpRequest.DONE) {
3537
if (xhr.status === 200) {
3638
try {
37-
resolve(JSON.parse(xhr.responseText));
39+
if (isArrayBuffer) {
40+
resolve(xhr.response);
41+
} else {
42+
resolve(JSON.parse(xhr.responseText));
43+
}
3844
} catch (error) {
3945
reject(new Error(`Failed to parse response: ${xhr.responseText}`));
4046
}
@@ -159,3 +165,77 @@ export function edit(title, text, summary, accessToken, language = 'en') {
159165
})
160166
);
161167
}
168+
169+
/**
170+
* Retrieves all maps from the wiki.
171+
* @param {string} language Wiki language
172+
* @returns {Promise<DataMap[]>} List of maps on the wiki
173+
*/
174+
export function getAllMaps(language = 'en') {
175+
return httpGet(getApiUrl(language), {
176+
action: 'query',
177+
generator: 'allpages',
178+
gapnamespace: '2900',
179+
gapfilterredir: 'nonredirects',
180+
gaplimit: 'max',
181+
prop: 'revisions',
182+
rvprop: 'ids|content',
183+
rvslots: 'main',
184+
format: 'json',
185+
formatversion: '2',
186+
}).then(data => data.query.pages
187+
.filter((/** @type {any} */ page) =>
188+
page.revisions &&
189+
page.revisions.length > 0 &&
190+
page.revisions[0].slots &&
191+
page.revisions[0].slots.main &&
192+
page.revisions[0].slots.main.contentmodel === 'datamap'
193+
)
194+
.map((/** @type {any} */ page) => {
195+
const {slots, revid} = page.revisions[0];
196+
const /** @type {DataMap} */ datamap = JSON.parse(slots.main.content);
197+
datamap.custom = datamap.custom || new MetadataImpl();
198+
datamap.custom.interwiki = datamap.custom.interwiki || {};
199+
datamap.custom.interwiki[language] = new InterwikiDataImpl({
200+
mapName: page.title.split(':').slice(1).join(':'),
201+
});
202+
datamap.custom.interwiki[language].revision = revid;
203+
return datamap;
204+
})
205+
.filter((/** @type {DataMap} */ datamap) => !datamap.$fragment)
206+
);
207+
}
208+
209+
/**
210+
* Returns the URLs of the given map files on the wiki.
211+
* @param {string[]} filenames Map file names
212+
* @param {string} language Wiki language
213+
* @returns {Promise<string[]>} URLs of the given map files on the wiki
214+
*/
215+
export function getFileUrls(filenames, language = 'en') {
216+
return httpGet(getApiUrl(language), {
217+
action: 'query',
218+
titles: filenames.map(name => `File:${name}`).join('|'),
219+
prop: 'imageinfo',
220+
iiprop: 'url',
221+
format: 'json',
222+
formatversion: '2'
223+
}).then(data => data.query.pages
224+
.map((/** @type {any} **/ page) => page.imageinfo[0].url));
225+
}
226+
227+
/**
228+
* Downloads a file from a URL and returns it as an ArrayBuffer.
229+
* @param {string} url URL to download the file from
230+
* @returns {Promise<ArrayBuffer>} Downloaded file data
231+
*/
232+
export function downloadFile(url) {
233+
return new Promise((resolve, reject) => {
234+
const xhr = new XMLHttpRequest();
235+
xhr.open('GET', url, true);
236+
xhr.responseType = 'arraybuffer';
237+
xhr.onreadystatechange = readyStateChange(xhr, resolve, reject, true);
238+
xhr.setRequestHeader('User-Agent', USER_AGENT);
239+
xhr.send();
240+
});
241+
}

extensions/includes/format.mjs

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ import {
66
getColorProperty,
77
getListProperty,
88
getNumberProperty,
9-
getStringProperty
9+
getStringProperty,
10+
getTiledColor,
11+
isBoxOverlay,
12+
isImageBackground,
13+
isPolylineOverlay,
14+
validateTiledPoint,
15+
validateTiledRectangle
1016
} from './util.mjs';
1117

1218
/**
@@ -221,6 +227,97 @@ export function convertTiledToDataMaps(map, mapName, language = 'en') {
221227
return datamap;
222228
}
223229

230+
const TILE_SIZE = 32;
231+
232+
/**
233+
* Converts a DataMaps map to the Tiled map format.
234+
* @param {DataMap} datamap DataMap to convert
235+
* @returns {TileMap} Converted Tiled map object
236+
*/
237+
export function convertDataMapsToTiled(datamap) {
238+
const primaryBg = datamap.backgrounds.find(isImageBackground);
239+
if (!primaryBg) {
240+
throw new Error('At least one background with an image is required to convert to Tiled format');
241+
}
242+
const crsBR = validateTiledPoint(datamap.crs.bottomRight);
243+
const mapWidth = Math.ceil(crsBR[0] / TILE_SIZE);
244+
const mapHeight = Math.ceil(crsBR[1] / TILE_SIZE);
245+
const map = new TileMap();
246+
map.tileWidth = TILE_SIZE;
247+
map.tileHeight = TILE_SIZE;
248+
map.width = mapWidth;
249+
map.height = mapHeight;
250+
if (datamap.disclaimer) {
251+
map.setProperty('disclaimer', datamap.disclaimer);
252+
}
253+
if (datamap.settings && datamap.settings.leaflet) {
254+
map.setProperty('popzoom', datamap.settings.leaflet.uriPopupZoom);
255+
}
256+
if (datamap.include) {
257+
map.setProperty('include', datamap.include.join('\n'));
258+
}
259+
const /** @type {[string, ImageLayer][]} */ imageFiles = [];
260+
for (const bg of datamap.backgrounds.filter(isImageBackground)) {
261+
const layer = new ImageLayer();
262+
// TODO: Support custom image property
263+
layer.name = bg.image.replace(/\.png$/, '');
264+
imageFiles.push([bg.image, layer]);
265+
map.addLayer(layer);
266+
}
267+
let annotationLayer = new ObjectGroup('annotations');
268+
map.addLayer(annotationLayer);
269+
for (const overlay of (primaryBg.overlays || [])) {
270+
const obj = new MapObject(overlay.name);
271+
if (isBoxOverlay(overlay)) {
272+
obj.shape = MapObject.Rectangle;
273+
const [[x1, y1], [x2, y2]] = validateTiledRectangle(overlay.at);
274+
obj.pos = { x: x1, y: y1 };
275+
obj.width = x2 - x1;
276+
obj.height = y2 - y1;
277+
if (overlay.color) {
278+
obj.setProperty('fill', getTiledColor(overlay.color));
279+
}
280+
if (overlay.borderColor) {
281+
obj.setProperty('border', getTiledColor(overlay.borderColor));
282+
}
283+
} else if (isPolylineOverlay(overlay)) {
284+
obj.polygon = overlay.path
285+
.map(p => validateTiledPoint(p))
286+
.map(p => ({ x: p[1], y: p[0] }));
287+
obj.pos = { x: 0, y: 0 };
288+
obj.shape = MapObject.Polyline;
289+
if (overlay.color) {
290+
obj.setProperty('color', getTiledColor(overlay.color));
291+
}
292+
if (overlay.thickness) {
293+
obj.setProperty('thickness', overlay.thickness);
294+
}
295+
}
296+
annotationLayer.addObject(obj);
297+
}
298+
// TODO: Support nested layers
299+
for (const [layerName, markers] of Object.entries(datamap.markers).reverse()) {
300+
const layer = new ObjectGroup(layerName);
301+
for (const m of markers) {
302+
const obj = new MapObject(m.name);
303+
obj.pos = {
304+
x: m.x,
305+
y: m.y
306+
};
307+
obj.shape = MapObject.Point;
308+
obj.setProperties({
309+
page: m.article,
310+
description: m.description,
311+
image: m.image,
312+
plain: m.isWikitext === undefined ? undefined : !m.isWikitext
313+
});
314+
layer.addObject(obj);
315+
}
316+
map.addLayer(layer);
317+
}
318+
return map;
319+
}
320+
224321
/**
225322
* Converts a Tiled map to DataMaps format.
226323
* @param {TileMap} map Tiled map to convert to DataMaps
@@ -235,8 +332,22 @@ function write(map, filePath) {
235332
file.commit();
236333
}
237334

335+
/**
336+
* Converts a DataMaps map to the Tiled map format.
337+
* @param {string} filePath Path to the file with the DataMaps map
338+
* @returns {TileMap} Tiled map
339+
*/
340+
function read(filePath) {
341+
const file = new TextFile(filePath, TextFile.ReadOnly);
342+
const content = file.readAll();
343+
file.close();
344+
const datamap = JSON.parse(content);
345+
return convertDataMapsToTiled(datamap.en);
346+
}
347+
238348
export default /** @type {ScriptedMapFormat} */ {
239349
extension: 'mw-datamaps',
240350
name: 'DataMaps',
351+
read,
241352
write
242353
};

extensions/includes/pull.mjs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { downloadFile, getAllMaps, getFileUrls } from "./api.mjs";
2+
import { getLanguageCodes } from "./language.mjs";
3+
import { isImageBackground } from "./util.mjs";
4+
5+
export default function run() {
6+
const languages = getLanguageCodes();
7+
Promise.all(languages
8+
.map(language => getAllMaps(language)))
9+
.then(maps => {
10+
/** @type {Record<string, Record<string, DataMap>>} */
11+
const allMaps = {};
12+
const /** @type {string[]} */ backgrounds = [];
13+
for (const [index, language] of languages.entries()) {
14+
for (const map of maps[index]) {
15+
const mapName = map.custom?.interwiki?.en?.mapName;
16+
if (!mapName) {
17+
tiled.log(`Map "${map.custom?.interwiki?.[language].mapName}" is missing an English map name in its metadata, skipping it.`);
18+
continue;
19+
}
20+
allMaps[mapName] = allMaps[mapName] || {};
21+
allMaps[mapName][language] = map;
22+
backgrounds.push(...map.backgrounds
23+
.filter(isImageBackground)
24+
.map(bg => bg.image));
25+
}
26+
}
27+
const baseDir = FileInfo.path(tiled.projectFilePath);
28+
for (const map of Object.values(allMaps)) {
29+
const mapName = map.en.custom?.interwiki?.en.mapName;
30+
if (!mapName) {
31+
continue;
32+
}
33+
const textFile = new TextFile(
34+
FileInfo.joinPaths(baseDir, `${mapName}.mw-datamaps`),
35+
TextFile.WriteOnly
36+
);
37+
textFile.write(JSON.stringify(map));
38+
textFile.commit();
39+
}
40+
const backgroundFiles = [...new Set(backgrounds)];
41+
return getFileUrls(backgroundFiles, 'en')
42+
.then(urls => Promise.all(urls.map(url => downloadFile(url))))
43+
.then(files => files.forEach((file, index) => {
44+
const filename = backgroundFiles[index];
45+
const binaryFile = new BinaryFile(
46+
FileInfo.joinPaths(baseDir, 'images', filename),
47+
BinaryFile.WriteOnly
48+
);
49+
binaryFile.write(file);
50+
binaryFile.commit();
51+
}));
52+
})
53+
.catch(error => {
54+
tiled.alert(`An error occurred while pulling maps from the wiki: ${error.message}`);
55+
tiled.log(`Error details: ${error.stack}`);
56+
});
57+
}

0 commit comments

Comments
 (0)