3 октября 2009 г.

Python backup, часть 3

Третья версия скрипта-бэкапа на питоне :) На этот раз архив с файлами для бэкапа не создаётся. Вместо этого файлы с одного места (исходные папки) перемещаются в другое место (папки с бэкапами) как есть. Для реальной работы, когда в день может поменяться всего пара десятков файлов, это гораздо выгоднее. DropBox будет загружать лишь эти несколько файлов, вместо громадного архива.

Вообще-то мне кажется что что-то такое уже должно существовать, тем более для линукса :) С другой стороны бэкап был написан не потому что такого ещё нет, а что бы лучше узнать питон.

Теперь алгоритм такой. Берём исходную папку ("лево"), создаём папку в выходной папке ("право"), копируем слева-направо только изменившиеся папки/файлы, удалённые слева папки/файлы удаляем и справа. При этом справа будет создана папка, отображающая полный путь до папки слева.

Например, исходная папка d:\projects\python, папка назначения - d:\dropbox\backup, тогда реальная папка назначения будет такой - d:\dropbox\backup\d_\projects\python.

Всё остальное осталось от предыдущих версий - файлы-списки toBackup.lst, ignore.lst, extra.lst.

Скриншотик:
Free Image Hosting at www.ImageShack.us

Скрипт:
import sys
import os
import os.path
import tarfile
import time
import gzip
import copy
import shutil

Verbose = False

ignoreList = []

copiedFiles = 0
skippedFiles = 0
createdFolders = 0
deletedFolders = 0
deletedFiles = 0
errors = 0

def removeDir(path):
    '''
    Remove dir and all subdirs with all subfiles
    '''
    for root, dirs, files in os.walk(path, False):
        for f in files:
            os.unlink(os.path.join(root, f))
            
        for d in dirs:
            os.rmdir(os.path.join(root, d))
            
    os.rmdir(path) 
   
def copyFile(fromFilename, toFilename):
    '''
    Function will copy file only if toFilename is different than fromFilename.
    Size and modification date checked.
    
    Returns True if file been realy copied,
    return False if there was no real copy (files seems the same)
    '''
    copy = True
    if os.path.isfile(toFilename):
        fromStat = os.stat(fromFilename)
        toStat = os.stat(toFilename)

        copy = (fromStat.st_size != toStat.st_size) or (str(fromStat.st_mtime) != str(toStat.st_mtime))        

    # Copy file with stat info, including e.g. modification date
    if copy:
        shutil.copy2(fromFilename, toFilename)
        
    return copy

def createFolderStruct(path):
    '''
    Create struct (tree node) for folder tree
    '''
    return {'full_path': path,
            'files': [],
            'dirs': {}}

def makeDirTree(path, data):
    '''
    Creates folder tree in form of special struct
    '''
    for i in os.listdir(path):
        newPath = os.path.join(path, i)

        # Ignore some folders from proccessing
        if (i.lower() in ignoreList) or (newPath.lower() in ignoreList):
            continue
        
        if os.path.isfile(newPath):
            data['files'].append(i)
        elif os.path.isdir(newPath):
            data['dirs'][i] = createFolderStruct(newPath) 
            
            makeDirTree(newPath, data['dirs'][i])
            
def copyFolder(srcDirs, outDirs):
    '''
    Copy folder from "left" to "right", given folders trees srcDirs and outDirs.
    Right folder will be complete copy of left folder with minimum number of file operations
    (don't copy file if it's already exists and hasn't been modified)
    '''
    
    # Use global counters
    global copiedFiles
    global skippedFiles
    global createdFolders
    global deletedFolders
    global deletedFiles
    global errors    
    
    # Loop throught all src folders, s - current folder name
    for s in srcDirs['dirs']:
        # If src folder isn't exist in output folder
        if s not in outDirs['dirs']:
            # If there is file in output folder with name as folder in src folder - delete that file
            if s in outDirs['files']:
                # Delete file s
                fullFilename = os.path.join(outDirs['full_path'], s)
                os.unlink(fullFilename)
                if Verbose:
                    print('D %s' % fullFilename)
                deletedFiles += 1                                        
                
            outDirs['dirs'][s] = createFolderStruct(os.path.join(outDirs['full_path'], s))
            
            # Create output folder with src folder name                
            fullPath = os.path.join(outDirs['full_path'], s)
            os.mkdir(fullPath)
            if Verbose:
                print('M [%s]' % fullPath)
            createdFolders += 1

        # Go deep inside current folder
        copyFolder(srcDirs['dirs'][s], outDirs['dirs'][s])
    
    # Remove folders from right, that don't exist on left
    for o in outDirs['dirs']:
        if o not in srcDirs['dirs']:
            fullPath = os.path.join(outDirs['dirs'][o]['full_path'])
            removeDir(fullPath)
            if Verbose:
                print('D [%s]' % fullPath)
            deletedFolders += 1                

    # Remove files from right, that don't exist on left
    for o in outDirs['files']:
        if o not in srcDirs['files']:
            fullFilename = os.path.join(outDirs['full_path'], o)
            os.unlink(fullFilename)
            if Verbose:
                print('D %s' % fullFilename)
            deletedFiles += 1            

    # Copy files from left to right
    for s in srcDirs['files']:
        fullFrom = os.path.join(srcDirs['full_path'], s)
        fullTo = os.path.join(outDirs['full_path'], s)
        
        copied = copyFile(fullFrom, fullTo)
        if copied: 
            c = 'C'
            copiedFiles += 1 
        else: 
            c = '-'
            skippedFiles += 1
            
        if Verbose and copied:
            print('%s %s -> %s' % (c, fullFrom, fullTo)) 

if __name__ == "__main__":
    if (len(sys.argv) < 2):
        print("Usage: backup.py <output folder> [--verbose]")
        print('E.g.: "backup.py d:\\dropbox\\my dropbox\\backup\\"')
        exit(1)

    RootFoldersFile = "tobackup.lst"
    IgnoreFoldersFile = "ignore.lst"
    ExtraFile = "extra.lst"
    OutputFolder = sys.argv[1]
    
    try:
        Verbose = (sys.argv[2] == '--verbose')
    except:
        pass

    # Only mandatory file is RootFoldersFile, check that it exists
    if (not os.path.isfile(RootFoldersFile)):
        print("! %s doesn't exist" % RootFoldersFile)
        exit(1)

    # Output folder should exist, or we should be able to create it
    if not os.path.isdir(OutputFolder):
        try:
            os.makedirs(OutputFolder)
        except:
            print("! Can't create output folder %s" % OutputFolder)
            exit(1)

    # Read root folders from file, skip empty lines
    rootFolders = [i.strip() for i in open(RootFoldersFile, "r").readlines()]
    if '' in rootFolders:
        rootFolders.remove('')

    # Read list of folders that need to be ignored (if specified)
    if (os.path.isfile(IgnoreFoldersFile)):
        ignoreList = [i.strip().lower() for i in open(IgnoreFoldersFile, "r").readlines()]
        if '' in ignoreList:
            ignoreList.remove('')

    # Create folders trees for each input folder
    for rootFolder in rootFolders:
        inputTree = createFolderStruct(rootFolder)
        makeDirTree(rootFolder, inputTree)

        # File will be placed under OutputFolder plus full path to src folder
        # E.g. src folder d:\projects\python will be copied to folder q:\backup\d_\projects\python
        #                 ^^^^^^^^^^^^^^^^^^                                    ^^^^^^^^^^^^^^^^^^            
        outFolder = inputTree['full_path']
        outFolder = outFolder.replace(':', '_')
        outFolder = os.path.join(OutputFolder, outFolder)

        print('[%s] -> [%s]...' % (rootFolder, outFolder))
            
        if not os.path.isdir(outFolder):
            try:
                os.makedirs(outFolder)
                print('M [%s]' % outFolder)
            except:
                print("! Can't create output folder [%s]" % outFolder)
                print("! Skip input folder %s" % rootFolder)
                errors += 1
                continue
        
        # Make folder tree for real output folder
        outputTree = createFolderStruct(outFolder)        
        makeDirTree(outFolder, outputTree)
        
        # Copy input folder to output folder
        copyFolder(inputTree, outputTree)
        
    # Read list of extra files
    if (os.path.isfile(ExtraFile)):
        extraList = [i.strip().lower() for i in open(ExtraFile, "r").readlines()]
        if '' in extraList:
            extraList.remove('')
        
        print('Extra files...')
        
        for file in extraList:
            outFileDir = os.path.join(OutputFolder, os.path.dirname(file).replace(':', '_'))
            if not os.path.isdir(outFileDir):
                try:
                    os.makedirs(outFileDir)
                except:
                    print("! Can't create output folder [%s]. Skip extra file %s" % (outFileDir, file))
                    errors += 1
                    continue
        
            fullTo = os.path.join(outFileDir, os.path.basename(file))
        
            copied = copyFile(file, fullTo)
            if copied: 
                c = 'C'
                copiedFiles += 1 
            else: 
                c = '-'
                skippedFiles += 1
                
            if Verbose and copied:
                print('%s %s -> %s' % (c, file, fullTo))
            
    print('==============================')
    print('Copied file(s):    %d' % copiedFiles)
    print('Skipped file(s):   %d' % skippedFiles)
    print('Created folder(s): %d' % createdFolders)
    print('Deleted folder(s): %d' % deletedFolders)
    print('Deleted file(s):   %d' % deletedFiles)
    print('Errors:            %d' % errors)
    print('==============================')    
    print("Done"

2 октября 2009 г.

Баланс в телефоне

Решил реализовать такую интересную и простую задачку - следить за балансом на телефоне, что бы был красивый график. Я использую БВК, у них есть ИССА - Интернет Служба Сервиса Абонента, через которую можно узнать состояние своего счёта.

Задача разбивается на две подзадачи - посмотреть состояние счёта в интернете, сохраняя эти данные куда-нибудь; построить график на основе этих данных.

Получение состояния счёта

Я написал небольшой скрипт на питоне, который логинится в ИССА и узнаёт состояние счёта. Далее он записывает эти данные, вместе с текущей датой/временем, в текстовый файл. Для работы с интернетом используется mechanize, этот веб-клиент, написанный на питоне, поддерживает куки, что необходимо для логина.

mechanize не входит в стандартную поставку питона, его нужно установить:
easy_install.py mechanize

Поиск баланса ведётся с помощью регулярных выражений по строке "Баланс вашего лицевого счёта равен xx руб.".
# -*- coding:utf-8 -*-

from mechanize import Browser
from time import strftime
import re
import os, sys

LOGIN_URL = 'http://issa.bwc.ru/'
ACCOUNT_URL = 'http://issa.bwc.ru/cgi-bin/cgi.exe?function=is_account'
DATA_FILE = os.path.dirname(sys.argv[0]) + '/history.txt'
PHONE_NUMBER = <phone number>
PASSWORD = <issa password>

browser = Browser()

browser.open(LOGIN_URL)
browser.select_form(name = 'num')
browser['mobnum'] = PHONE_NUMBER
browser['Password'] = PASSWORD
browser.submit()
r = browser.open(ACCOUNT_URL)

page = r.read().decode('cp1251')

match = re.search(ur'Баланс вашего лицевого счета равен (?P<money1>\d+).(?P<money2>\d)', page, re.IGNORECASE)
if match:
    money = int(match.group('money1')) + float(match.group('money2')) / 10 
    f = open(DATA_FILE, 'a')
    f.write('%s, %.1f\n' % (strftime('%d.%m.%Y %H:%M'), + money))
    f.close()
    
    print('Phone balance: %.1f rub' % money)
else:
    print page
Я добавил задание в стандартный планировщик Windows, который запускает этот скрипт каждые два часа.

График

Для рисования графика я взял библиотеку от Google google.visualization.AnnotatedTimeLine. Ей нужно передать данные, она сама нарисует красивый график, используя Flash. Для разбора файла-лога с балансами я написал скрипт на PHP. Страница index.php, которая ответственна за вывод графика, имеет такой вид:

<?php
    define('HISTORY_FILE', 'd:\projects\python\issa.bwc\history.txt');

    $history = array();
    $hist = explode("\n", trim(file_get_contents(HISTORY_FILE)));
    foreach ($hist as $_h) {
        $tmp = split('[. ,:]', $_h);

        if (!$tmp[0])
            continue;

        $history[] = array('day' => $tmp[0],
                           'month' => $tmp[1],
                           'year' => $tmp[2],
                           'hours' => $tmp[3],
                           'minutes' => $tmp[4],
                           'value' => intval($tmp[6]) + intval($tmp[7]) / 10);
    }
?>

<script type='text/javascript' src='http://www.google.com/jsapi'></script>
<script type='text/javascript'>
  google.load('visualization', '1', {'packages':['annotatedtimeline']});
  google.setOnLoadCallback(drawChart);
  function drawChart() {
    var data = new google.visualization.DataTable();
    data.addColumn('datetime', 'Date');
    data.addColumn('number', 'Phone balance');
    data.addRows([
    <?php foreach ($history as $_h) { ?>
      [new Date(<?php print $_h['year']; ?>, <?php print $_h['month']; ?>, <?php print $_h['day']; ?>, <?php print $_h['hours']; ?>, <?php print $_h['minutes']; ?>), <?php print $_h['value']; ?>]<?php if ($_h != end($history)) { ?>,<?php } ?>

    <?php } ?>
    ]);

    var chart = new google.visualization.AnnotatedTimeLine(document.getElementById('chart_div'));
    chart.draw(data, {displayAnnotations: true});
  }
</script>

<body>
    <div id='chart_div' style='width: 100%; height: 100%;'></div>
</body>

В результате работы скрипта получаем такую картинку. Этот скрипт работает всего пол дня, поэтому изменения совсем незаметные :)

