уважаемые посетители блога, если Вам понравилась, то, пожалуйста, помогите автору с лечением. Подробности тут.

 

Продолжаю копаться в Google Documents List API. И сегодня на повестке дня – работа с деревом каталогов и документов Google Docs. В силу того, что список документов Google представляет собой ни что иное как хранилище данных GData или, говоря проще, большую базу данных, этот список имеет ряд особенностей в плане расположения и структурирования данных нежели простой каталог с документами на жестком диске компьютера.

Введение

В чем принципиальное отличие? Дело в том, что как и в любой базе данных, любую запись в хранилище Google можно однозначно идентифицировать по ключу. В Google Docs ключом может выступать как Resource ID (идентификатор ресурса) так и уникальное значение – ETag. И это обстоятельство дает нам такую редко используемую на практике, но тем не менее возможность, как создание на одном уровне вложенности нескольких документов или папок (коллекций) с одним и тем же названием, что. как известно, невозможно сделать стандартными средствами той же ОС Windows. Например, можно создать вот такую структуру коллекций документов:

GDocsCollections

Так же само дело обстоит и с документами – можем создать два документа любого типа с одним и тем же названием в одной коллекции или в корне аккаунта. Собственно об этой особенности Google Docs я уже один раз упоминал в блоге. Возникает вполне резонный вопрос – как быть при построении такого дерева каталогов и документов?

Кажущийся на первый взгляд очевидным вариант – распознавать документ по его уникальному идентификатору на самом деле имеет несколько “подводных камней” с которыми так или иначе приходится сталкиваться и решать самыми различными способами, например, когда требуется полная синхронизация документов и каталогов между аккаунтом и локальной директорией. Однако эти проблемы никак не коснуться нас в случае, если нам необходимо решить только такую задачу как построение дерева каталогов и документов Google Docs – в этом случае Resource ID документа будет более, чем достаточно. Вот решением этой задачи мы и займемся сегодня.

1. Получаем исходные данные для построения дерева Google Docs.

Итак, что нам крайне необходимо для работы с деревом?

Во-первых, это значение Resource ID по которому мы будем распознавать элементы дерева.

Во-вторых, название элемента – его мы будем использовать для отображения элемента в дереве.

В-третьих, тип элемента. Этот параметр не столь значителен как первые два, но, тем не менее, в случае необходимости, его можно будет использовать, например, для того, чтобы присвоить узлу дерева определенное изображение из ImageList или правильно сформировать ссылку для скачивания документа и т.д.

В-четвертых, ID родительского элемента – по нему мы будем узнавать куда вставить тот или иной узел в дереве.

Этих значений нам будет достаточно. Добавить другие данные при необходимости не составит после сегодняшней работы никакого труда.

Естественно, для доступа к аккаунту нам понадобится компонент для ClientLogin, скачать который можно из репозитория GitHub.

Теперь приступим к реализации. В начале, как и положено, обратимся к официальной документации по API и посмотрим, что представляет из себя фид списка документов.

Все данные, которые нам будут необходимы, содержаться в элементах entry. Вот с ними мы и будем сейчас плотно работать. Про то как получить список документов Google Docs я также упоминал. Однако сегодня мы напишем класс без использования NativeXML и включим в него только те данные, которые нам необходимы для работы.

Класс назовем — TGoogleDoc:

uses ...xmlintf, xmldoc, TypInfo, StrUtils, SynaCode{Synapse}...
 
type
  TDocType = (dtUnknown, dtDocument, dtPdf, dtFolder, dtPresentation,
    dtSpreadsheet, dtForm, dtFile, dtDrawing);
 
TGoogleDoc = class
  private
    FTitle: string;
    FID: string;
    FParentID: string;
    FDocType: TDocType;
  public
    constructor Create(const AEntry: IXMLNode);
    destructor Destroy; override;
    procedure ParseXML(const AEntry: IXMLNode);
    property Title: string read FTitle write FTitle;
    property ID: string read FID;
    property ParentID: string read FParentID;
    property DocType: TDocType read FDocType;
  end;

При создании в конструктор класса передается узел AEntry:IXMLNode. Если предоставленный узел удовлетворяет требованиям то есть значение не равно nil , то запускается процедура ParseXML, которая пробует разобрать все дочерние узлы и заполнить поля класса необходимыми значениями. В противном случае — Вы всегда имеете возможность вызвать метод ParseXML из своей программы.

Процедура ParseXML выглядит следующим образом:

procedure TGoogleDoc.ParseXML(const AEntry: IXMLNode);
 
  function GetDocType(const ResourceID: string): TDocType;
  var
    ParamStr: string;
    ID: Integer;
  begin
    ParamStr := copy(ResourceID, 1, pos(':', ResourceID) - 1);
    ID := GetEnumValue(TypeInfo(TDocType), 'dt' + ParamStr);
    if ID > -1 then
      Result := TDocType(ID)
    else
      Result := dtUnknown;
  end;
 
var
  Node: IXMLNode;
 
begin
  if (AEntry = nil) or (AEntry.NodeName <> cEntryTag) then
    Exit;
  try
    Node := AEntry.ChildNodes.First;
    while Assigned(Node) do
    begin
      if Node.NodeName = cTitleTag then
        FTitle := Node.Text
      else if Node.NodeName = cResourceIDTag then
      begin
        FID := Node.Text;
        FDocType := GetDocType(FID);
      end
      else
        if Node.NodeName='link' then
          begin
            if Node.Attributes['rel']='http://schemas.google.com/docs/2007#parent' then
               begin
                 FParentID := ReverseString(Node.Attributes['href']);
                 FParentID := DecodeURL(ReverseString(copy(FParentID, 1, pos('/', FParentID) - 1)));
               end;
          end;
      Node := Node.NextSibling;
    end;
  except
    on E: EXMLDocError do
      raise E.CreateFmt(rsParseError, [E.Message])
    else
      raise Exception.Create(rsUnknownError);
  end;
end;

Здесь во вложенной функции GetDocType происходит преобразование строкового значения (Resource ID) к типу документа TDocType. В остальном процедура принимает в параметре XML-узел и, проходя по всех дочерним элементам узла, заполняет необходимые поля класса.

Что касается конструктора класса, то он достаточно прост:

constructor TGoogleDoc.Create(const AEntry: IXMLNode);
begin
  inherited Create;
  if AEntry<>nil then
    ParseXML(AEntry);
end;

Можно сказать, что заготовка для работы с Google Docs у нас готова. Теперь, если Вам потребуется, чтобы в классе TGoogleDoc хранились другие сведения по документу, например, ссылки на скачивание/редактирование или информация по автору документа, то создавайте необходимые поля и дополняйте метод ParseXML — эти действия никак не нарушат работу с нашим будущим деревом, а даже наоборот — расширят возможности.

Но хранить каждый элемент списка в отдельной переменной было бы расточительно и малоэффективно – требуется какая-нибудь структура наподобие списка или массива в которой будет хранится весь список Google Docs. Так как на дворе уже Delphi XE, а забивать статью лишним кодом мне бы не хотелось, то я решил воспользоваться в дальнейшей работе не списками типа TList, а таким относительно новым средством как дженерики. Может это и будет выглядеть как стрельбы из пушки по воробьям, но место и время на разработку сэкономит точно. Опять же, если кому-то потребуется, то дописать несколько методов к классу и сделать простой TList, думаю, не затруднит.

Итак, создадим класс списка документов, который будет заполняться на основании полученного из сети фида. Назовем класс TGoogleDocList:

TGoogleDocList = class(TList)
  public
    constructor Create;
    destructor Destroy; override;
    procedure FillFromXML(XMLStream: TStream);
  end;

В этом классе определен метод FillFromXML в параметре которого необходимо передать любой поток, содержащий XML-данные (фид списка документов) — на основании содержимого этого потока будет заполняться наш список:

procedure TGoogleDocList.FillFromXML(XMLStream: TStream);
var
  xmldoc: IXMLDocument;
  Node: IXMLNode;
  i: Integer;
begin
  if not Assigned(XMLStream) or (XMLStream.Size = 0) then
    Exit;
  xmldoc := TXMLDocument.Create(nil);
  try
    xmldoc.LoadFromStream(XMLStream, xetUTF_8);
    if xmldoc.IsEmptyDoc then
      Exit;
    for i := 0 to xmldoc.DocumentElement.ChildNodes.Count - 1 do
    begin
      Node := xmldoc.DocumentElement.ChildNodes.GET(i);
      if Node.NodeName = cEntryTag then
        self.Add(TGoogleDoc.Create(Node));
    end;
  finally
    xmldoc := nil
  end;
end;
 
constructor TGoogleDocList.Create;
begin
  inherited Create;
end;
 
destructor TGoogleDocList.Destroy;
begin
  Clear;
  inherited Destroy;
end;

Как видите, с дженериком код оказался довольно скромным. Кому интересно, можете почитать целую серию переводов про дженерики в блоге Алексея Тимохина — очень полезная информация.

Класс документа есть, список для хранения документов есть, переходим к следующему шагу – непосредственно получению списка от Google. В принципе, здесь Вы можете определиться сами какие библиотеки использовать, как организовывать работу с запросами и т.д. Для сохранения целостности статьи я не поленился и написал ещё один небольшой класс для отправки запросов, который, опять же в случае надобности, Вы сможете потом приспособить под отправку любых запросов к API. Класс использует в работе библиотеку Synapse и называется TGoogleRequest:

TGoogleRequest = class
  private
    FAuthKey: string;
    FHTTP: THTTPSend;
    procedure SetAuthKey(const Value: string);
  public
    constructor Create;
    destructor Destroy; override;
    procedure GET(const URL: string; var Stream: TStream);
    property AuthKey: string read FAuthKey write SetAuthKey;
  end;

В классе, кроме конструктора и деструктора определены всего одно свойство и два метода один из которых выполняет необходимый нам GET-запрос:

procedure TGoogleRequest.GET(const URL: string; var Stream: TStream);
begin
  FHTTP.Clear;
  FHTTP.Headers.Add('GData-Version: ' + cGDataVersion);
  FHTTP.Headers.Add('Authorization: GoogleLogin auth=' + FAuthKey);
  if FHTTP.HTTPMethod('GET', URL) and (FHTTP.ResultCode = 200) then
    Stream.CopyFrom(FHTTP.Document, FHTTP.Document.Size)
  else
    raise Exception.CreateFmt(rsGETError,
      [IntToStr(FHTTP.ResultCode) + ' ' + FHTTP.ResultString]);
end;

Вот теперь можно сказать, что все исходные данные для построения дерева документов Google Docs у нас есть. Подведем небольшой итог первой части работы (ниже будет рассмотрен пример). На данном этапе работы у нас есть:

  1. Компонент ClientLogin для получения ключа авторизации в сервисе Google Docs
  2. Класс TGoogleRequest, который, используя этот ключ выполняет GET-запросы для получения списка документов
  3. Класс для хранения данных по документу – TGoogleDoc.
  4. Класс для хранения списка документов – TGoogleDocList.

Теперь можно сохранить весь код в отдельный модуль с названием GoogleDocument и перейти к части построения дерева.

2. Пишем свой компонент для дерева документов Google Docs

2129Какими свойствами должно обладать наше дерево? Как минимум быть таким же пушистым и зеленым как на рисунке.

Если Вы обратите внимание на рисунок выше (с коллекциями) то увидите, что корневым элементом дерева всегда будет коллекция с названием “Мои коллекции”. Что ж и мы предусмотрим такую возможность – пусть в нашем дереве будет не только всегда фигурировать корневой элемент, но и название этого элемента мы будем задавать самостоятельно.

Также, в аккаунте Google Docs коллекции всегда отображаются в дереве, а файлы – списком. Наше дерево будет обладать свойствами и списка Google Docs и свойствами дерева коллекций, то есть предусмотрим три режима построения дерева:

  • только коллекции
  • только файлы
  • всё содержимое аккаунта

И третьей особенностью пусть будет то, что наше дерево будет хранить в своих узлах не только название документа, но и все данные по документу, включая ID, тип документа и т.д. – всё, что вы захотите хранить в классе TGoogleDoc. Это позволит нам в любой момент получать необходимую информацию по элементу списка без лишний поисков в списке документов – буквально в один клик.

Итак, добавляем в наш проект новый модуль. Назовем его GoogleTree. В uses подключаем следующие модули: SysUtils, Windows, Controls, Classes, ComCtrls, GoogleDocument.

Создаем класс наследник от TCustomTreeView со следующими полями и свойствами:

type
TGoogleVirtualTree = class(TCustomTreeView)
  private
    FRootName: string;
    FDocuments: TGoogleDocList;
    FAssigned: boolean;
    FMode: TTreeMode;
    procedure SetRootName(Value: string);
    procedure SetMode(Value: TTreeMode);
    procedure GenerateTree(Path: string);
    function FindFolder(const aParentID: string; var aDocument: TGoogleDoc;
      Offset: Integer = 0): Integer;
    procedure Setup;
  public
    constructor Create(AOwner: TComponent); override;
    destructor Destroy; override;
    procedure AssignDocuments(ADocuments: TGoogleDocList);
    property Items;
  published
    ...
    property Mode: TTreeMode read FMode write SetMode;
    property RootName: string read FRootName write SetRootName;
  end;

Разберемся, что и для чего здесь определено.

Свойство RootName: string — задает имя корневого элемента дерева. При присвоении какого-либо значения этому свойству корневой узел в дереве создается автоматически в методе SetRootName:

procedure TGoogleVirtualTree.SetRootName(Value: string);
begin
  if Length(Trim(Value))=0 then
    FRootName:=rsRootFolder
  else
    FRootName := Value;
  if Assigned(TopItem) then
    TopItem.Text := FRootName
  else
  begin
    try
      Self.Items.Add(TTreeNode.Create(Self.Items), RootName);
      with Self.TopItem do
      begin
        Data := TGoogleDoc.Create(nil);
        TGoogleDoc(Data).Title := RootName;
      end;
    except
    end;
  end;
end;

Если мы попробуем присвоить свойству пустую строку, то корневой элемент создастся со значением по умолчанию:

resourcestring
  rsRootFolder = 'Мои документы';

Свойство Mode: TTreeMode — задает режим построения дерева. При этом класс TTreeMode определен следующим образом (три режима, как и говорилось выше):

type
  TTreeMode = (mFiles, mFolders, mBoth);

Опять же, в случае, если меняется значение этого свойства, то дерево пробует перестроится под нужный режим автоматически в методе SetMode:

procedure TGoogleVirtualTree.SetMode(Value: TTreeMode);
begin
  FMode := Value;
  Setup;
end;

Здесь мы вначале вычищаем все, что было ранее в узлах дерева, затем пересоздаем корневой элемент и после этого переходим к построению обновленного дерева. При этом по флагу FAssigned определятся — стоит ли вообще, что-либо менять, есть ли в списке хотя бы один документ для показа его в дереве.

Теперь переходим к методам. Первый метод, который передает сведения о составе документов – это метод AssignDocuments. Так как в работе со списком мы использовали дженерик, то метод получился следующий:

pprocedure TGoogleVirtualTree.AssignDocuments(ADocuments: TGoogleDocList);
begin
  FDocuments.Clear;
  if Assigned(ADocuments) then
  begin
    FDocuments.AddRange(ADocuments);
    FAssigned := FDocuments.Count > 0;
    Setup;
  end;
end;

Здесь мы вначале очищаем предыдущий список документов, а затем копируем все, что передано в параметре одним, пока ещё новым для нас методом, AddRange, который является стандартным для дженерика. В методе Setup происходит проверка состояния списка и построение дерева:

procedure TGoogleVirtualTree.Setup;
begin
  if FAssigned then
  begin
    Items.Clear;
    SetRootName(FRootName);
    GenerateTree('');
  end;
end;

То есть, если список документов содержит хотя бы один элемент, то дерево полностью очищается, устанавливается корневой элемент, а затем строится новое дерево. Для построения используется метод GenerateTree:

procedure TGoogleVirtualTree.GenerateTree(Path: string);
 
  procedure AddElement(Parent: TTreeNode; Document: TGoogleDoc);
  var
    Add: boolean;
  begin
    case Mode of
      mFolders:
        Add := (Document.DocType = dtFolder);
      mBoth:
        Add := true;
    end;
    if Add then
      Self.Items.AddChild(Parent, Document.Title).Data := pointer(Document)
  end;
 
var
  found, i: Integer;
  Folder: TGoogleDoc;
begin
  if (Mode = mFolders) or (Mode = mBoth) then
  begin
    found := FindFolder(Path, Folder);
    while (found > -1) do
    begin
      if (Folder.ParentID = '') then
        AddElement(TopItem, Folder)
      else
        for i := Items[Items.Count - 1].Parent.Index to Items.Count - 1 do
          if Items[i].Data <> nil then
            if TGoogleDoc(Items[i].Data).ID = Folder.ParentID then
            begin
              AddElement(Items[i], Folder);
              break;
            end;
      if (Folder.DocType = dtFolder) and (Mode <> mFiles) then
        GenerateTree(Folder.ID);
      found := FindFolder(Path, Folder, found + 1);
    end;
  end
  else
  begin
    for i := 0 to FDocuments.Count - 1 do
      if FDocuments[i].DocType <> dtFolder then
        Items.AddChild(Parent, FDocuments[i].Title).Data := pointer(FDocuments[i])
  end;
end;

В этом методе, в зависимости от заданного режима, выбирается одна из веток if..then..else и строится дерево. Самым простым с точки зрения построения, является режим mFiles — когда требуется просто отсеять элементы с типом dtFolder, а все остальные выставить дочерними к родительскому узлу — сформировать список документов. Эта операция происходит в else.

Более сложные манипуляции со списком происходят, когда выбран режим mFoders или mBoth — тогда мы выстраиваем дерево, используя рекурсию. При этом в для построения используются два дополнительных метода. Первый метод — FindFolder. Выполняет поиск элемента с заданным значением ParentId, то есть чью-то «дочку». При этом, для ускорения работы в метод можно передавать третьим параметром отступ от начала списка (offset: integer):

function TGoogleVirtualTree.FindFolder(const aParentID: string;
  var aDocument: TGoogleDoc; Offset: Integer): Integer;
var
  i: Integer;
begin
  for i := Offset to FDocuments.Count - 1 do
    if FDocuments[i].ParentID = aParentID then
    begin
      aDocument := FDocuments[i];
      Result := i;
      Exit;
    end;
  Result := -1;
end;

Второй метод — это вложенная процедура AddElement, которая проверяет соответствие элемента заданному режиму построения и в случае, если элемент подходит, то вставляет его в дерево. Код процедуры AddItem представлен в листинге выше.

Собственно на этом все – компонент готов и осталось его только зарегистрировать и проверить на работоспособность. Добавляем в модуль процедуру:

procedure Register;
 
implementation
 
procedure Register;
begin
  RegisterComponents('BuBa Group', [TGoogleVirtualTree]);
end;

Сохраняем модуль с названием GoogleTree.pas и переходим к третьему этап работы — регистрации компонента и его тестированию.

3. Тестируем работу дерева документов Google Docs

Создадим в Delphi новую группу проектов как показано на рисунке:

gTree

Пакет GoogleVirtualTree содержит два только что написанных модуля. С помощью него мы и зарегистрируем наше дерево в палитре компонентов. Жмем на пакете правой кнопкой мыши и выбираем в меню “Install”.

Теперь, как я и говорил в самом начале, для работы нам нужен компонент ClientLogin – качаем его их репозитория и также устанавливаем.

Теперь открываем главную форму приложения GTree.exe и укладываем на ней компоненты как показано на рисунке:

GTreeMain

В uses подключаем модуль GoogleDocument (GoogleTree подключится после того как бросите на форму компонент дерева). В секции private класса формы задаем две переменные:

 private
    FGoogleDocs: TGoogleDocList;
    FRequest: TGoogleRequest;

У компонента GoogleLogin1 свойство Service устанавливаем равным writley (сервис документов Google). Теперь переходим к обработчикам событий. На OnCreate формы пишем:

procedure TfMain.FormCreate(Sender: TObject);
begin
  FGoogleDocs := TGoogleDocList.Create;
  FRequest := TGoogleRequest.Create;
end;

На OnDestroy формы:

procedure TfMain.FormDestroy(Sender: TObject);
begin
  FGoogleDocs.Destroy;
  FRequest.Destroy;
end;

У компонента GoogleLogin1 создаем обработчик OnAutorization:

resourcestring
  rsOk = 'Авторизация прошла успешно';
  rsFail = 'Авторизация не удалась. Проверьте логин и пароль';
 
procedure TfMain.GoogleLogin1Autorization(const LoginResult: TLoginResult;
  Result: TResultRec);
begin
  if LoginResult = lrOk then
  begin
    FRequest.AuthKey := Result.Auth;
    ShowMessage(rsOk);
  end
  else
    raise Exception.Create(rsFail);
end;

Обработчик кнопки Login напишем следующим образом:

procedure TfMain.btnLoginClick(Sender: TObject);
begin
  GoogleLogin1.Email := edEmail.Text;
  GoogleLogin1.Password := edPass.Text;
  GoogleLogin1.Login();
end;

Теперь, в случае успешного логина, переменная FRequest получит необходимое значение ключа и можно будет получать список документов. Пишем обработчик кнопки «Получить список»:

procedure TfMain.btnGetListClick(Sender: TObject);
var
  Stream: TStream;
begin
  Stream := TMemoryStream.Create;
  try
    FRequest.GET
      ('https://docs.google.com/feeds/default/private/full?showfolders=true',
      Stream);
    FGoogleDocs.FillFromXML(Stream);
    lbDocCount.Caption := IntToStr(FGoogleDocs.Count);
  finally
    Stream.Free;
  end;
end;

Здесь мы загружаем полный список документов, включая коллекции, передаем полученный поток в переменную списка FGoogleDocs и заполняем список значениями. Для построения дерева у нас есть отдельная кнопка «Построить дерево», обработчик которой выглядит так:

procedure TfMain.btnTreeClick(Sender: TObject);
begin
  GoogleVirtualTree1.AssignDocuments(FGoogleDocs);
end;

Этой строки достаточно чтобы построить дерево. Группа RadioButton’ов с помощью которых меняются режимы построения дерева имеет такой обработчик OnClick:

procedure TfMain.rgTreeModeClick(Sender: TObject);
begin
  case rgTreeMode.ItemIndex of
    0:
      GoogleVirtualTree1.Mode := mFiles;
    1:
      GoogleVirtualTree1.Mode := mFolders;
    2:
      GoogleVirtualTree1.Mode := mBoth;
  end;
end;

И последний обработчик — это обработчик нашего дерева, с помощью которого мы будем выводить на панель информацию по выделенному элементу дерева:

procedure TfMain.GoogleVirtualTree1Click(Sender: TObject);
begin
  if GoogleVirtualTree1.Selected <> nil then
  begin
    with TGoogleDoc(GoogleVirtualTree1.Selected.Data) do
    begin
      case DocType of
        dtUnknown:
          lbDocType.Caption := 'Неизвестен';
        dtDocument:
          lbDocType.Caption := 'Документ';
        dtPdf:
          lbDocType.Caption := 'PDF';
        dtFolder:
          lbDocType.Caption := 'Коллекция';
        dtPresentation:
          lbDocType.Caption := 'Презентация';
        dtSpreadsheet:
          lbDocType.Caption := 'Таблица';
        dtForm:
          lbDocType.Caption := 'Форма';
        dtFile:
          lbDocType.Caption := 'Файл';
        dtDrawing:
          lbDocType.Caption := 'Картинка';
      end;
      lbTitle.Caption:=Title;
      lbID.Caption:=ID;
      lbIDParent.Caption:=ParentID;
    end;
  end;
end;

Проверяем работу программы. Запускаем проект, вводим логин и пароль и последовательно жмем «Логин», «Получить список», «Построить дерево». Если введены правильные логин и пароль, то Вы получите примерно такую картинку:

GTreeWorked

Достаточно ткнуть на любой режим построения дерева и дерево будет перестраиваться. Вот, например, режим “Только папки”:

GTreeWorked2

Все данные для заполнения сведений документа вытаскиваются из полей Data соответствующих узлов, что, как я и говорил вначале, избавляет нас от лишних операций поиска необходимого документа в списке.

Скачать исходник: Исходники —> API онлайн-сервисов —> Google API

Книжная полка

Описание: Рассмотрены практические вопросы по разработке клиент-серверных приложений в среде Delphi 7 и Delphi 2005 с использованием СУБД MS SQL Server 2000, InterBase и Firebird. Приведена информация о теории построения реляционных баз данных и языке SQL. Освещены вопросы эксплуатации и администрирования СУБД.
купить книгу delphi на ЛитРес
Описание: Рассмотрены малоосвещенные вопросы программирования в Delphi. Описаны методы интеграции VCL и API. Показаны внутренние механизмы VCL и приведены примеры вмешательства в эти механизмы. Рассмотрено использование сокетов в Delphi: различные режимы их работы, особенности для протоколов TCP и UDP и др.
купить книгу delphi на ЛитРес
0 0 голоса
Рейтинг статьи
уважаемые посетители блога, если Вам понравилась, то, пожалуйста, помогите автору с лечением. Подробности тут.
Подписаться
Уведомить о
0 Комментарий
Межтекстовые Отзывы
Посмотреть все комментарии