Loading...
← All projects

Moving an academic journal from uCoz to WordPress

Since 2010 the journal "Scientific Notes of Sumy State Pedagogical University. Geographical Sciences" has lived on uCoz at scinotesgeo.sspu.edu.ua. I'm moving it to WordPress with a custom theme, a data model built around articles and issues, and a plugin that pulls the whole archive out of the uCoz backup in one run.

Right now it runs on a local copy (XAMPP). It hasn't been deployed yet.

What was actually in the backup

The uCoz backup is dated April 26, 2026. Once parsed, it came down to 24 pages, 2 volumes, 16 issues covering 2010 to 2025, 287 articles and 336 files, most of them PDFs. Only 77 articles have a DOI; the early issues never had them.

Volume wasn't the hard part.

Some files didn't sit on the main domain at all but on neighbouring uCoz hosts like *.at.ua, and a number of antivirus products flag those domains as malicious. So a link to the old site wasn't just stale. It could break the page for a reader.

Two layers instead of one clever importer

A journal has a shape (volume, issue, article with authors, DOI and PDF) that generic WordPress importers don't know about. I split the job in two.

Step one turns the backup's site.txt and loads.txt into a single data.json with normalized pages, volumes, issues, articles, a file list with candidate paths, and a map of old URLs. Step two is the plugin, "SciNotes Journal: import from uCoz", which reads that JSON and builds everything in WordPress. The upside I cared about most: I can open the JSON, see exactly what will hit the database, and fix the data before import rather than cleaning up afterwards.

An import that survives max_execution_time

Copying 300+ PDFs in one request will time out on local XAMPP, and a university host is unlikely to be more generous. So the import has six steps, and each step runs as small AJAX chunks:

php
const SNG_STEPS = array( 'files', 'terms', 'articles', 'pages', 'links', 'finish' );

// sng_step_files(): 8 files per request
if ( microtime( true ) - $started > 20 ) { // big PDFs: stop early, continue next request
	$batch = array_slice( $batch, 0, $i + 1 );
	break;
}

Files go eight at a time, articles forty. When a large PDF eats 20 seconds, the step stops and the next request picks up where it left off. Progress lives in the sng_import_state option, and every created record is tagged with _sng_key, so running the import again updates what's there instead of creating duplicates.

Files are looked up in the local backup folders first, case-insensitively, since uCoz wasn't consistent about casing. If a file is missing, the importer can download it from the old site, but never from *.at.ua or *.ucoz.*. Images that still can't be found are removed from the HTML; links to missing files become plain text. The final log lists everything that's missing so the editors know what to upload by hand.

Articles as posts, issues as terms

Articles are a custom post type, sng_article, under /statti/…. Issues are a hierarchical taxonomy, sng_issue, under /arkhiv/…: the top level is the volume, the child is the issue. Issue meta holds volume, number, year, cover and the full-issue PDF; article meta holds authors, DOI and the article PDF. Order inside an issue comes from menu_order, matching the printed table of contents.

The model started out in the plugin. In 1.1.0 I moved it into the theme and left the plugin for the one-off import only, for a mundane reason: if someone on the editorial team ever disables an "unused" plugin, the articles shouldn't vanish.

Site search needed a tweak too. Core WordPress doesn't search meta fields, and people look for papers by author surname.

Old URLs shouldn't end in a 404

The uCoz URLs have been cited in bibliographies and indexes for years. The import builds a redirect map with over a hundred old addresses for pages, volumes and issues (/index/…/0-27, /publ/…), plus every migrated file mapped to its new Media Library URL. All of these are 301s.

Anything not in the map but starting with an old uCoz module prefix (/load, /publ, /news, /blog, /index) gets a 302 to the issue archive. Not perfect. Still better than a blank 404.

Google Scholar tags

Every article page outputs citation_* meta tags: title, authors, journal, both ISSNs, year, volume, issue, DOI and PDF link. Readers never see them, but they decide whether Google Scholar indexes the paper correctly.

An English UI without a multilingual plugin

The journal has no separate English site, so I added an English interface inside the same WordPress install: Ukrainian at /, English at /en/. No WPML, no Polylang.

The language is detected from the path before WordPress parses the request. For English, a home_url filter appends /en; WordPress strips it during routing, and every link it generates (permalinks, menus, the search form) stays under /en/. Admin, the REST API and uploads aren't touched.

Content follows a "one record, extra fields" approach: articles get sng_title_en, sng_authors_en and sng_abstract_en, pages get sng_content_en. If a page hasn't been translated, the English view says so and shows the Ukrainian text wrapped in lang="uk". That's what most pages look like today, because translating content is a separate job for the editorial team.

The theme

SciNotes Geo is written from scratch: SCSS with tokens exposed as CSS custom properties, Literata for reading text and Manrope for UI, and submenu toggle buttons so the menu works with a keyboard and on touch screens. The feedback form needs no third-party plugin. It uses a nonce, a honeypot field, a small arithmetic captcha and a limit of one message per minute per IP. Messages are emailed and also stored in the admin as their own post type, so a lost email doesn't mean a lost request.

Still to do

The site only exists locally for now. Next come deployment to the university server, checking the redirects on the real domain, and filling in the English fields. Generating data.json is still a separate manual step, though future issues won't need it: new articles will be added directly in the admin.