Skip to content

Commit

Permalink
Complete working TS port
Browse files Browse the repository at this point in the history
  • Loading branch information
gilmoreg committed Dec 30, 2018
1 parent 311c596 commit 1f092b6
Show file tree
Hide file tree
Showing 12 changed files with 2,691 additions and 1,742 deletions.
1,078 changes: 649 additions & 429 deletions douki.user.js

Large diffs are not rendered by default.

2,931 changes: 1,810 additions & 1,121 deletions package-lock.json

Large diffs are not rendered by default.

14 changes: 8 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@
"devDependencies": {
"@types/node": "^7.0.29",
"@types/pad": "^1.0.0",
"@types/webpack": "^2.2.15",
"@types/tapable": "^1.0.4",
"@types/webpack": "^4.4.22",
"css-loader": "^0.28.4",
"jsonschema": "^1.1.1",
"pad": "^1.1.0",
"style-loader": "^0.18.2",
"ts-loader": "^2.1.0",
"ts-node": "^3.0.6",
"typescript": "^2.3.4",
"webpack": "^2.6.1"
"ts-loader": "^5.3.2",
"ts-node": "^7.0.1",
"typescript": "^2.9.2",
"webpack": "^4.28.3",
"webpack-cli": "^3.1.2"
}
}
}
80 changes: 3 additions & 77 deletions src/Anilist.ts
Original file line number Diff line number Diff line change
@@ -1,79 +1,5 @@
import * as Log from './Log';

/*
Anilist response is as follows:
data: {
anime: {
lists: [
{
entries: [
{entry}
]
}
]
},
manga: {
lists: [
{
entries: [
{entry}
]
}
]
},
}
*/

type AnilistDate = {
year: number
month: number
day: number
}

type BaseEntry = {
status: string
score: number
progress: number
progressVolumes: number
startedAt: AnilistDate
completedAt: AnilistDate
repeat: number
}

type Entry = BaseEntry & {
media: {
idMal: number
title: {
romaji: string
}
}
}

type MediaList = {
entries: Array<Entry>
[key: string]: Array<Entry>
}

type MediaListCollection = {
lists: MediaList
}

type AnilistResponse = {
anime: MediaListCollection
manga: MediaListCollection
[key: string]: MediaListCollection
}

type FormattedEntry = BaseEntry & {
type: string
id: number
title: string
}

type DoukiAnilistData = {
anime: Array<FormattedEntry>
manga: Array<FormattedEntry>
}
import { AnilistEntry, MediaList, FormattedEntry, DoukiAnilistData } from './Types';

const flatten = (obj: MediaList) =>
// Outer reduce concats arrays built by inner reduce
Expand All @@ -83,7 +9,7 @@ const flatten = (obj: MediaList) =>
// @ts-ignore
acc2.concat(obj[list][item]), [])), []);

const uniqify = (arr: Array<Entry>) => {
const uniqify = (arr: Array<AnilistEntry>) => {
const seen = new Set();
return arr.filter(item => (seen.has(item.media.idMal) ? false : seen.add(item.media.idMal)));
};
Expand Down Expand Up @@ -169,7 +95,7 @@ const fetchList = (userName: string) =>
manga: uniqify(flatten(res.manga.lists)),
}));

const sanitize = (item: Entry, type: string): FormattedEntry => ({
const sanitize = (item: AnilistEntry, type: string): FormattedEntry => ({
type,
progress: item.progress,
progressVolumes: item.progressVolumes,
Expand Down
25 changes: 23 additions & 2 deletions src/Dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export const addImportForm = (syncFn: Function) => {
const addImportFormEventListeners = (syncFn: Function) => {
const importButton = document.querySelector(id(DOUKI_IMPORT_BUTTON_ID));
importButton && importButton.addEventListener('click', function (e) {
syncFn();
syncFn(e);
});

const textBox = document.querySelector(id(ANILIST_USERNAME_ID)) as HTMLInputElement;
Expand Down Expand Up @@ -124,5 +124,26 @@ const setLocalStorageSetting = (setting: string, value: string) => {

export const getDateSetting = (): string => {
const dateSetting = document.querySelector(id(DATE_SETTING_ID)) as HTMLSelectElement;
return dateSetting && dateSetting.value;
if (!dateSetting) throw new Error('Unable to get date setting');
return dateSetting.value;
}

export const getCSRFToken = (): string => {
const csrfTokenMeta = document.querySelector('meta[name~="csrf_token"]');
if (!csrfTokenMeta) throw new Error('Unable to get CSRF token - no meta element');
const csrfToken = csrfTokenMeta.getAttribute('content');
if (!csrfToken) throw new Error('Unable to get CSRF token - no content attribute');
return csrfToken;
}

export const getMALUsername = () => {
const malUsernameElement = document.querySelector('.header-profile-link') as HTMLDivElement;
if (!malUsernameElement) return null;
return malUsernameElement.innerText;
}

export const getAnilistUsername = () => {
const anilistUserElement = document.querySelector('#douki-anilist-username') as HTMLInputElement;
if (!anilistUserElement) throw new Error('Unable to get Anilist username');
return anilistUserElement.value;
}
15 changes: 14 additions & 1 deletion src/Log.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { SYNC_LOG_ID, ERROR_LOG_ID } from './const';
import { id } from './util';
import { id, getOperationDisplayName } from './util';

const getSyncLog = (): Element | null => document.querySelector(id(SYNC_LOG_ID));
const getErrorLog = (): Element | null => document.querySelector(id(ERROR_LOG_ID));
const getCountLog = (operation: string, type: string): Element | null => document.querySelector(id(`douki-${operation}-${type}-items`));

const clearErrorLog = () => {
const errorLog = getErrorLog();
Expand Down Expand Up @@ -40,3 +41,15 @@ export const info = (msg: string) => {
console.info(msg);
}
}

export const addCountLog = (operation: string, type: string, max: number) => {
const opName = getOperationDisplayName(operation);
const logId = `douki-${operation}-${type}-items`;
info(`${opName} <span id="${logId}">0</span> of ${max} ${type} items.`);
}

export const updateCountLog = (operation: string, type: string, count: number) => {
const countLog = getCountLog(operation, type) as HTMLSpanElement;
if (!countLog) return;
countLog.innerHTML = `${count}`;
}
103 changes: 72 additions & 31 deletions src/MAL.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
const getMALHashMap = async (type: string, username: string, list: Array<any> = [], page = 1): Promise<any> => {
import { sleep, getOperationDisplayName } from './util';
import * as Log from './Log';
import * as Dom from './Dom';
import { MALHashMap, MALItem, MediaDate, FormattedEntry, FullDataEntry } from './Types';

const createMALHashMap = (malList: Array<MALItem>, type: string): MALHashMap => {
const hashMap: MALHashMap = {};
malList.forEach(item => {
hashMap[item[`${type}_id`]] = item;
});
return hashMap;
}

const getMALHashMap = async (type: string, username: string, list: Array<MALItem> = [], page = 1): Promise<MALHashMap> => {
const offset = (page - 1) * 300;
const nextList = await fetch(`https://myanimelist.net/${type}list/${username}/load.json?offset=${offset}&status=7`).then(res => res.json());
const nextList = await fetch(`https://myanimelist.net/${type}list/${username}/load.json?offset=${offset}&status=7`)
.then(res => res.json());
if (nextList && nextList.length) {
await sleep(1000);
return getMALHashMap(type, username, [...list, ...nextList], page + 1);
}
Log.info(`Fetched MyAnimeList ${type} list.`);
const fullList = [...list, ...nextList];
return createMALHashMap(fullList, type);
return createMALHashMap([...list, ...nextList], type);
}

const malEdit = (type, data) =>
const malEdit = (type: string, data: MALItem) =>
fetch(`https://myanimelist.net/ownlist/${type}/edit.json`, {
method: 'post',
body: JSON.stringify(data)
Expand All @@ -20,7 +33,7 @@ const malEdit = (type, data) =>
throw new Error(JSON.stringify(data));
});

const malAdd = (type, data) =>
const malAdd = (type: string, data: MALItem) =>
fetch(`https://myanimelist.net/ownlist/${type}/add.json`, {
method: 'post',
headers: {
Expand All @@ -35,7 +48,7 @@ const malAdd = (type, data) =>
throw new Error(JSON.stringify(data));
});

const getStatus = (status) => {
const getStatus = (status: string) => {
// MAL status: 1/watching, 2/completed, 3/onhold, 4/dropped, 6/plantowatch
// MAL handles REPEATING as a boolean, and keeps status as COMPLETE
switch (status.trim()) {
Expand All @@ -55,7 +68,7 @@ const getStatus = (status) => {
}
}

const buildDateString = (date) => {
const buildDateString = (date: MediaDate) => {
if (date.month === 0 && date.day === 0 && date.year === 0) return null;
const dateSetting = Dom.getDateSetting();
const month = `${String(date.month).length < 2 ? '0' : ''}${date.month}`;
Expand All @@ -67,7 +80,7 @@ const buildDateString = (date) => {
return `${day}-${month}-${year}`;
}

const createMALData = (anilistData, malData, csrf_token) => {
const createMALData = (anilistData: FormattedEntry, malData: MALItem, csrf_token: string): MALItem => {
const status = getStatus(anilistData.status);
const result = {
status,
Expand All @@ -83,7 +96,7 @@ const createMALData = (anilistData, malData, csrf_token) => {
month: anilistData.startedAt.month || 0,
day: anilistData.startedAt.day || 0
},
};
} as MALItem;

result[`${anilistData.type}_id`] = anilistData.id;

Expand All @@ -106,9 +119,9 @@ const createMALData = (anilistData, malData, csrf_token) => {
result.num_read_volumes = malData.manga_num_volumes || 0;
}
}
} else {
// Non-completed item; use Anilist's counts
// Note the possibility that this count could be higher than MAL's max; see if that creates problems
} else {
if (anilistData.type === 'anime') {
result.num_watched_episodes = anilistData.progress || 0;
} else {
Expand All @@ -119,15 +132,7 @@ const createMALData = (anilistData, malData, csrf_token) => {
return result;
};

const createMALHashMap = (malList, type) => {
const hashMap = {};
malList.forEach(item => {
hashMap[item[`${type}_id`]] = item;
});
return hashMap;
}

const shouldUpdate = (mal, al) =>
const shouldUpdate = (mal: MALItem, al: MALItem) =>
Object.keys(al).some(key => {
switch (key) {
case 'csrf_token':
Expand Down Expand Up @@ -162,23 +167,14 @@ const shouldUpdate = (mal, al) =>
// In certain cases the next two values will be missing from the MAL data and trying to update them will do nothing.
// To avoid a meaningless update every time, skip it if undefined on MAL
case 'num_watched_times':
{
if (!mal.hasOwnProperty('num_watched_times')) {
return false;
}
if (al[key] !== mal[key]) {
return true;
};
return false;
}
case 'num_read_times':
{
if (!mal.hasOwnProperty('num_read_times')) {
if (!mal.hasOwnProperty(key)) {
return false;
}
if (al[key] !== mal[key]) {
return true;
}
};
return false;
}
default:
Expand All @@ -194,3 +190,48 @@ const shouldUpdate = (mal, al) =>
}
}
});

const syncList = async (type: string, list: Array<FullDataEntry>, operation: string) => {
if (!list || !list.length) {
return;
}
Log.addCountLog(operation, type, list.length);
let itemCount = 0;
// This uses malEdit() for 'completed' as well
const fn = operation === 'add' ? malAdd : malEdit;
for (let item of list) {
await sleep(500);
try {
await fn(type, item.malData);
itemCount++;
Log.updateCountLog(operation, type, itemCount);
} catch (e) {
console.error(e);
Log.info(`Error for ${type} <a href="https://myanimelist.net/${type}/${item.id}" target="_blank" rel="noopener noreferrer">${item.title}</a>. Try adding or updating it manually.`);
}
}
}

export const syncType = async (type: string, anilistList: Array<FormattedEntry>, malUsername: string, csrfToken: string) => {
Log.info(`Fetching MyAnimeList ${type} list...`);
let malHashMap = await getMALHashMap(type, malUsername);
let alPlusMal = anilistList.map(item => Object.assign({}, item, {
malData: createMALData(item, malHashMap[item.id], csrfToken),
})) as Array<FullDataEntry>;

const addList = alPlusMal.filter(item => !malHashMap[item.id]);
await syncList(type, addList, 'add');

// Refresh list to get episode/chapter counts of new completed items
Log.info(`Refreshing MyAnimeList ${type} list...`);
malHashMap = await getMALHashMap(type, malUsername);
alPlusMal = anilistList.map(item => Object.assign({}, item, {
malData: createMALData(item, malHashMap[item.id], csrfToken),
}));
const updateList = alPlusMal.filter(item => {
const malItem = malHashMap[item.id];
if (!malItem) return false;
return shouldUpdate(malItem, item.malData)
});
await syncList(type, updateList, 'edit');
};
Loading

0 comments on commit 1f092b6

Please sign in to comment.