The right mindset before syncing
Do not try to force an old website to match Init Manga’s internal structure.
The correct approach is simple: read the data the way the old site stores it, then transform (map) it into a format that Init Manga understands. The sync API acts as a translation layer between two systems that speak different “data languages”.
Example data model used in this guide
In the example code below (which you can paste and run directly), the data structure is assumed to be:
- Manga / Story: stored as a
category - Story metadata: stored in
term metaandACF - Chapters: stored as regular
posts - Story–chapter relationship: determined by the first category assigned to the post
Important: this is only an example. If your site uses custom taxonomies, custom post types, or different meta keys, you only need to adjust the mapping logic, not the overall sync flow.
Core principles of the sync API
- Separate endpoints for stories and chapters
- Pagination to handle large datasets safely
- Header-based authentication (no secrets in URLs)
- Clean, predictable JSON responses
Sync API code
The code below can be placed inside a mu-plugin or a regular plugin. Once pasted, your sync API is immediately available for Init Manga Sync to consume.
<?php
/**
* Old Manga Sync API (mu-plugin)
*
* This plugin exposes REST API endpoints that allow legacy manga websites
* (using different themes / data structures) to sync data into Init Manga.
*
* Available endpoints:
* - GET /wp-json/oldmanga/v1/stories
* - GET /wp-json/oldmanga/v1/chapters
*
* Pagination:
* - Stories: 10 items per page
* - Chapters: 100 items per page
*
* Authentication:
* - Header-based API key (X-Init-Manga-Key)
*/
if ( ! defined('ABSPATH') ) exit;
class OldManga_Sync_API {
/**
* IMPORTANT:
* Change this API key before using in production.
* You can generate a strong key using Init Password Generator (https://en.inithtml.com/init-password-generator/).
*/
const API_PASSWORD = 'STRONG_API_KEY';
const STORIES_PER_PAGE = 10;
const CHAPTERS_PER_PAGE = 100;
const CHAP_SLUG_PREFIX = 'chap';
/**
* Bootstrap the API
*/
public static function init() {
add_action('rest_api_init', [__CLASS__, 'register_routes']);
}
/**
* Register REST API routes
*/
public static function register_routes() {
register_rest_route('oldmanga/v1', '/stories', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_stories'],
'permission_callback' => [__CLASS__, 'check_api_key'],
]);
register_rest_route('oldmanga/v1', '/chapters', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_chapters'],
'permission_callback' => [__CLASS__, 'check_api_key'],
]);
}
/**
* Validate API key from request headers
*
* Expected header:
* X-Init-Manga-Key: your-secret-key
*/
public static function check_api_key($request) {
$key = $request->get_header('X-Init-Manga-Key');
$password = '';
if (isset($key)) {
$password = $key;
}
// Use hash_equals to prevent timing attacks
if (empty($api_key) || !hash_equals(self::API_PASSWORD, $api_key)) {
return new WP_Error(
'invalid_api_key',
'Invalid or missing API key',
['status' => 401]
);
}
return true;
}
/**
* GET /wp-json/oldmanga/v1/stories?page=1
*
* In this example:
* - Each story is represented by a WordPress category
*/
public static function get_stories($request) {
$page = max(1, (int) $request->get_param('page'));
$offset = ($page - 1) * self::STORIES_PER_PAGE;
// Fetch categories as stories
$cats = get_terms([
'taxonomy' => 'category',
'hide_empty' => true,
'orderby' => 'term_id',
'order' => 'DESC',
'number' => self::STORIES_PER_PAGE,
'offset' => $offset,
]);
$total = wp_count_terms('category', ['hide_empty' => true]);
$total_pages = ceil($total / self::STORIES_PER_PAGE);
$items = [];
foreach ($cats as $cat) {
$items[] = self::build_story($cat);
}
return new WP_REST_Response([
'success' => true,
'page' => $page,
'per_page' => self::STORIES_PER_PAGE,
'total' => $total,
'total_pages' => $total_pages,
'items' => $items,
], 200);
}
/**
* GET /wp-json/oldmanga/v1/chapters?page=1
*
* In this example:
* - Each chapter is a standard WordPress post
* - The parent story is determined by the first assigned category
*/
public static function get_chapters($request) {
$page = max(1, (int) $request->get_param('page'));
$offset = ($page - 1) * self::CHAPTERS_PER_PAGE;
$query = new WP_Query([
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => self::CHAPTERS_PER_PAGE,
'offset' => $offset,
]);
$total = $query->found_posts;
$total_pages = ceil($total / self::CHAPTERS_PER_PAGE);
$chapters = [];
foreach ($query->posts as $post) {
// Determine parent story from the first category
$cats = wp_get_post_categories($post->ID, ['fields' => 'all']);
$story_id = 0;
$story_slug = '';
$story_name = '';
if (!empty($cats)) {
$cat = $cats[0];
$story_id = $cat->term_id;
$story_slug = $cat->slug;
$story_name = html_entity_decode($cat->name, ENT_QUOTES, 'UTF-8');
}
// Extract chapter number from the post title
$number = self::extract_chapter_number($post->post_title);
$chapters[] = [
'id' => $post->ID,
'story_id' => $story_id,
'story_slug' => $story_slug,
'story_name' => $story_name,
'title' => '',
'number' => (int) $number,
'slug' => self::CHAP_SLUG_PREFIX . '-' . (int) $number,
'content' => $post->post_content,
'date' => $post->post_date,
'modified' => $post->post_modified,
];
}
return new WP_REST_Response([
'success' => true,
'page' => $page,
'per_page' => self::CHAPTERS_PER_PAGE,
'total' => $total,
'total_pages' => $total_pages,
'items' => $chapters,
], 200);
}
/**
* Build a story payload from a category term
*/
protected static function build_story(WP_Term $cat) {
$alt_title = (string) get_term_meta($cat->term_id, 'comic_othername', true);
$status_vi = (string) get_term_meta($cat->term_id, 'comic_status', true);
// Featured cover image
$cover_id = (int) get_term_meta($cat->term_id, 'comic_image', true);
$featured = ['url' => '', 'alt' => '', 'caption' => ''];
if ($cover_id) {
$url = wp_get_attachment_url($cover_id);
if ($url) {
$featured['url'] = $url;
$featured['alt'] = get_post_meta($cover_id, '_wp_attachment_image_alt', true) ?: '';
$featured['caption'] = wp_get_attachment_caption($cover_id) ?: '';
}
}
// Resolve taxonomies
$genres = self::resolve_genres($cat->term_id);
$teams = self::resolve_teams($cat->term_id);
$authors = self::resolve_authors($cat->term_id);
$content = term_description($cat->term_id, 'category') ?: '';
$meta = [
'type' => 'comic',
'webtoon_support' => '1',
'status' => self::map_status($status_vi),
'alt_title' => $alt_title,
];
$taxonomies = ['genre' => $genres];
if ($authors) $taxonomies['author_tax'] = $authors;
if ($teams) $taxonomies['team'] = $teams;
return [
'id' => $cat->term_id,
'title' => html_entity_decode($cat->name, ENT_QUOTES, 'UTF-8'),
'slug' => $cat->slug,
'meta' => $meta,
'taxonomies' => $taxonomies,
'content' => $content,
'featured' => $featured,
];
}
/**
* Map legacy Vietnamese status values to Init Manga status keys
*/
protected static function map_status($status_vi) {
$status = preg_replace('/\s+/u', ' ', trim(wp_strip_all_tags($status_vi)));
$map = [
'Đang tiến hành' => 'ongoing',
'Trọn bộ' => 'completed',
'Tạm ngưng' => 'dropped',
];
return $map[$status] ?? 'ongoing';
}
/**
* Resolve authors from term meta
*/
protected static function resolve_authors($term_id) {
$raw = (string) get_term_meta($term_id, 'comic_author', true);
if (!$raw) return [];
$parts = array_map('trim', explode(',', str_replace(';', ',', $raw)));
$out = [];
$seen = [];
foreach ($parts as $name) {
if (!$name) continue;
$slug = sanitize_title($name);
if (!$slug || isset($seen[$slug])) continue;
$seen[$slug] = true;
$out[] = [
'name' => $name,
'slug' => $slug,
'taxonomy' => 'author_tax',
];
}
return $out;
}
/**
* Resolve genres from legacy tag references
*/
protected static function resolve_genres($term_id) {
$raw = get_term_meta($term_id, 'comic_tags', true);
if (is_string($raw)) {
$maybe = @maybe_unserialize($raw);
if ($maybe !== false || $raw === 'b:0;') {
$raw = $maybe;
}
}
$ids = [];
if (is_array($raw)) {
foreach ($raw as $v) {
if (is_numeric($v)) $ids[] = (int) $v;
}
} elseif (is_numeric($raw)) {
$ids[] = (int) $raw;
}
$genres = [];
$seen = [];
foreach ($ids as $id) {
$term = get_term($id, 'post_tag');
if ($term && !is_wp_error($term) && !isset($seen[$term->slug])) {
$genres[] = [
'name' => $term->name,
'slug' => $term->slug,
'taxonomy' => 'genre',
];
$seen[$term->slug] = true;
}
}
return $genres;
}
/**
* Resolve team taxonomy via ACF or fallback term meta
*/
protected static function resolve_teams($term_id) {
if (function_exists('get_field')) {
$acf_val = get_field('comic_team', 'category_' . $term_id);
$teams = self::normalize_teams($acf_val);
if ($teams) return $teams;
}
$raw = get_term_meta($term_id, 'comic_team', true);
if (is_string($raw)) {
$maybe = @maybe_unserialize($raw);
if ($maybe !== false || $raw === 'b:0;') {
$raw = $maybe;
}
}
return self::normalize_teams($raw);
}
/**
* Normalize team input into Init Manga taxonomy format
*/
protected static function normalize_teams($input) {
$ids = [];
if (is_array($input)) {
foreach ($input as $v) {
if (is_object($v) && isset($v->ID)) $ids[] = (int) $v->ID;
elseif (is_array($v) && isset($v['ID'])) $ids[] = (int) $v['ID'];
elseif (is_numeric($v)) $ids[] = (int) $v;
}
} elseif (is_object($input) && isset($input->ID)) {
$ids[] = (int) $input->ID;
} elseif (is_numeric($input)) {
$ids[] = (int) $input;
}
$ids = array_unique(array_filter($ids));
$out = [];
$seen = [];
foreach ($ids as $id) {
$post = get_post($id);
if ($post && !isset($seen[$post->post_name])) {
$out[] = [
'name' => get_the_title($post),
'slug' => $post->post_name,
'taxonomy' => 'team',
];
$seen[$post->post_name] = true;
}
}
return $out;
}
/**
* Extract chapter number from a post title
*
* Examples:
* - "One Piece Chap 1234" -> 1234
* - "Chap 56.5" -> 56.5
* - "Chapter 789" -> 789
*/
protected static function extract_chapter_number($title) {
// Normalize Unicode dashes
$title = str_replace(["–", "—", "−"], "-", $title);
if (preg_match('/\b(chap|chapter|chương)\b[^0-9]*?(\d+(?:[.,]\d+)?)/iu', $title, $m)) {
return (float) str_replace(',', '.', $m[2]);
}
return 0;
}
}
OldManga_Sync_API::init();
After pasting the code, you may want to adjust the following parts to match your website:
- Stories are not categories → replace
get_terms()with your own taxonomy query - Chapters are not posts → change
post_typeinWP_Query - Different meta keys → update calls to
get_term_meta()orget_post_meta() - No ACF → remove or replace
get_field()usage
How Init Manga Sync calls the API
Init Manga Sync authenticates requests using an HTTP header, not query parameters:
X-Init-Manga-Key: your-secret-key
This approach prevents API keys from being exposed through URLs, logs, caches, or CDN layers.
The most important part: data mapping
No two websites store data in exactly the same way.
Your API must ensure that Init Manga receives the following core data:
- Story ID and slug
- Story title
- Taxonomies (genres, authors, teams, etc.)
- Story content / description
- Chapter list with correct chapter numbers
As long as these fields are correct, Init Manga does not care how or where the data originally comes from.
Common mistakes to avoid
- Fetching too much data in a single request
- Skipping pagination and causing timeouts
- Not normalizing slugs and taxonomy values
- Ignoring missing or malformed legacy data
Think of the sync API as a pure data layer. The simpler it is, the easier it will be to maintain long-term.
Conclusion
Syncing data from a website using a different theme into Init Manga is straightforward when approached correctly. Treat the API code as a reusable template, then focus your effort on accurately mapping your existing data structure.
When done right, you can consolidate multiple heterogeneous manga sites into Init Manga without touching the core theme—clean, secure, and built to scale.
Comments