シート2の行順とA〜C列をシート1へ反映し、同じキーの行にあるD列以降の手入力値を引き継ぐGASの例です。初期状態は下見だけで、書き換えは行いません。自動実行トリガーは作成せず、手動で動かす一方向の同期です。
変更しないキーと値だけの練習表を用意する
ファイルを複製し、コピーのURLの/d/と/editの間にあるIDをspreadsheetIdへ設定します。シート2を最新の一覧、シート1を備考付きの一覧にします。両方の1行目を見出しにし、A〜C列の見出しをそろえます。シート1のD列以降に備考を入れます。各シート1000データ行以内、シート1はZ列以内の値だけの表が対象です。
A〜C列の組み合わせは両シートで一意で、同期の前後で変わらない値にしてください。例えばID・拠点コード・種別などです。名称やステータスなど変わる項目をキーにすると、変更後は別の行と判断され、元の備考を引き継げません。行位置が変わってもキーが同じ場合だけ備考を引き継ぎます。
このコードは数値1と文字列の1、日付型と日付を表す文字列を区別します。区切り文字を含む値も区別できるよう、型付きの配列をJSON化してキーにします。A〜C列がすべて空の行や重複キーは停止条件です。数式・結合セルも拒否します。書式、セルのメモ、入力規則、コメント、リッチテキストのリンクをキーに合わせて移す処理ではありません。これらを使わない練習表から始めます。
下見してから同期するコード
複製したファイルで「拡張機能」→「Apps Script」を開き、以下を保存します。apply:falseのままsyncSheetsByKeyAndOrderを実行し、追加・維持・除外件数を確認します。「除外」はシート2にないシート1の行で、その備考も反映後の一覧から外れます。シート2が空の場合は、全消去せず停止します。
const SYNC = {spreadsheetId:'COPY_SPREADSHEET_ID',master:'シート2',target:'シート1',apply:false};
function syncKey_(row) {
const parts=row.slice(0,3);
if(parts.every(v=>v===''))throw new Error('A:Cがすべて空の行があります');
return JSON.stringify(parts.map(v=>v instanceof Date?['date',v.getTime()]:[typeof v,v]));
}
function syncPlan_(master,target,width) {
const map=new Map(), seen=new Set();
for(const row of target){const k=syncKey_(row);if(map.has(k))throw new Error('シート1のキー重複');map.set(k,row);}
let added=0,matched=0;
const values=master.map(row=>{
const k=syncKey_(row);if(seen.has(k))throw new Error('シート2のキー重複');seen.add(k);
const previous=map.get(k);if(previous)matched++;else added++;
const out=previous?previous.slice():Array(width).fill('');
row.slice(0,3).forEach((v,i)=>out[i]=v);return out;
});
return {values,added,matched,removed:target.length-matched};
}
function syncSnapshot_(rows) {
return JSON.stringify(rows.map(row => row.map(v =>
v instanceof Date ? ['date', v.getTime()] : [typeof v, v])));
}
function syncRead_(sheet,width) {
const last=sheet.getLastRow();
if(last<1 || last>1001)throw new Error('見出しが必要です。データは1000行以内にしてください');
const range=sheet.getRange(1,1,last,width);
if(range.isPartOfMerge())throw new Error('結合セルは対象外です');
if(range.getFormulas().some(r=>r.some(Boolean)))throw new Error('数式は対象外です。値だけの練習表にしてください');
return range.getValues();
}
function syncSheetsByKeyAndOrder() {
const lock=LockService.getScriptLock();
if(!lock.tryLock(1000))throw new Error('別の同期が実行中です');
try {
if(SYNC.master===SYNC.target)throw new Error('異なるシートを指定してください');
const ss=SpreadsheetApp.openById(SYNC.spreadsheetId),m=ss.getSheetByName(SYNC.master),t=ss.getSheetByName(SYNC.target);
if(!m||!t)throw new Error('シート名を確認してください');
const width=Math.max(4,t.getLastColumn());
if(width>26)throw new Error('シート1はZ列までにしてください');
const master=syncRead_(m,3),target=syncRead_(t,width);
if(master.length<2)throw new Error('シート2が空のため停止します。全消去はしません');
if(JSON.stringify(master[0])!==JSON.stringify(target[0].slice(0,3)))throw new Error('A:Cの見出しをそろえてください');
const plan=syncPlan_(master.slice(1),target.slice(1),width);
console.log(JSON.stringify({追加:plan.added,維持:plan.matched,除外:plan.removed,反映:SYNC.apply}));
if(!SYNC.apply)return;
const backup=t.copyTo(ss).setName('同期前_'+Utilities.getUuid().replace(/-/g,''));
console.log('バックアップ:'+backup.getName());
if(syncSnapshot_(syncRead_(m,3))!==syncSnapshot_(master) || t.getLastColumn()>width || syncSnapshot_(syncRead_(t,width))!==syncSnapshot_(target))throw new Error('読み取り後に内容が変わりました。同期は中止しました');
const safe=v=>typeof v==='string' && /^[\s]*[=+@-]/.test(v)?"'"+v:v;
const values=plan.values.map(r=>r.map(safe));
if(t.getMaxRows()<values.length+1)t.insertRowsAfter(t.getMaxRows(),values.length+1-t.getMaxRows());
t.getRange(2,1,values.length,width).setValues(values);
if(target.length-1>values.length)t.getRange(values.length+2,1,target.length-1-values.length,width).clearContent();
} finally {
try {SpreadsheetApp.flush();}
finally {lock.releaseLock();}
}
console.log('同期処理終了。キーと備考を照合してください');
}
小さなデータで追加・除外・並び替えを確認する
シート1のA〜D列に、[A,東京,通常,メモA]、[B,大阪,通常,メモB]を入力します。シート2のA〜C列は、[B,大阪,通常]、[C,福岡,通常]の順にします。下見の想定は追加1・維持1・除外1です。
共同編集・並べ替え・他の更新処理を止めた状態でapply:trueに変更して実行します。シート1のコピー「同期前_固有ID」が作られた後、シート1はB・Cの順になり、BのD列はメモB、CのD列は空欄になります。Aの行とメモAは反映後の一覧から外れますが、変更前のコピーに残ります。確認したらapply:falseに戻してください。
下見の結果は保存された承認済み計画ではありません。apply:trueの実行時に再取得したデータを使います。実行内では書き込み直前にも値と型を比較し、日付が同じ表記の文字列に変わった場合も停止します。ただし、その後の人の編集や別プロジェクトの実行を完全に防ぐものではありません。ScriptLockは同じプロジェクトからの実行同士を制御します。
途中失敗と復旧時の確認
書き込みと余った行の消去は一括で元に戻せる処理ではありません。失敗したら連続実行せず、ログのバックアップ名と現在のシート1を確認してください。バックアップの作成・名前変更だけが済んで停止する場合や、一部を書き換えて停止する場合があります。 ロック解放前には、途中失敗時もSpreadsheetApp.flushで保留中の変更を反映します。これは取り消し処理ではありません。flushに失敗してもロックは解放し、同期処理終了のログは出しません。
復旧は、他の人による追加変更がないことを確認してから、変更前コピーまたはファイルのバックアップと照合して行います。シート名だけを入れ替えると他シートの参照に影響する場合があるため、元のシートを安易に削除しないでください。このコードは復旧やバックアップ削除を自動では行いません。
値の読み書きはRangeの公式仕様、バックアップ作成はSheet.copyToの公式仕様を参照しています。先頭が数式として扱われる文字列は、式にならないよう書き込み時に処理します。日付の表示形式など、値以外の設定は別途確認してください。
検証範囲と旧版の参考画像
2026年10月28日の確認はローカル模擬テストです。日付から文字列への変更検知と、書き込み・反映の失敗時のロック解放も確認しました。キーの衝突・重複・型の区別、備考の対応、下見、バックアップと書き込み先を検証しました。Googleの認可、実シート上の動作、日付や文字列の表示、復旧操作と画面確認は未実施です。以下の既存画像は旧版の例で、今回のコードの実機検証を示しません。






旧版のコードはMOTOKI-LLC/cg-method-codeにあります。本文との同期は未確認のため、異なる版を混ぜず本文コードを使ってください。