Заключение

Практическая ценность такой игрушки весьма сомнительна :) Потом, когда у меня появится свой VDS :) это нужно будет переместить туда.

1 октября 2009 г.

ЧПУ для тэгов в Magento

Задача - сделать ЧПУ адреса для тэгов, т.е. из /tag/product/list/tagId/253/ сделать /tag/weight-loss. Думаю другого способа, кроме реврайтов, нет, поэтому будем создавать по реврайту на тэг. А т.к. модуль, создающий реврайты, у нас уже есть (Blogrewrite, он делает реврайты для блоговых адресов), то вместо создания нового модуля воспользуемся существующим. В будущем надо бы сделать отдельный модуль, если можно - даже перехватывать создание нового тэга и автоматически делать реврайт. Но сейчас лениво :)

Контроллер

Итак, добавляем новую кнопку на страницу модуля Blogrewrite, которая будет создавать/обновлять реврайты для тэгов. Соответственно, добавляем новое действие в контроллер Blogrewrite:
<?php

class Mage_Blogrewrite_Adminhtml_BlogrewriteController extends Mage_Adminhtml_Controller_Action {
    ...
    public function maketagrewritesAction() {
    ...
    }
}
?>
Действие очень простое - нужно перебрать все тэги и на каждый создать реврайт. Для этого будем использовать модель tag/tag:
// Load all approved tags
$model = Mage::getModel('tag/tag');
$collection = $model->getResourceCollection()
    ->addStatusFilter($model->getApprovedStatus())
    ->addStoreFilter(Mage::app()->getStore()->getId())
    ->load();
Получили коллекцию тэгов, утверждённых, и относящихся к текущему магазину. Дальше перебираем эту коллекцию и обновляем реврайты:
foreach ($collection as $_tag) {
    $tagId = $_tag->getId();

    $idPath = "tag/$tagId";
    $requestPath = 'tag/' . Slugify($_tag->getName());
    $targetPath = "tag/product/list/tagId/$tagId";

    $this->_makeRewrite($idPath, $requestPath, $targetPath);
}
Наверное я плохо искал, но я не увидел как сделать slug адреса с помощью Magento. Поэтому я написал свою функцию, которую вынес в index.php. Она нам ещё пригодится для шаблонов.
function Slugify($url) {
    // remove all non-alphanumeric characters except for spaces and hyphens
    $res = preg_replace('/[^a-z0-9- ]/', '', strtolower($url));

    // remove double spaces and substitute the spaces with hyphens
    $res = str_replace(' ', '-', str_replace('  ', ' ', $res));

    return $res;
}
Готово. Теперь что бы создать реврайты для тэгов нужно зайти в админку, на странцу модуля Blogrewrite, и нажать кнопку Make Tag Rewrites.

Шаблоны

Теперь адреса вида /tag/weight-loss обработаются нормально, только вот они пока нигде не используются. Нужно поправить шаблоны, которые выводят тэги. Это файлы cloud.phtml, list.phtml и popular.phtml в папке app\design\frontend\default\sunnyD\template\tag\. Вывод адреса тэга имеет такой вид:
<a href="<?php echo $_tag->getTaggedProductsUrl() ?>"><?php echo $this->htmlEscape($_tag->getName()) ?></a>
Нам нужно заменить вызов функции getTaggedProductsUrl на свой код:
<a href="<?php print trim(Mage::getBaseUrl(), '/') . '/tag/' . Slugify($_tag->getName()) ?>"><?php echo $this->htmlEscape($_tag->getName()) ?></a>
Готово! :) Реврайты созданы и работают, шаблоны выводят ЧПУ.

30 сентября 2009 г.

Проблемы при установке модуля BlogRewrites на сервер

На локальной машине всё работало, но перестало на сервере. Почему же? А потому что была лишняя заглавная буква в названиях.
  • Название модуля. Было BlogRewrite, надо Blogrewrite. Названия модулей можно делать только с одной заглавной буквой в начале, что бы потом не было неожиданных проблемм. Название модуля используется во всех названиях классов модуля, и кое-где в конфигах. Везде BlogRewrite надо заменить на Blogrewrite.
  • Название класса контроллера. Было Mage_BlogRewrite_Adminhtml_BlogRewriteController, надо Mage_Blogrewrite_Adminhtml_BlogrewriteController.
  • Имена файлов. В Windows файловая система регистро-независима, в линуксах - зависима (что за бред!). Поэтому нужно переименовать все файлы и папки где есть BlogRewrite в Blogrewrite. Например, app/code/local/Mage/BlogRewrite должно стать app/code/local/Mage/Blogrewrite.

После таких простых переименовываний (и столько потраченного времени), всё заработало.

Таким образом - во всех именах, модулей, контроллеров, действий, файлов, и т.д., нужно использовать только одну заглавную первую букву. Если использовать более одной заглавной буквы - когда-нибудь перестанет работать.

18 сентября 2009 г.

Интеграция Wordpress в Magento - ЧПУ, часть 2

После создания модуля, генерирующего реврайты для вордпресовских адресов, я подумал и решил, что такая идея не будет работать на 100%. Вот почему:
  • Кроме адресов постов, категорий, тэгов, и т.д., т.е. тех адресов, для которых мы делаем реврайты, есть другие адреса, такие, как action формы, отправляющей комментарий, адрес редактирования поста, адрес подписки на комментарии каждого поста. И это только те адреса, которые я заметил, при том все эти адреса ЧПУ, т.е. они просто не будут работать в Magento без реврайтов.
  • Некоторые страницы будут иметь так много постов, что они не будут влезать на одну страницу. Т.е. адреса "следующая страница с постами" не будут работать.
К счастью, я нашёл решение этих проблем, что не позволит выкинуть результат предыдущей работы с автосозданием реврайтов для блога :)

Тепер подробнее.

Проблема с ЧПУ

Сначала рассмотрим проблему со множеством ЧПУ.

Источник проблемы в том, что в настройках Wordpress указана ЧПУ схема адресов. Т.е. абсолютно все адреса, даже для которых мы не писали реврайты, будут ЧПУ. А зная, что такие адреса в Magento работать не будут, получается, что блог будет выдавать "страница не найдена" в неожиданных местах :)

Отсюда следует, что если поставить не-ЧПУ схему, все адреса станут не-ЧПУ, и начнут работать. Это и надо сделать.

Но мы же делали реврайты, т.е. некоторые адреса могут быть ЧПУ. Поэтому мы будем модифицировать шаблоны, выводящие адреса, меняя некоторые из них на ЧПУ.

Для этого я поступил не совсем красиво - вылез в файл index.php, что находится в самом корне Magento. Думаю, есть решение по-красивее, но я его не знаю :)

В этот файл я добавил следующий код (после включения хедеров вордпресса):
...
require_once $mageFilename;

define('WP_USE_THEMES', true);
require('wordpress/wp-blog-header.php');

// bof Blogrewrite module code
$oldPermalink = get_option('permalink_structure');

function ChangePermalink() {
    global $wp_rewrite, $oldPermalink;
    $wp_rewrite->set_permalink_structure("/%year%/%monthnum%/%postname%/");
}

function RestorePermalink() {
    global $wp_rewrite, $oldPermalink;
    $wp_rewrite->set_permalink_structure($oldPermalink);
}
// eof Blogrewrite module code

#Varien_Profiler::enable();
...
Т.е. сначала я запоминаю текущую схему адресов. Вообще-то это достаточно бесполезное занятие т.к. схема должна быть только "по-умолчанию", т.е. $oldPermalink == ''. Т.е. вместо запоминания текущей схемы можно использовать пустую строку. Сделал это, наверное, на будущее...

Затем я добавил две функции. Одна меняет схему адресов на ЧПУ (что автоматически меняет схемы адресов категорий, тэгов и т.д.), другая восставнавливает предыдущую схему (не-ЧПУ, пустая строка).

По вкусу можно добавить вызов RestorePermalink(); в конец файла :) Наверное так будет лучше.

Теперь, когда нам нужна ЧПУ схема, мы вызываем ChangePermalink(). Это мы будем делать, когда будем генерировать реврайты (т.к. для получения вордпресовских ссылок на посты/категории/и т.д. мы используем специальные вордпресовские функции, которые возвращают адреса, соответствующие текущей схеме). Так же мы будем вызывать эту функцию во время формирования блока с ссылками на посты, категории, тэги (который находится слева или справа). И последнее место - вывод контента - содержимого постов, список постов категории и т.д.

После того, как мы поработаем с ЧПУ, вызываем RestorePermalink() - восстанавливаем изменённую схему адресов.

Итак, файлы и код, которые используют эти функции:
  • Mage_Blogrewrite_Adminhtml_BlogrewriteController::makerewritesAction()

    Где-нибудь вначале метода нужно вызвать ChangePermalink, где-нибудь в конце - RestorePermalink.
  • Шаблон блока со списком постов/категорий/и т.д. находится в файле app/design/frontend/default/sunnyD/template/blog/menu.phtml. Т.к. мы создали реврайты для всех ссылок, которые выводятся в этом файле, то вызываем ChangePermalink в начале файла, RestorePermalink - в конце.
  • wordpress/wp-content/themes/Wordpress-theme/magento/magento.php

    Это файл-шаблон, используемый для отображения "контента", содержимого блога - списка постов, отдельного поста. Выводится по-середине страницы :)

    Те адреса, для которых мы писали реврайты, мы выводим с включёнными ЧПУ. Остальные адреса - с выключенными ЧПУ.

    Например, мы знаем, что ссылка на пост может быть ЧПУ. Поэтому код может выглядеть так:
    <?php wp_reset_query(); ?>
    
    <?php if (have_posts()) : ?>
    
      <?php while (have_posts()) : the_post(); ?>
    
       <div class="post" id="post-<?php the_ID(); ?>">
                    <?php ChangePermalink(); ?>
        <h2><a href="<?php the_permalink() ?>" rel="bookmark" title="Permanent Link to <?php the_title_attribute(); ?>"><?php the_title(); ?></a></h2>
                    <?php RestorePermalink(); ?>
    ...

Pagination

Ремонтирование разбиения на страницы оказалось проще. Когда мы получаем список категорий, постов, тэгов, месячных архивов, мы можем узнать количество постов, удовлетворяющих выбранному критерию. Ещё мы можем узнать выводимое количество постов на странице:
$postsPerPage = get_option('posts_per_page');
А, зная это, мы легко можем подсчитать количество страниц, необходимое для отображения всех постов, удовлетворяющих выбранному критерию, и добавить соответствующих реврайтов:
$pagesCount = ceil($totalPostsCount / $postsPerPage);
Вот как был модифицирован код:
  • Для категорий:
    // Update rewrites for categories
    $cats = get_categories();
    foreach ($cats as $_c) {
        $this->_makeRewrite('blog_cat/' . $_c->term_id,
            trim(substr(get_category_link($_c->term_id), strlen($baseUrl)), '/'),
            'blog/index/index/cat/' . $_c->term_id);
    
        // Also add rewrites for other pages, e.g. page/2, page/3 etc
        for ($i = 2; $i <= ceil($_c->count / $postsPerPage); $i++) {
            $pages++;
            $this->_makeRewrite('blog_cat/' . $_c->term_id . '/page/' . $i,
                trim(substr(get_category_link($_c->term_id), strlen($baseUrl)), '/') . "/page/$i",
                'blog/index/index/cat/' . $_c->term_id . '/page/' . $i);
        }
    }
  • Для тэгов:
    // Update rewrites for tags
    $tags = get_tags();
    foreach ($tags as $_t) {
        $this->_makeRewrite('blog_tag/' . $_t->term_id,
            trim(substr(get_tag_link($_t->term_id), strlen($baseUrl)), '/'),
            'blog/index/index/tag/' . $_t->term_id);
    
        // Also add rewrites for other pages, e.g. page/2, page/3 etc
        for ($i = 2; $i <= ceil($_t->count / $postsPerPage); $i++) {
            $pages++;
            $this->_makeRewrite('blog_tag/' . $_t->term_id . '/page/' . $i,
                trim(substr(get_category_link($_t->term_id), strlen($baseUrl)), '/') . "/page/$i",
                'blog/index/index/tag/' . $_t->term_id . '/page/' . $i);
        }
    }
  • Для месячных архивов:
    $query = "SELECT DISTINCT YEAR(post_date) AS `year`, MONTH(post_date) AS `month`, count(ID) as posts FROM $wpdb->posts $join $where GROUP BY YEAR(post_date), MONTH(post_date) ORDER BY post_date DESC $limit";
    $archive = $wpdb->get_results($query);
    if ($archive) {
        $afterafter = $after;
        foreach ((array) $archive as $_a) {
            $id = sprintf("%4d%02d", $_a->year, $_a->month);
    
            $this->_makeRewrite('blog_month/' . $id,
                trim(substr(get_month_link($_a->year, $_a->month), strlen($baseUrl)), '/'),
                'blog/index/index/m/' . $id);
    
            // Also add rewrites for other pages, e.g. page/2, page/3 etc
            for ($i = 2; $i <= ceil($_a->posts / $postsPerPage); $i++) {
                $pages++;
                $this->_makeRewrite('blog_month/' . $id . '/page/' . $i,
                    trim(substr(get_month_link($_a->year, $_a->month), strlen($baseUrl)), '/') . "/page/$i",
                    'blog/index/index/m/' . $id . '/page/' . $i);
            }
        }
    }

Итог

Итак:
  • Выставляем схему адресов в Wordpress "по-умолчанию". Это гарантирует нам, что любые ссылки, сгенерированне вордпрессом будут работать сами по себе.
  • Во время генерации реврайтов для постов/категорий/тэгов/месячных архивов устанавливаем схему адресов в ЧПУ. Генерируем реврайты, не забываем про "многостраничные страницы". Благодаря реврайтам пользователь сможет зайти на блог по ЧПУ (это относится к постам, категориям, тэгам, месячным архивам).
  • Модифицируем шаблоны, генерирующие вордпресовские адреса, включая ЧПУ схему для тех элементов, для которых мы создали реврайт на предыдущем шаге, выключая ЧПУ для тех элементов, для которых реврайтов нет
В итоге в основном пользователь будет видеть ЧПУ. Остальные адреса, для которых мы не сделали реврайтов, будут не-ЧПУ. Т.е. теперь блог будет 100% рабочий, разве что не 100% ЧПУ.

PS. Ну и недостаток, который есть у данной схемы: смена структуры адресов не самая быстрая операция. При этом она может выполняться несколько десятков раз (при отображении 20 постов на одной странице смен будет 20 * 3 = 60). Ну, Magento сама по себе весьма не скоростная, будем надеяться эти новые смены никто не заметит :)

PPS. Ещё я добавил загрузку реврайта по Request Path, если реврайт не найден по Id Path. Это сделано для того, что бы код без проблем заработал в магазине, где уже есть некоторые реврайты, добавленные вручную.
protected function _makeRewrite($idPath, $requestPath, $targetPath, $permamentRedirect = false) {
    $this->_urlRewrite->loadByIdPath($idPath);
    if (!$this->_urlRewrite->getId())
        $this->_urlRewrite->loadByRequestPath($requestPath);
    ...
}
Код попытается найти реврайт по Id Path, скорее всего не найдёт (Id Path заполнялся вручную и не совпадает с генерируемым нашим модулем). Если затем не попробовать загрузить реврайт по Request Path, Magento упадёт при попытке создать новый реврайт с тем же Request Path, который уже есть в системе (Request Path однозначно идентифицирует реврайт, это уникальное поле). Добавляя дополнительную загрузку по Request Path мы уберегаем себя от подобных падений - вместо падения Magento загрузит уже существующий, добавленный вручную реврайт, и обновит его.

16 сентября 2009 г.

Простой бэкап, Python + DropBox, часть 2

Новая версия скрипта для бэкапа. Или другая версия...

Логика работы с файлами-списками не поменялась. Поменялся только параметр скрипта - теперь это имя архива без расширения, скрипт сам добавит .tar.gz.

Новое:
  • Создание архива переместилось из внешнего архиватора внутрь скрипта. Создаётся tar.gz архив.
  • Все пути теперь системо-зависимы, т.е. скрипт должен работать на линуксах тоже
Кроме приятного нового, повились неприятный минус - скрипт стал жрать кучу памяти.

Сделать сжатый архив с помощью модуля tarfile не вышло. Архив создаётся и сжимается, но внтури архива пути почему-то съезжают.

Т.е. нужно создать архив d:\dropbox\my dropbox\backup\backup.tar.gz, он там появляется. Но если открыть этот gz архив, tar архив будет глубоко внутри по пути dropbox\my dropbox\backup\backup.tar. Т.е. полный путь до данных получается такой: d:\dropbox\my dropbox\backup\backup.tar.gz\dropbox\my dropbox\backup\backup.tar\.... В принципе с этим можно жить, но некрасиво...

Поэтому сделал по-другому - сначала создаётся tar архив без сжатия со всеми файлами, затем он сжимается. Вот в этом месте функции, сжимающей архив, он, архив, передаётся целиком, т.е. весь файл весом несколько сот Мб засасывается в память.

Надо бы ещё покопать в эту сторону и найти лучшее решение...
import sys
import os
import os.path
import tarfile
import time
import gzip

__author__="race1"
__date__ ="$09.09.2009 19:55:06$"

if __name__ == "__main__":
    if (len(sys.argv) < 2):
        print("Usage: backup.py ")
        print("E.g.: \"backup.py d:\\dropbox\\my dropbox\\backup\"")
        exit(1)

    RootFoldersFile = "tobackup.lst"
    IgnoreFoldersFile = "ignore.lst"
    ExtraFile = "extra.lst"
    OutputArchive = sys.argv[1]
    OutputArchiveTar = OutputArchive + ".tar"
    OutputArchiveTarGz = OutputArchive + ".tar.gz"

    # Only mandatory file is RootFoldersFile, check that it exists
    if (not os.path.isfile(RootFoldersFile)):
        print("%s doesn't exist" % RootFoldersFile)
        exit(1)

    # Read root folders from file
    rootFolders = [i.strip() for i in open(RootFoldersFile, "r").readlines()]

    # Read list of folders that need to be igonred (if specified)
    ignoreList = []
    if (os.path.isfile(IgnoreFoldersFile)):
        ignoreList = [i.strip().lower() for i in open(IgnoreFoldersFile, "r").readlines()]

    # Delete old archive if exists
    if (os.path.isfile(OutputArchiveTar)):
        os.unlink(OutputArchiveTar)

    if (os.path.isfile(OutputArchiveTarGz)):
        os.unlink(OutputArchiveTarGz)

    filesCount = 0

    # Open output archive for writing (we'll gzip it later)
    try:
        tar = tarfile.open(OutputArchiveTar, "w")
    except IOError as err:
        print(err)
        exit(1)

    lastTime = time.time()
    for rootFolder in rootFolders:
        for root, dirs, files in os.walk(rootFolder):
            for file in files:
                tar.add(os.path.join(root, file))
                filesCount += 1

                # Show number of processed files each second
                if (time.time() - lastTime >= 1):
                    print("%d files..." % (filesCount))
                    lastTime = time.time()

            # Skip ignored folders
            for dir in dirs:                
                if (dir.lower() in ignoreList or (os.path.join(root, dir).lower() in ignoreList)):
                    dirs.remove(dir)

    # Add some files from extra files list
    if (os.path.isfile(ExtraFile)):
        for file in [i.strip() for i in open(ExtraFile, "r").readlines()]:
            tar.add(file)
            
    tar.close()

    print("Compressing...")

    # Gzip output archive
    try:
        gz = gzip.open(OutputArchiveTarGz, "wb")
    except IOError as err:
        print(err)
        exit(1)
    
    gz.write(open(OutputArchiveTar, "rb").read())
    gz.close()

    os.unlink(OutputArchiveTar)
        
    print("Output: %s, %d file(s), %d bytes" % (OutputArchiveTarGz, filesCount, os.path.getsize(OutputArchiveTarGz)))
    print("Done")

15 сентября 2009 г.

Интеграция Wordpress в Magento - ЧПУ

Уже давно у нас в в Magento был внедрён Wordpress, специальным модулем-плагином для Magento. Но, используя такой метод внедрения, есть проблема с адресами в Wordpress - можно использовать только простые адреса, вида ?p=<номер поста>, ?cat=<категория> и т.д. Это связано с тем, что обработку всех адресов берёт на себя Magento, т.е. все адреса должны быть в специальном формате - <module name>/<controller name>/<controller action>[/<param1>/<value1>/<param2>/<value2>...]. Если же в Wordpress включить ЧПУ адреса, например, вида <year>/<month>/<post title>, такой адрес придёт в Magento, которая попытается найти модуль <year>, в нём найти контроллер <month>, и выполнить метод <post title>. Естественно ничего такого в Magento нет, поэтому будет ошибка "страница не найдена".

Тем не менее, в Magento можно использовать ЧПУ. Для этого надо писать реврайты. Magento сама создаёт по реврайту на каждый продукт и категорию. Rewrite говорит, какой произвольный адрес перенаправить на какой корректный "Magento-адрес". Например, адрес /benq-2220hd нужно заменить адресом /catalog/product/view/id/496.

Реврайты бывают "внтуренние" и "с редиректом". Внутренние работают внутри :) Т.е. пользователь ввёл один адрес, он видит его в адресной строке, а Magento внутри себя вызывает другой адрес для обработки (более точно - другое действие другого контроллера). Реврайт с редиректом наоборот сразу бросается в глаза - браузер пользователя перенаправляется с одного адреса на другой.

Но даже с реврайтами есть проблема. Модуль, использующийся для интеграции Wordpress в Magento, не позволял использовать ЧПУ. Я его немного переделал, и теперь стало возможным написать адрес вида blog/index/index/param1/value1/param2/value2, и эти параметры будут переданы Wordpress, который на их основе выполнит запрос. Т.е. раньше приходилось писать реврайты с редиректом на не-ЧПУ адрес (например, на /blog/?p=123), сейчас же можно писать "внутренние" реврайты. А подсмотреть, какие нужно передавать параметры, можно в документации на Wordpress. Т.е., если перейти по адресу /blog/index/index/p/123, Wordpress покажет пост с id=123. Кроме p есть другие параметры, cat - для категорий, tag - для тэгов, m - для архивов, и т.д.

Сначала мы писали реврайты для постов вручную. Но это очень неудобно, медленно, не расширяемо и т.д., и я подумал что это дело можно автоматизировать. Сейчас будем этим заниматься :)

Мы напишем модуль, который сам будет создавать реврайты на все посты/категории/тэги/архивы блога/rss. Делать это будет специальная волшебная кнопка "пыщь" в админке :)

Создание модуля

Создадим модуль Blogrewrite в пространстве имён Mage. Что бы сделать свою страницу в админке нужно иметь такой config.xml:
<?xml version="1.0"?>
<config>
    <global>
        <helpers>
            <Blogrewrite>
                <class>Mage_Blogrewrite_Helper</class>
            </Blogrewrite>
        </helpers>
    </global>

    <admin>
        <routers>
            <Blogrewrite>
                <use>admin</use>
                <args>
                    <module>Mage_Blogrewrite</module>
                    <frontName>Blogrewrite</frontName>
                </args>
            </Blogrewrite>
        </routers>
    </admin>

    <adminhtml>
        <menu>
            <catalog module="catalog">

            <children>
                <Blogrewrite translate="title" module="Blogrewrite">
                    <title>Blog Rewrite</title>
                    <action>Blogrewrite/adminhtml_Blogrewrite</action>
                </Blogrewrite>
            </children>

            </catalog>
        </menu>
    </adminhtml>
</config>
В секции admin мы говорим, что наш модуль будет доступен по адресу Blogrewrite (www.example.com/Blogrewrite). Т.о. контроллер нашего модуля - Mage_Blogrewrite_Adminhtml_BlogrewriteController.

В секции adminhtml мы добавляем новый пункт меню, в меню Catalog, и говорим, какое действие нужно выполнить, когда пользователь нажмёт на этом пункте меню - модуль Blogrewrite, контроллер Mage_Blogrewrite_Adminhtml_BlogrewriteController, действие по-умолчанию - index.

Действие index всего лишь отображает блок Mage_Blogrewrite_Block_Adminhtml_Blogrewrite:
public function indexAction() {
    $this->_initAction();
    $this->getLayout()->getBlock('head')
         ->setCanLoadRulesJs(true);
    $this->_addContent($this->getLayout()->createBlock('Blogrewrite/adminhtml_Blogrewrite')
         ->setCanLoadRulesJs(true));
    $this->renderLayout();
}
Этот блок находится в файле Block/Adminhtml/Blogrewrite.php:
<?php
class Mage_Blogrewrite_Block_Adminhtml_Blogrewrite extends Mage_Adminhtml_Block_Template {
    public function __construct() {
        parent::__construct();
        $this->setTemplate('Blogrewrite/index.phtml');
    }
}
Блок всего лишь выводит шаблонный файл index.phtml, который должен быть в app/design/adminhtml/default/default/template/Blogrewrite/index.phtml. В шаблоне - одна кнопка, которая вызывает действие makerewrites нашего контроллера:
<div class="content-header">
    <table cellspacing="0">
        <tr>
            <td>
                <h3 class="head-dashboard"><?php echo $this->__('Blog Rewrite') ?></h3>
            </td>
        </tr>
    </table>
</div>

<form action="<?php print $this->getUrl('*/*/makerewrites'); ?>">
    <button type="submit">Make Blog Rewrites</button>
</form>
Итак, модуль готов, он виден в админке, форма с кнопкой отображается. Надо делать собственно добавление реврайтов

Создаём rewrites

Как создать реврайт? Для этого есть модель core/url_rewrite, мы можем получить её так:
Mage::getModel('core/url_rewrite');
Можно загрузить данные из базы по некоторым параметрам, нам вполне хватит загрузки по Id Path:
Mage::getModel('core/url_rewrite')->loadByIdPath($idPath);
Если запись с таким Id Path есть в базе, она загрузится в модель, иначе нет :)

После того как мы загрузили данные из базы (или если данных нет), нужно задать параметры реврайта:
Mage::getModel('core/url_rewrite')
    ->loadByIdPath($idPath)
    ->setIdPath($idPath)
    ->setRequestPath($requestPath)
    ->setTargetPath($targetPath)
    ->setDescription('Automagically generated, Blogrewrite module')
    ->setIsSystem(0);
И, наконец, сохраняем реврайт:
->save();
Для удобства вынесем создание/обновление реврайта в отдельную функцию:
protected function _makeRewrite($idPath, $requestPath, $targetPath, $permamentRedirect = false) {
    $this->_urlRewrite->loadByIdPath($idPath);
    $this->_urlRewrite->setIdPath($idPath)
          ->setRequestPath($requestPath)
          ->setTargetPath($targetPath)
          ->setOptions($permamentRedirect ? 'RP' : '')
          ->setDescription('Automagically generated, Blogrewrite module')
          ->setIsSystem(0);
    $this->_urlRewrite->save();
}
Ещё я добавил новый параметр $permanentRedirect, который, соответственно, делает реврайт редиректом (по-умолчанию реврайты создаются "внутренними").

Если сохранить реврайт так, как выше, без указания магазина (Store Id), он добавится для всех магазинов. Это хорошо :)

makerewritesAction

Создание реврайтов будет происходить в действии makerewrites контроллера Mage_Blogrewrite_Adminhtml_BlogrewriteController:
<?php
class Mage_Blogrewrite_Adminhtml_BlogrewriteController extends Mage_Adminhtml_Controller_Action {
...
    public function makerewritesAction() {
...
    }
...
}
?>
Что бы создать реврайт, нужно знать, для чего его создавать. Т.е. нужно перебрать все посты, категории, архивы, и т.д., узнать их адреса, и создать по реврайту, перенаправляя эти адреса на правильные.

Сейчас будет в основном Wordpress часть.

Wordpress

Мы будем пользоваться функциями Wordpress для получения ЧПУ постов/категорий и т.д. Отсюда следует два замечения:
  • Пермалинки в настройках Wordpress должны быть настроены на ЧПУ. Иначе мы будем делать ревайты на адреса вида ?p=123. Нам этого не надо, эти адреса и так работают.

    С другой стороны, схема ЧПУ может быть любой. Всё что нужно сделать после смены вида пермалинков - нажать кнопку "пыщь" на странице нашего модуля :)
  • Все функции, возвращающие адреса, возвращают полные, абсолютные адреса. Реврайты же нужно создавать на относительные адреса (иначе они просто не будут работать). Поэтому нужно удалять доменную часть. Для этого получаем базовый адрес Magento - вызываем метод Mage::getBaseUrl(Mage_Core_Model_Store::URL_TYPE_WEB), и получаем, например, http://www.example.com/. Далее при получении адреса для реврайта, будем отсекать эту часть:
    trim(substr(get_permalink(), strlen($baseUrl)), '/')
    Кроме того, нужно убрать последний слэш, без него реврайты тоже не сработают. trim это делает.

Перебор постов

Для перебора постов можно воспользоваться "циклом", loop. Но по-умолчанию Wordpress загружает лишь несколько постов в цикл. А нам надо все. Это делается вызовом метода query_posts. После этого запускаем цикл, перебирающий все посты. На каждой итерации добавляем/обновляем реврайт:
// Query all published posts
query_posts(array('post_status' => 'publish', 'showposts' => -1));
while (have_posts()) {
    the_post();

    // get_permalink returns full absolute url. We need to remove domain info.
    // Also remove trailing slash, it's important.
    // E.g. was http://www.example.com/blog/2009/02/title
    //   become                                             blog/2009/02/title
    $this->_makeRewrite('blog/' . get_the_ID(),
        trim(substr(get_permalink(), strlen($blogUrl) - 4), '/'),
        'blog/index/index/p/' . get_the_ID());

    $posts++;
}

Перебор категорий

Для получения всех категорий достаточно вызвать функцию get_categories. Для получения адреса категории есть функция get_category_link.
// Update rewrites for categories
$cats = get_categories();
foreach ($cats as $_c) {
    $this->_makeRewrite('blog_cat/' . $_c->term_id,
        trim(substr(get_category_link($_c->term_id), strlen($blogUrl) - 4), '/'),
        'blog/index/index/cat/' . $_c->term_id);
}

Перебор тэгов

Тэги так же доступны вызовом всего одной функции get_tags, а для получения адреса есть функция get_tag_url:
// Update rewrites for tags
$tags = get_tags();
foreach ($tags as $_t) {
    $this->_makeRewrite('blog_tag/' . $_t->term_id,
        trim(substr(get_tag_link($_t->term_id), strlen($blogUrl) - 4), '/'),
        'blog/index/index/tag/' . $_t->term_id);
}

Перебор архивов

Я посмотрел, как архивы выводятся у нас на сайте. Это задаётся в шаблонном файле app/design/frontend/default/sunnyD/template/blog/menu.phtml, а именно - вызов функции wp_get_archives('type=monthly'). Эта функция возвращает уже отформатированный html. К сожалению, нет нормального способа выбрать нужные мне ссылки. Пришлось скопировать код этой функции к себе и немного его переделать:
// Update rewrites for archive (from Wordpress core file wp-includes\general-template.php,
// function wp_get_archives, from line 753)
global $wpdb;
$defaults = array(
    'type' => 'monthly', 'limit' => '',
    'format' => 'html', 'before' => '',
    'after' => '', 'show_post_count' => false,
    'echo' => 1
);
$r = wp_parse_args('', $defaults);
$where = apply_filters('getarchives_where', "WHERE post_type = 'post' AND post_status = 'publish'", $r);
$join = apply_filters('getarchives_join', "", $r);
$query = "SELECT DISTINCT YEAR(post_date) AS `year`, MONTH(post_date) AS `month`, count(ID) as posts FROM $wpdb->posts $join $where GROUP BY YEAR(post_date), MONTH(post_date) ORDER BY post_date DESC $limit";
$archive = $wpdb->get_results($query);
if ($archive) {
    $afterafter = $after;
    foreach ((array) $archive as $_a) {
        $this->_makeRewrite('blog_month/' . $_a->year . $_a->month,
            trim(substr(get_month_link($_a->year, $_a->month), strlen($blogUrl) - 4), '/'),
            'blog/index/index/m/' . $_a->year . $_a->month);
    }
}

RSS

Последние ссылки без реврайтов - подписка на новости. Их две - подписка на новые посты, и новые комментарии.
$feeds = trim(substr(get_bloginfo('rss2_url'), strlen($baseUrl)), '/');
$feedsComments = trim(substr(get_bloginfo('comments_rss2_url'), strlen($baseUrl)), '/');

$this->_makeRewrite('blog_feeds', $feeds, 'blog/index/index/feed/rss2');
$this->_makeRewrite('blog_comments_feeds', $feedsComments, 'blog/index/index/feed/comments-rss2');

Заключение

Итак, мы сделали по реврайту на посты, категории, тэги, месячные архивы и rss. Теперь пользователи будут видеть ЧПУ, относящиеся к блогу, в адресной строке браузера. Так же адреса, связанные с Wordpress, выводимые на странице магазина, будут ЧПУ (т.к. это указано в настройках Wordpress).

Но всё же есть одна неприятность. Страницы просмотра категории, или тэга, могут иметь несколько страниц. Например, blog/my-category/page/2. Такой адрес выдаст страницу "не найдено" :(