Skip to content

Instantly share code, notes, and snippets.

@mary-ext
Created June 15, 2025 15:58
Show Gist options
  • Save mary-ext/797b3ece51e67dcb3f741ce43445d616 to your computer and use it in GitHub Desktop.
Save mary-ext/797b3ece51e67dcb3f741ce43445d616 to your computer and use it in GitHub Desktop.
kenmei export to mihon/tachiyomi backup
import { assert } from '@std/assert';
import * as p from '@mary/protobuf';
const Source = p.message({
id: p.int64(),
name: p.string(),
}, {
id: 2,
name: 1,
});
const Category = p.message({
id: p.int64(),
order: p.optional(p.int64(), 0n),
name: p.string(),
}, {
id: 3,
order: 2,
name: 1,
});
type Category = p.InferInput<typeof Category>;
const Chapter = p.message({
url: p.string(),
chapterNumber: p.optional(p.float(), 0),
name: p.string(),
read: p.optional(p.boolean(), false),
}, {
url: 1,
chapterNumber: 9,
name: 2,
read: 4,
});
type Chapter = p.InferInput<typeof Chapter>;
const Manga = p.message({
source: p.int64(),
url: p.string(),
title: p.string(),
chapters: p.optional(p.repeated(Chapter), []),
categories: p.optional(p.repeated(p.int64()), []),
}, {
source: 1,
url: 2,
title: 3,
chapters: 16,
categories: 17,
});
type Manga = p.InferInput<typeof Manga>;
const Backup = p.message({
entries: p.repeated(Manga),
categories: p.optional(p.repeated(Category), []),
sources: p.optional(p.repeated(Source), []),
}, {
entries: 1,
categories: 2,
sources: 101,
});
{
const buffer = Deno.readFileSync('./backup.tachibk');
const decoded = p.decode(Backup, buffer);
console.log(decoded);
}
{
interface RawEntry {
title: string;
status: 'reading' | 'on_hold' | 'plan_to_read' | 'completed' | 'dropped';
score: number | '';
last_volume_read: number | '';
last_chapter_read: number | '';
tracked_site: 'MangaDex' | 'Comick' | 'LHTranslation';
series_url: string;
tags: string;
}
const READING_CATEGORY: Category = { id: 1n, order: 0n, name: `Reading` } as const;
const PAUSED_CATEGORY: Category = { id: 2n, order: 1n, name: `Paused` } as const;
const PLANNED_CATEGORY: Category = { id: 3n, order: 2n, name: `Planned` } as const;
const COMPLETED_CATEGORY: Category = { id: 4n, order: 3n, name: `Completed` } as const;
const DROPPED_CATEGORY: Category = { id: 5n, order: 4n, name: `Dropped` } as const;
const NSFW_CATEGORY: Category = { id: 6n, order: 5n, name: `NSFW` } as const;
const rawEntries: RawEntry[] = JSON.parse(Deno.readTextFileSync('./kenmei.json'));
const MANGADEX_ID = 2499283573021220255n;
const COMICK_ID = 2971557565147974499n;
const LHTRANSLATION_ID = 8802607595629671202n;
const toFloat32 = (value: number): number => {
return new Float32Array([value])[0];
};
const backup: p.InferInput<typeof Backup> = {
categories: [
READING_CATEGORY,
PAUSED_CATEGORY,
PLANNED_CATEGORY,
COMPLETED_CATEGORY,
DROPPED_CATEGORY,
NSFW_CATEGORY,
],
sources: [
{ name: 'MangaDex', id: MANGADEX_ID },
{ name: 'Comick', id: COMICK_ID },
],
entries: rawEntries.map((entry): Manga => {
const categories: Category[] = [];
switch (entry.status) {
case 'completed': {
categories.push(COMPLETED_CATEGORY);
break;
}
case 'dropped': {
categories.push(DROPPED_CATEGORY);
break;
}
case 'on_hold': {
categories.push(PAUSED_CATEGORY);
break;
}
case 'plan_to_read': {
categories.push(PLANNED_CATEGORY);
break;
}
case 'reading': {
categories.push(READING_CATEGORY);
break;
}
}
if (entry.tags === 'NSFW') {
categories.push(NSFW_CATEGORY);
}
const chapters: Chapter[] = [];
if (entry.last_chapter_read) {
const last = entry.last_chapter_read;
const lastDecimal = Math.floor(last * 10);
for (let i = 0; i <= lastDecimal; i++) {
const num = i / 10;
const floated = toFloat32(num);
chapters.push({
url: `unknown_${num}`,
name: `Ch. ${num}: Refresh your library`,
chapterNumber: num,
read: true,
});
if (num !== floated) {
chapters.push({
url: `unknown_${floated}`,
name: `Ch. ${floated}: Refresh your library`,
chapterNumber: floated,
read: true,
});
}
}
// Handle cases where last chapter has finer precision than 0.1
if (last * 10 !== lastDecimal) {
chapters.push({
url: `unknown_${last}`,
name: `Ch. ${last}: Refresh your library`,
chapterNumber: toFloat32(last),
read: true,
});
}
chapters.reverse();
}
switch (entry.tracked_site) {
case 'MangaDex': {
return {
source: MANGADEX_ID,
title: entry.title,
url: entry.series_url.replace('https://mangadex.org/title/', '/manga/'),
categories: categories.map((category) => category.order!),
chapters: chapters,
};
}
case 'Comick': {
return {
source: COMICK_ID,
title: entry.title,
url: entry.series_url.replace('https://comick.io/comic/', '/comic/') + '#',
categories: categories.map((category) => category.order!),
chapters: chapters,
};
}
case 'LHTranslation': {
return {
source: LHTRANSLATION_ID,
title: entry.title,
url: entry.series_url.replace('https://lhtranslation.net/manga/', '/manga/'),
categories: categories.map((category) => category.order!),
chapters: chapters,
};
}
default: {
assert(false, `unknown tracked site ${entry.tracked_site}`);
}
}
}),
};
console.log(backup);
Deno.writeFileSync('./kenmei.tachibk', p.encode(Backup, backup));
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment