Работа не теряется: автосохранение, видимое сохранение, запасной файл
Сохранение теперь отвечает в строке состояния: «Проект сохранён: имя, время». Раньше об этом знал только журнал, а он по умолчанию закрыт — нажал Ctrl+S и не знаешь, дошло ли до файла. Проект пишется во временный файл рядом и подставляется целиком; прежний остаётся как .bak. Оборванная запись больше не может оставить огрызок вместо проекта. Автосохранение: пока есть несохранённое, раз в полминуты рядом с проектом пишется копия .autosave. При открытии программа сравнивает её время с временем проекта и, если копия новее, показывает оба времени и предлагает её открыть. После сохранения проекта копия убирается. Журнал рядом с программой больше не переписывается при каждом запуске: запуски дописываются с заголовком, файл урезается при переросте. Прошлый запуск — как раз тот, в котором обычно и надо разбираться. Клавиша \ подтягивает окно к курсору и ставит его посередине: её жмут после реза или выделения. Переключатель «курсор посередине» остался кнопкой с целью.
This commit is contained in:
parent
86c28a3308
commit
18e55d466e
3 changed files with 319 additions and 20 deletions
24
README.md
24
README.md
|
|
@ -76,6 +76,7 @@ xdg-mime query default application/x-nlve-project
|
|||
|---|---|
|
||||
| Пуск и пауза | Пробел |
|
||||
| В начало / конец монтажа | Home / End |
|
||||
| Показать, где курсор (окно едет к нему) | \ |
|
||||
| Разрезать по курсору (все дорожки) | S |
|
||||
| Разрезать точно (перекодирует стык) | Shift+S |
|
||||
| Удалить выделенное | Delete |
|
||||
|
|
@ -144,7 +145,7 @@ xdg-mime query default application/x-nlve-project
|
|||
липнут при перетаскивании, курсор — при щелчке, и одно бывает нужно без
|
||||
другого.
|
||||
|
||||
**Курсор посередине** (кнопка с целью, клавиша `\`). Включено — при
|
||||
**Курсор посередине** (кнопка с целью). Включено — при
|
||||
воспроизведении монтаж едет под курсором, и всегда видно, что будет
|
||||
дальше. Выключено — окно не двигается вовсе, даже когда курсор ушёл за
|
||||
край: отмотал Shift с колесом — там и останешься. Раньше в выключенном
|
||||
|
|
@ -251,6 +252,27 @@ Ctrl+Shift+R, который схлопывает дыру под курсоро
|
|||
неправильно. Усиление меняет только рисунок, самого звука не касается, и
|
||||
запоминается между запусками.
|
||||
|
||||
## Сохранение и автосохранение
|
||||
|
||||
**Сохранение видно.** После Ctrl+S внизу появляется «Проект сохранён:
|
||||
имя, время». Раньше об этом говорила только строчка в журнале, а журнал
|
||||
по умолчанию закрыт: нажал — и непонятно, дошло до файла или нет.
|
||||
|
||||
**Запись не оставляет огрызков.** Проект пишется во временный файл рядом
|
||||
и подставляется на место настоящего, только когда записан целиком.
|
||||
Прежний файл при этом становится запасным — `имя.nlve.bak`. Падение или
|
||||
кончившееся место посреди записи больше не оставляют половину проекта.
|
||||
|
||||
**Копия пишется сама.** Пока в проекте есть несохранённое, раз в полминуты
|
||||
рядом появляется `имя.nlve.autosave`. Это не сохранение: файл проекта не
|
||||
трогается, признак «изменён» остаётся.
|
||||
|
||||
При открытии проекта программа смотрит, нет ли рядом копии новее его
|
||||
самого. Есть — значит прошлая работа до файла не дошла, и программа
|
||||
показывает оба времени и предлагает открыть копию. Отказ копию не
|
||||
удаляет: она лежит, пока проект не сохранят заново. После сохранения
|
||||
копия не нужна и убирается.
|
||||
|
||||
## Файл не совпадает с проектом
|
||||
|
||||
Проект имеет одно разрешение и один набор кодеков на всё — иначе куски не
|
||||
|
|
|
|||
|
|
@ -95,6 +95,10 @@ type
|
|||
FStarted: Boolean;
|
||||
|
||||
FPreviewTimer: TTimer;
|
||||
{ Автосохранение: копия проекта пишется сама собой, пока человек
|
||||
работает. Нужна ровно на один случай — работа пропала, а сохранить
|
||||
её забыли или сохранение не дошло до файла. }
|
||||
FAutoSaveTimer: TTimer;
|
||||
FPreviewDirty: Boolean;
|
||||
FUpdatingFromPlayer: Boolean;
|
||||
FLastLoadedEdl: string;
|
||||
|
|
@ -217,6 +221,8 @@ type
|
|||
FSeekWaitTicks: Integer;
|
||||
{ До какого времени ответ на команду держится в строке состояния. }
|
||||
FStatusHoldUntil: TDateTime;
|
||||
{ Автосохранение не удалось — жалуемся один раз, а не каждые полминуты. }
|
||||
FAutoSaveBroken: Boolean;
|
||||
|
||||
procedure ApplyVolumeAt(const ATime: Double);
|
||||
{ Переписывает файл субтитров и отдаёт его плееру. }
|
||||
|
|
@ -292,9 +298,16 @@ type
|
|||
procedure RemindAboutDeps;
|
||||
procedure Log(const AText: string);
|
||||
procedure LogToFile(const AText: string);
|
||||
{ Урезает файл журнала, когда он перерос предел. }
|
||||
procedure TrimLogFile;
|
||||
{ AAnswer — это ответ на команду человека, а не проходное сообщение:
|
||||
такой ответ держится в строке несколько секунд. }
|
||||
procedure SetStatus(const AText: string; AAnswer: Boolean = False);
|
||||
{ Автосохранение: копия рядом с проектом. }
|
||||
function AutoSavePath: string;
|
||||
procedure DropAutoSave;
|
||||
procedure AutoSaveTick(Sender: TObject);
|
||||
procedure OfferAutoSave;
|
||||
procedure UpdateCaption;
|
||||
procedure UpdateTimeLabel;
|
||||
procedure RefreshPool;
|
||||
|
|
@ -415,6 +428,18 @@ const
|
|||
затереть проходным сообщением. Четыре секунды — успеть прочесть. }
|
||||
StatusHoldTime = 4 / (24 * 60 * 60);
|
||||
|
||||
{ Как часто пишется копия проекта для восстановления. Полминуты — это
|
||||
потолок потерь: больше получаса работы человек в неё не вложит, а
|
||||
запись проекта стоит миллисекунды. }
|
||||
AutoSaveIntervalMs = 30 * 1000;
|
||||
|
||||
{ Приставка к имени файла проекта у автосохранённой копии. }
|
||||
AutoSaveSuffix = '.autosave';
|
||||
|
||||
{ Предел для файла журнала. Больше двух мегабайт читать всё равно
|
||||
никто не станет, а место жалко. }
|
||||
MaxLogFileSize = 2 * 1024 * 1024;
|
||||
|
||||
function FormatTimecode(const Seconds: Double): string;
|
||||
var
|
||||
Total, H, M, S, Ms: Integer;
|
||||
|
|
@ -460,6 +485,11 @@ begin
|
|||
FPreviewTimer.Interval := PreviewDebounceMs;
|
||||
FPreviewTimer.OnTimer := @PreviewTimerTick;
|
||||
|
||||
FAutoSaveTimer := TTimer.Create(Self);
|
||||
FAutoSaveTimer.Interval := AutoSaveIntervalMs;
|
||||
FAutoSaveTimer.OnTimer := @AutoSaveTick;
|
||||
FAutoSaveTimer.Enabled := True;
|
||||
|
||||
{ Список изображений держит миниатюры. Размер задан жёстко, и ffmpeg
|
||||
делает их ровно такими: список изображений в LCL требует одинакового
|
||||
размера у всех элементов. }
|
||||
|
|
@ -1245,11 +1275,14 @@ begin
|
|||
lstPool.ItemIndex := 0;
|
||||
end;
|
||||
|
||||
{ Спрашивать про клипы на чужих дорожках можно только теперь: окно
|
||||
показано, и вопрос не окажется за кадром. }
|
||||
{ Спрашивать можно только теперь: окно показано, и вопрос не окажется
|
||||
за кадром. Автосохранённая копия — первым делом: если прошлая работа
|
||||
не дошла до файла, человек должен узнать об этом до того, как начнёт
|
||||
правку поверх старого. }
|
||||
if FCheckMisplacedOnShow then
|
||||
begin
|
||||
FCheckMisplacedOnShow := False;
|
||||
OfferAutoSave;
|
||||
CheckMisplacedClips;
|
||||
end;
|
||||
end;
|
||||
|
|
@ -1334,12 +1367,57 @@ end;
|
|||
|
||||
{ --- Мелочи интерфейса --- }
|
||||
|
||||
{ Урезание журнала до последних строк, когда файл перерос предел.
|
||||
|
||||
Читается целиком: предел небольшой, и возиться с чтением с конца ради
|
||||
пары мегабайт незачем. }
|
||||
procedure TMainForm.TrimLogFile;
|
||||
var
|
||||
L: TStringList;
|
||||
Keep: Integer;
|
||||
Found: TSearchRec;
|
||||
Size: Int64;
|
||||
begin
|
||||
if not FileExists(FLogFilePath) then
|
||||
Exit;
|
||||
Size := 0;
|
||||
if FindFirst(FLogFilePath, faAnyFile, Found) = 0 then
|
||||
begin
|
||||
Size := Found.Size;
|
||||
FindClose(Found);
|
||||
end;
|
||||
if Size <= MaxLogFileSize then
|
||||
Exit;
|
||||
L := TStringList.Create;
|
||||
try
|
||||
try
|
||||
L.LoadFromFile(FLogFilePath);
|
||||
Keep := L.Count div 2;
|
||||
while L.Count > Keep do
|
||||
L.Delete(0);
|
||||
L.Insert(0, '=== начало журнала урезано ===');
|
||||
L.SaveToFile(FLogFilePath);
|
||||
except
|
||||
{ Не вышло урезать — не беда: журнал не работа, ради которой сюда
|
||||
пришли. }
|
||||
end;
|
||||
finally
|
||||
L.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
{ Дублирование журнала в файл рядом с программой.
|
||||
|
||||
Нужно ровно для одного случая: программа падает до того, как окно
|
||||
успело появиться. Тогда всё, что она о себе рассказала, пропадает
|
||||
вместе с окном, и причина остаётся неизвестной. Файл переписывается
|
||||
при каждом запуске: интересен последний.
|
||||
вместе с окном, и причина остаётся неизвестной.
|
||||
|
||||
Файл не переписывается при запуске, а дописывается: разбираться обычно
|
||||
приходится не с тем запуском, который идёт сейчас, а с прошлым, и
|
||||
затирать его нельзя — второй запуск программы уносил бы единственное
|
||||
свидетельство. Каждый запуск отделён строкой с датой и временем. Файл
|
||||
урезается, когда перерастёт предел: бесконечно растущий журнал никому
|
||||
не нужен.
|
||||
|
||||
Неудача записи ничего не значит — каталог может быть закрыт на запись,
|
||||
и работать это мешать не должно. }
|
||||
|
|
@ -1350,14 +1428,19 @@ begin
|
|||
if FLogFileBroken then
|
||||
Exit;
|
||||
try
|
||||
if not FLogFileStarted then
|
||||
begin
|
||||
TrimLogFile;
|
||||
FLogFileStarted := True;
|
||||
LogToFile('');
|
||||
LogToFile('=== запуск ' + FormatDateTime('dd.mm.yyyy hh:nn:ss', Now) +
|
||||
' ===');
|
||||
end;
|
||||
AssignFile(F, FLogFilePath);
|
||||
if FLogFileStarted then
|
||||
if FileExists(FLogFilePath) then
|
||||
Append(F)
|
||||
else
|
||||
begin
|
||||
Rewrite(F);
|
||||
FLogFileStarted := True;
|
||||
end;
|
||||
try
|
||||
WriteLn(F, AText);
|
||||
finally
|
||||
|
|
@ -2363,6 +2446,9 @@ begin
|
|||
FSettings.NoteRecentProject(FProject.FileName);
|
||||
RefreshRecentMenu;
|
||||
Log('Открыт проект: ' + FProject.FileName);
|
||||
{ Если рядом лежит копия новее проекта — значит прошлая работа не дошла
|
||||
до файла, и об этом надо сказать сразу при открытии. }
|
||||
OfferAutoSave;
|
||||
CheckMisplacedClips;
|
||||
end;
|
||||
|
||||
|
|
@ -3322,12 +3408,21 @@ begin
|
|||
Exit;
|
||||
end;
|
||||
|
||||
{ Обратная косая: пункта меню для неё нет намеренно — сочетание из
|
||||
одной такой клавиши в меню выглядит как опечатка, а работать должно. }
|
||||
{ Обратная косая — «покажи, где курсор»: окно едет к нему и ставит его
|
||||
посередине. Жмут её чаще всего после реза или выделения, когда курсор
|
||||
стоит на границе и надо к ней вернуться.
|
||||
|
||||
Раньше она переключала режим «курсор посередине». Переключатель
|
||||
остался кнопкой с целью внизу: включают его раз и надолго, а
|
||||
подтягивать окно к курсору нужно постоянно.
|
||||
|
||||
Пункта меню для неё нет намеренно — сочетание из одной такой клавиши
|
||||
в меню выглядит как опечатка, а работать должно. }
|
||||
if (Key = VK_OEM_5) and (Shift = []) then
|
||||
begin
|
||||
Key := 0;
|
||||
ToggleCenterPlayhead;
|
||||
FTimeline.ScrollToPlayhead(True);
|
||||
SetStatus('Окно у курсора: ' + FormatTimecode(FTimeline.Playhead), True);
|
||||
end;
|
||||
end;
|
||||
|
||||
|
|
@ -3391,10 +3486,134 @@ begin
|
|||
else
|
||||
begin
|
||||
UpdateCaption;
|
||||
Log('Проект сохранён.');
|
||||
{ Сохранение должно быть видно. Раньше о нём говорила только строчка
|
||||
в журнале, а журнал по умолчанию закрыт: человек нажимал Ctrl+S,
|
||||
ничего не происходило, и понять, дошло сохранение до файла или нет,
|
||||
было не по чему. }
|
||||
SetStatus('Проект сохранён: ' + ExtractFileName(FProject.FileName) +
|
||||
', ' + FormatDateTime('hh:nn:ss', Now), True);
|
||||
Log('Проект сохранён: ' + FProject.FileName);
|
||||
{ Копия для восстановления больше не нужна: в файле то же самое. }
|
||||
DropAutoSave;
|
||||
end;
|
||||
end;
|
||||
|
||||
{ Где лежит автосохранённая копия проекта. Рядом с самим проектом, чтобы
|
||||
она нашлась там же, где его ищут. У безымянного проекта её нет: некуда
|
||||
положить, и восстанавливать потом нечего. }
|
||||
function TMainForm.AutoSavePath: string;
|
||||
begin
|
||||
if FProject.FileName = '' then
|
||||
Result := ''
|
||||
else
|
||||
Result := FProject.FileName + AutoSaveSuffix;
|
||||
end;
|
||||
|
||||
procedure TMainForm.DropAutoSave;
|
||||
var
|
||||
Path: string;
|
||||
begin
|
||||
Path := AutoSavePath;
|
||||
if (Path <> '') and FileExists(Path) then
|
||||
DeleteFile(Path);
|
||||
end;
|
||||
|
||||
{ Копия проекта пишется, пока в нём есть несохранённое.
|
||||
|
||||
Это не замена сохранению: файл проекта не трогается, имя не меняется,
|
||||
признак «изменён» остаётся. Копия нужна на случай, когда работа всё же
|
||||
пропала — программу закрыли, она упала, сохранение не дошло до файла. }
|
||||
procedure TMainForm.AutoSaveTick(Sender: TObject);
|
||||
var
|
||||
Path, Err: string;
|
||||
begin
|
||||
if FProject = nil then
|
||||
Exit;
|
||||
Path := AutoSavePath;
|
||||
if Path = '' then
|
||||
Exit;
|
||||
{ Несохранённого нет — писать нечего. Копию при этом не трогаем: она
|
||||
может быть единственным следом прошлой работы, а спросить о ней ещё
|
||||
не успели. Убирается она там, где точно больше не нужна: после
|
||||
сохранения проекта и после ответа на вопрос при открытии. }
|
||||
if not FProject.Modified then
|
||||
Exit;
|
||||
if not FProject.SaveCopyTo(Path, Err) then
|
||||
begin
|
||||
{ Жалуемся один раз и больше не мешаем: автосохранение — подстраховка,
|
||||
а не работа, ради которой человек сюда пришёл. }
|
||||
if not FAutoSaveBroken then
|
||||
begin
|
||||
FAutoSaveBroken := True;
|
||||
Log('Автосохранение не работает: ' + Err);
|
||||
end;
|
||||
end
|
||||
else
|
||||
FAutoSaveBroken := False;
|
||||
end;
|
||||
|
||||
{ Проверка при открытии: не осталась ли копия новее самого проекта.
|
||||
|
||||
Так бывает ровно в одном случае — работа не дошла до файла проекта.
|
||||
Молча подменять файл нельзя, поэтому спрашиваем и показываем время. }
|
||||
procedure TMainForm.OfferAutoSave;
|
||||
var
|
||||
Path, Err: string;
|
||||
CopyStamp, ProjStamp: LongInt;
|
||||
CopyTime, ProjTime: TDateTime;
|
||||
begin
|
||||
Path := AutoSavePath;
|
||||
if (Path = '') or (not FileExists(Path)) then
|
||||
Exit;
|
||||
CopyStamp := FileAge(Path);
|
||||
ProjStamp := FileAge(FProject.FileName);
|
||||
if (CopyStamp < 0) or (ProjStamp < 0) then
|
||||
Exit;
|
||||
CopyTime := FileDateToDateTime(CopyStamp);
|
||||
ProjTime := FileDateToDateTime(ProjStamp);
|
||||
{ Копия старше проекта — значит проект сохранён позже неё, и она уже
|
||||
ни о чём не говорит. }
|
||||
if CopyTime <= ProjTime + 1 / (24 * 60 * 60) then
|
||||
begin
|
||||
DeleteFile(Path);
|
||||
Exit;
|
||||
end;
|
||||
|
||||
Log(Format('Найдена автосохранённая копия от %s, проект сохранён %s.',
|
||||
[FormatDateTime('dd.mm hh:nn:ss', CopyTime),
|
||||
FormatDateTime('dd.mm hh:nn:ss', ProjTime)]));
|
||||
|
||||
if ThemedMessageDlg('nlve',
|
||||
Format('Рядом с проектом лежит автосохранённая копия от %s, ' +
|
||||
'а сам проект сохранён %s.' + LineEnding + LineEnding +
|
||||
'Похоже, последние правки не дошли до файла проекта. ' +
|
||||
'Открыть копию?' + LineEnding + LineEnding +
|
||||
'Проект при этом не перезаписывается: копия просто откроется, и ' +
|
||||
'сохранить её поверх проекта можно будет самому. Откажетесь — ' +
|
||||
'копия останется лежать рядом, пока проект не сохранят заново.',
|
||||
[FormatDateTime('dd.mm.yyyy hh:nn:ss', CopyTime),
|
||||
FormatDateTime('dd.mm.yyyy hh:nn:ss', ProjTime)]),
|
||||
mtConfirmation, [mbYes, mbNo], 0) <> mrYes then
|
||||
Exit;
|
||||
|
||||
if not FProject.LoadFromFile(Path, Err) then
|
||||
begin
|
||||
ThemedMessageDlg('nlve', Err, mtError, [mbOK], 0);
|
||||
Exit;
|
||||
end;
|
||||
|
||||
{ Имя возвращаем проектное: копия открыта, но работать человек
|
||||
продолжает с проектом, и Ctrl+S должен писать в него. }
|
||||
FProject.FileName := Copy(Path, 1, Length(Path) - Length(AutoSaveSuffix));
|
||||
FProject.Modified := True;
|
||||
RefreshPool;
|
||||
UpdateCaption;
|
||||
FLastLoadedEdl := '';
|
||||
SchedulePreview;
|
||||
SetStatus('Открыта автосохранённая копия. Проверьте и сохраните: Ctrl+S.',
|
||||
True);
|
||||
end;
|
||||
|
||||
procedure TMainForm.MenuSaveProject(Sender: TObject);
|
||||
begin
|
||||
DoSaveProject;
|
||||
|
|
|
|||
|
|
@ -377,6 +377,9 @@ type
|
|||
|
||||
{ --- Файл проекта --- }
|
||||
function SaveToFile(const APath: string; out AError: string): Boolean;
|
||||
{ Копия проекта в другой файл: имя проекта и признак «изменён» не
|
||||
трогаются. Для автосохранения. }
|
||||
function SaveCopyTo(const APath: string; out AError: string): Boolean;
|
||||
function LoadFromFile(const APath: string; out AError: string): Boolean;
|
||||
|
||||
property Pool[AIndex: Integer]: TMediaItem read GetPoolItem;
|
||||
|
|
@ -1843,7 +1846,10 @@ begin
|
|||
end;
|
||||
end;
|
||||
|
||||
function TProject.SaveToFile(const APath: string; out AError: string): Boolean;
|
||||
{ Запись проекта в произвольный файл, не трогая ни имя проекта, ни признак
|
||||
«изменён». Нужна для автосохранения: копия пишется сама собой, и проект
|
||||
от этого не должен считаться сохранённым. }
|
||||
function TProject.SaveCopyTo(const APath: string; out AError: string): Boolean;
|
||||
var
|
||||
Text: TStringList;
|
||||
begin
|
||||
|
|
@ -1851,23 +1857,75 @@ begin
|
|||
Result := False;
|
||||
Text := TStringList.Create;
|
||||
try
|
||||
{ Имя файла назначаем до записи: от него зависит путь к кэшу, который
|
||||
попадает внутрь проекта косвенно — через сгенерированные клипы. }
|
||||
FFileName := ExpandFileName(APath);
|
||||
Text.Text := Serialize;
|
||||
try
|
||||
Text.SaveToFile(FFileName);
|
||||
FModified := False;
|
||||
Text.SaveToFile(APath);
|
||||
Result := True;
|
||||
except
|
||||
on E: Exception do
|
||||
AError := 'Не удалось сохранить проект: ' + E.Message;
|
||||
AError := 'Не удалось записать ' + APath + ': ' + E.Message;
|
||||
end;
|
||||
finally
|
||||
Text.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
{ Сохранение проекта.
|
||||
|
||||
Пишем сперва во временный файл рядом, и только когда он записан целиком,
|
||||
подставляем его на место настоящего. Прежний файл при этом становится
|
||||
запасным (.bak). Так падение или полный диск посреди записи не оставляют
|
||||
от проекта огрызок, а испорченное сохранение всегда можно откатить.
|
||||
|
||||
Признак «изменён» снимается только после того, как файл на месте: если
|
||||
запись не удалась, проект остаётся несохранённым и программа спросит
|
||||
о нём при выходе. }
|
||||
function TProject.SaveToFile(const APath: string; out AError: string): Boolean;
|
||||
var
|
||||
Full, Temp, Backup: string;
|
||||
begin
|
||||
AError := '';
|
||||
Result := False;
|
||||
{ Имя файла назначаем до записи: от него зависит путь к кэшу, который
|
||||
попадает внутрь проекта косвенно — через сгенерированные клипы. }
|
||||
Full := ExpandFileName(APath);
|
||||
FFileName := Full;
|
||||
Temp := Full + '.tmp';
|
||||
Backup := Full + '.bak';
|
||||
|
||||
if not SaveCopyTo(Temp, AError) then
|
||||
Exit;
|
||||
|
||||
try
|
||||
if FileExists(Full) then
|
||||
begin
|
||||
if FileExists(Backup) then
|
||||
DeleteFile(Backup);
|
||||
if not RenameFile(Full, Backup) then
|
||||
begin
|
||||
AError := 'Не удалось отложить прежний файл проекта: ' + Backup;
|
||||
Exit;
|
||||
end;
|
||||
end;
|
||||
|
||||
if not RenameFile(Temp, Full) then
|
||||
begin
|
||||
{ Подстановка не удалась — возвращаем прежний файл на место, чтобы
|
||||
не остаться вообще без проекта. }
|
||||
if FileExists(Backup) and (not FileExists(Full)) then
|
||||
RenameFile(Backup, Full);
|
||||
AError := 'Не удалось записать проект: ' + Full;
|
||||
Exit;
|
||||
end;
|
||||
|
||||
FModified := False;
|
||||
Result := True;
|
||||
except
|
||||
on E: Exception do
|
||||
AError := 'Не удалось сохранить проект: ' + E.Message;
|
||||
end;
|
||||
end;
|
||||
|
||||
function TProject.LoadFromFile(const APath: string; out AError: string): Boolean;
|
||||
var
|
||||
Text: TStringList;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue