Skip to content

Commit 46e41b2

Browse files
committed
Merge branch 'release/0.8.73'
2 parents c71ba0a + c1b47ef commit 46e41b2

4 files changed

Lines changed: 221 additions & 4 deletions

File tree

.version.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
"strategy": "semver",
33
"major": 0,
44
"minor": 8,
5-
"patch": 72,
5+
"patch": 73,
66
"build": 0
77
}

src/Cms/Cli/Commands/Install/UpgradeCommand.php

Lines changed: 147 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ public function configure(): void
5252
$this->addOption( 'migrations-only', 'm', false, 'Only copy new migrations' );
5353
$this->addOption( 'skip-views', null, false, 'Skip updating view files' );
5454
$this->addOption( 'force-views', null, false, 'Overwrite existing view files with package versions (destroys local view customizations)' );
55+
$this->addOption( 'prompt-views', null, false, 'Prompt before overwriting each view that is newer in the package and differs from the local copy' );
5556
$this->addOption( 'skip-migrations', null, false, 'Skip copying migrations' );
5657
$this->addOption( 'run-migrations', 'r', false, 'Run migrations automatically after copying' );
5758
}
@@ -84,10 +85,12 @@ public function execute( array $parameters = [] ): int
8485
// Check for updates
8586
$hasUpdates = $this->checkForUpdates();
8687

87-
// --force-views always has work to do (it re-publishes package views).
88-
$forceViews = (bool) $this->input->getOption( 'force-views' );
88+
// --force-views / --prompt-views always have potential work to do: they
89+
// re-examine published views even when the manifest version is unchanged.
90+
$forceViews = (bool) $this->input->getOption( 'force-views' );
91+
$promptViews = (bool) $this->input->getOption( 'prompt-views' );
8992

90-
if( !$hasUpdates && !$forceViews )
93+
if( !$hasUpdates && !$forceViews && !$promptViews )
9194
{
9295
$this->output->success( "✓ CMS is already up to date!" );
9396
return 0;
@@ -97,6 +100,10 @@ public function execute( array $parameters = [] ): int
97100
{
98101
$this->output->writeln( " ⚠️ --force-views: existing view files will be overwritten with package versions" );
99102
}
103+
elseif( $promptViews )
104+
{
105+
$this->output->writeln( " ℹ️ --prompt-views: you will be asked before overwriting each changed view" );
106+
}
100107

101108
// If --check flag, exit after displaying what would be updated
102109
if( $this->input->getOption( 'check' ) )
@@ -486,6 +493,29 @@ private function updateViews(): bool
486493
}
487494

488495
$force = (bool) $this->input->getOption( 'force-views' );
496+
$prompt = (bool) $this->input->getOption( 'prompt-views' );
497+
498+
// Interactive merge: add new views, and ask per file for views that are
499+
// newer in the package and differ from the local copy.
500+
if( $prompt && !$force )
501+
{
502+
$copied = $this->copyViewsInteractive( $viewSource, $viewDest );
503+
504+
if( $copied > 0 )
505+
{
506+
$this->output->writeln( "\n Copied $copied view file" . ( $copied !== 1 ? 's' : '' ) );
507+
}
508+
else
509+
{
510+
$this->output->writeln( " No view files copied" );
511+
}
512+
513+
$this->output->writeln( " ℹ️ Unchanged and declined views were left as-is" );
514+
$this->output->writeln( " Package views location: " . $viewSource . "/" );
515+
516+
return true;
517+
}
518+
489519
$copied = $this->copyNewViews( $viewSource, $viewDest, $force );
490520

491521
if( $copied > 0 )
@@ -583,6 +613,120 @@ private function copyNewViews( string $source, string $dest, bool $force = false
583613
return $copied;
584614
}
585615

616+
/**
617+
* Recursively copy views, prompting before overwriting changed files.
618+
*
619+
* Missing views are added automatically. An existing view is only offered
620+
* for overwrite when the package copy is newer (by modification time) AND
621+
* its contents differ from the local copy, so identical or locally-newer
622+
* files are skipped without noise.
623+
*
624+
* @param string $source Source directory
625+
* @param string $dest Destination directory
626+
* @return int Number of files copied
627+
*/
628+
private function copyViewsInteractive( string $source, string $dest ): int
629+
{
630+
$items = scandir( $source );
631+
632+
if( $items === false )
633+
{
634+
return 0;
635+
}
636+
637+
$copied = 0;
638+
639+
foreach( $items as $item )
640+
{
641+
if( $item === '.' || $item === '..' )
642+
{
643+
continue;
644+
}
645+
646+
$sourcePath = $source . '/' . $item;
647+
$destPath = $dest . '/' . $item;
648+
649+
if( is_dir( $sourcePath ) )
650+
{
651+
$copied += $this->copyViewsInteractive( $sourcePath, $destPath );
652+
continue;
653+
}
654+
655+
$relative = ltrim( str_replace( $this->_projectPath, '', $destPath ), '/' );
656+
657+
// New view: add without prompting.
658+
if( !file_exists( $destPath ) )
659+
{
660+
if( $this->copyViewFile( $sourcePath, $destPath ) )
661+
{
662+
$this->output->writeln( " ✓ Added: $relative" );
663+
$this->_messages[] = "Added view: $relative";
664+
$copied++;
665+
}
666+
667+
continue;
668+
}
669+
670+
// Only consider views the package updated more recently.
671+
if( filemtime( $sourcePath ) <= filemtime( $destPath ) )
672+
{
673+
continue;
674+
}
675+
676+
// Skip when contents are identical despite the newer timestamp.
677+
if( md5_file( $sourcePath ) === md5_file( $destPath ) )
678+
{
679+
continue;
680+
}
681+
682+
$packageDate = date( 'Y-m-d H:i', (int) filemtime( $sourcePath ) );
683+
$localDate = date( 'Y-m-d H:i', (int) filemtime( $destPath ) );
684+
685+
$this->output->writeln( "$relative (package $packageDate is newer than local $localDate)" );
686+
687+
if( !$this->confirm( " Overwrite this view?", false ) )
688+
{
689+
$this->output->writeln( " – Skipped: $relative" );
690+
continue;
691+
}
692+
693+
if( $this->copyViewFile( $sourcePath, $destPath ) )
694+
{
695+
$this->output->writeln( " ✓ Updated: $relative" );
696+
$this->_messages[] = "Updated view: $relative";
697+
$copied++;
698+
}
699+
}
700+
701+
return $copied;
702+
}
703+
704+
/**
705+
* Copy a single view file, creating the destination directory as needed.
706+
*
707+
* @param string $source Source file
708+
* @param string $dest Destination file
709+
* @return bool
710+
*/
711+
private function copyViewFile( string $source, string $dest ): bool
712+
{
713+
$destDir = dirname( $dest );
714+
715+
if( !is_dir( $destDir ) && !mkdir( $destDir, 0755, true ) && !is_dir( $destDir ) )
716+
{
717+
$this->output->error( " ✗ Failed to create directory: $destDir" );
718+
return false;
719+
}
720+
721+
if( !copy( $source, $dest ) )
722+
{
723+
$this->output->error( " ✗ Failed to copy: $dest" );
724+
return false;
725+
}
726+
727+
return true;
728+
}
729+
586730
/**
587731
* Scaffold the scheduled jobs configuration.
588732
*

tests/Unit/Cms/Cli/Commands/Install/UpgradeCommandTest.php

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,77 @@ public function testCopyNewViewsForceOverwritesExistingFiles(): void
161161
}
162162
}
163163

164+
public function testCopyViewsInteractiveOnlyPromptsForNewerChangedViews(): void
165+
{
166+
$base = sys_get_temp_dir() . '/neuron_cms_views_prompt_' . uniqid();
167+
$source = $base . '/source';
168+
$dest = $base . '/dest';
169+
170+
$old = time() - 1000;
171+
$mid = time() - 500;
172+
$new = time();
173+
174+
// a_new: only in the package -> added automatically (no prompt).
175+
$this->writeFile( $source . '/admin/a_new.php', 'PKG_A' );
176+
177+
// b_changed: differs and package is newer -> prompt (answered yes).
178+
$this->writeFile( $dest . '/admin/b_changed.php', 'LOCAL_B' );
179+
$this->writeFile( $source . '/admin/b_changed.php', 'PKG_B' );
180+
181+
// c_changed: differs and package is newer -> prompt (answered no).
182+
$this->writeFile( $dest . '/admin/c_changed.php', 'LOCAL_C' );
183+
$this->writeFile( $source . '/admin/c_changed.php', 'PKG_C' );
184+
185+
// d_same: identical contents though package is newer -> no prompt.
186+
$this->writeFile( $dest . '/admin/d_same.php', 'SAME' );
187+
$this->writeFile( $source . '/admin/d_same.php', 'SAME' );
188+
189+
// e_older: differs but package is older -> no prompt.
190+
$this->writeFile( $dest . '/admin/e_older.php', 'LOCAL_E' );
191+
$this->writeFile( $source . '/admin/e_older.php', 'PKG_E' );
192+
193+
// Local copies share a baseline mtime; package copies are newer except e.
194+
touch( $dest . '/admin/b_changed.php', $old );
195+
touch( $dest . '/admin/c_changed.php', $old );
196+
touch( $dest . '/admin/d_same.php', $old );
197+
touch( $dest . '/admin/e_older.php', $mid );
198+
199+
touch( $source . '/admin/b_changed.php', $new );
200+
touch( $source . '/admin/c_changed.php', $new );
201+
touch( $source . '/admin/d_same.php', $new );
202+
touch( $source . '/admin/e_older.php', $old );
203+
204+
// Prompts occur in scandir (alphabetical) order: b then c.
205+
$this->inputReader->addResponse( 'y' );
206+
$this->inputReader->addResponse( 'n' );
207+
208+
try {
209+
$reflection = new \ReflectionClass( $this->command );
210+
$method = $reflection->getMethod( 'copyViewsInteractive' );
211+
212+
$copied = $method->invoke( $this->command, $source, $dest );
213+
214+
// Only the added view and the accepted overwrite were copied.
215+
$this->assertEquals( 2, $copied );
216+
217+
// New view added.
218+
$this->assertEquals( 'PKG_A', file_get_contents( $dest . '/admin/a_new.php' ) );
219+
220+
// Accepted overwrite applied; declined one preserved.
221+
$this->assertEquals( 'PKG_B', file_get_contents( $dest . '/admin/b_changed.php' ) );
222+
$this->assertEquals( 'LOCAL_C', file_get_contents( $dest . '/admin/c_changed.php' ) );
223+
224+
// Identical and older-package views untouched and never prompted.
225+
$this->assertEquals( 'SAME', file_get_contents( $dest . '/admin/d_same.php' ) );
226+
$this->assertEquals( 'LOCAL_E', file_get_contents( $dest . '/admin/e_older.php' ) );
227+
228+
// Exactly two prompts were shown (b and c).
229+
$this->assertCount( 2, $this->inputReader->getPromptHistory() );
230+
} finally {
231+
$this->removeDirectory( $base );
232+
}
233+
}
234+
164235
public function testScaffoldScheduleConfigCreatesWhenMissingAndPreservesExisting(): void
165236
{
166237
$base = sys_get_temp_dir() . '/neuron_cms_schedule_' . uniqid();

versionlog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
## 0.8.73 2026-06-19
2+
13
## 0.8.72 2026-06-19
24

35
* Added repeating events.

0 commit comments

Comments
 (0)